文章目录
一、synchronized
1、synchronized的作用
- synchronized 表示同步的,指同一时刻,一个同步代码块(被synchronized修饰的方法或者代码块)只能被一个线程所获取并执行。
- 当代码块被 synchronized 修饰时,表示此内部的代码属于临界区,不可被多个线程同时访问,必须互斥访问该代码块。其执行原理则 synchronized 定义的锁机制,当线程进入该代码块前,需要获取到该对象的锁,才可顺利进入访问资源。
2、并发编程的三大特性
在了解并发编程的知识过程中,不免了解到并发的三大特性:原子性、可见性、有序性。而 synchronized 关键字内部对三特性都定义了相关实现,如下对三特性的解析
- 原子性:一个操作或者多个操作要么全部执行完成,要么全部不执行。synchronized 关键字可以确保只有一个线程可以拿到对象锁。
- 可见性:当一个线程对共享变量进行修改后,其他线程能看到。 synchronized 关键字通过内存屏障指令保证了可见性,比如线程A获取对象锁后,访问同步方法区,并修改了数据,则内存屏障指令会将数据(如变量)强制刷新到主内存中,而当线程B再次获取到这个对象锁时,访问本地缓存中数据时,会让缓存数据失效,要求线程从主内存中读取最新数据,这样就保证了可见性。
- 读屏障插入在读指令前面,清空缓存中的数据,使其失效,并从主内存中读取数据
- 写屏障插入在写指令后面,要求写入缓存的最新数据立刻刷新到主内存
当然内存屏障还有防止指令重排序问题,还有四种指令方式,需要了解的自行了解,这只是为了理解,就不拓开讲了
- 有序性:顾名思义,即程序的执行顺序会按照代码的先后顺序执行的。有时候,编译优化器和处理器可能针对代码顺序进行优化,达到效率最好的目的,但是并不是所以的重排序是好的,在并发操作中,可能会导致结果的不一致问题。而 synchronized 关键字保证了一个线程执行该同步方法区,相当于单线程,保证了结果的有序性,不会被其他线程干扰。
3、synchronized的使用方式
有三种应用方式:修饰普通方法、修饰静态方法、修饰代码块
修饰普通方法
public synchronized void 方法名() {
代码主体
}
修饰静态方法
public synchronized static void 方法名() {
代码主体
}
修饰代码块
public void 方法名() {
synchronized (this) {
代码主体
}
}
- 修饰普通方法
先来看一段没有被 sychronized 修饰的代码
public class Main implements Runnable {
共享资源
static int i = 0;
没有 synchronized 修饰的普通方法
public void add() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
add();
}
}
public static void main(String args[]) throws Exception {
建立了一个对象
Main mian = new Main();
Thread t1 = new Thread(mian);
Thread t2 = new Thread(mian);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果 i :" + i);
}
}
此时有两个线程,所以必然有两个线程在争抢资源,而 i++是复合操作,不是原子性的,需要取值、相加、赋值三个步骤,导致结果变小
最终结果 i :1914
那怎么办呢?那就让 i++ 变为原子性似的操作,而加上关键字 synchronized 即可实现该功能
public class Main implements Runnable {
共享资源
static int i = 0;
// synchronized 同步方法
public synchronized void add() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
add();
}
}
public static void main(String args[]) throws Exception {
建立了一个对象
Main mian = new Main();
Thread t1 = new Thread(mian);
Thread t2 = new Thread(mian);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果 i :" + i);
}
}
这是一个经典的案例,synchronized 修饰普通方法,使其结果不会被其他线程干扰
最终结果 i :2000
但是,我们也不难发现这是针对同一个对象下的发生情景,如果是实例化了多个对象呢?让我们继续往下看
- 修饰静态方法
直接看代码
public class Main implements Runnable {
共享资源
static int i = 0;
// synchronized 同步方法
public synchronized void add() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
add();
}
}
public static void main(String args[]) throws Exception {
实例化了两个对象
Thread t1 = new Thread(new Main());
Thread t2 = new Thread(new Main());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果 i :" + i);
}
}
发现add() 方法已经被 synchronized 修饰,但运行后,发现并不是每次结果都如我们所愿,有时候结果并不是 2000 ,而是小于2000
最终结果 i :1173
这是为什么呢?
首先我们需要明确的一点就是 synchronized 中所说争抢的锁,是针对对象的,即多线程并发状态下,所说的锁的细粒度大小就是对象。
而上面代码中,我们发现实例化了两个对象,所以线程t1、t2使用的是不同的对象锁,自然不能达到约束的效果。可以理解为类是一个模板,用它创造了不同对象,那自然有不同的锁了
那怎么可以让结果一致,符合我们的期望呢?
那就把锁加入模板呗,而我们知道静态方法是属于类的,把 synchronized关键字修饰在静态方法上,自然可以达到同样的效果,让线程竞争是同一把锁。
public class Main implements Runnable {
共享资源
static int i = 0;
// synchronized 同步方法
public synchronized static void add() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
add();
}
}
public static void main(String args[]) throws Exception {
实例化了两个对象
Thread t1 = new Thread(new Main());
Thread t2 = new Thread(new Main());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果 i :" + i);
}
}
输出结果如下,运行多次,依然达到我们的预期效果
最终结果 i :2000
- 修饰代码块
有时候,我们定义了一个功能模块,这个功能主体代码非常复杂,体量大,而其中只要一小部分代码需要同步条件。这样的情况下,我们直接用关键字 synchronized 修饰会导致整体代码性能降低,所以我们只需要对需要同步的代码进行修饰即可。所以定义了synchronized 修饰的代码块
public class Main implements Runnable {
共享资源
static int i = 0;
@Override
public void run() {
其他复杂代码.......
// synchronized修饰这一小块代码块
// this表示当前对象实例
synchronized (this){
for (int j = 0; j < 1000; j++) {
i++;
}
}
}
public static void main(String args[]) throws Exception {
Main main = new Main();
Thread t1 = new Thread(main);
Thread t2 = new Thread(main);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果 i :" + i);
}
}
运行多次,结果一致
最终结果 i :2000
二、synchronized 的四种锁
1、synchronized 底层原理
在学习四种锁时,先了解一下 synchronized 实现锁的原理
对于Java对象,它在内存中被划分为三部分:对象头、实例数据和填充对齐,我们这里主要针对对象头了解即可。
对象头中存储了 锁状态标志位、、存储到对象类型数据的指针、数组长度等,在32/64位虚拟机中,Java对象头的组成如下:
内容 | 说明 | 长度 |
---|---|---|
Mark Word | 存储对象的哈希码、分代年龄、锁标记位 | 32/64bit |
Class Metadata Address | 存储到对象类型数据的指针 | 32/64bit |
Array length | 数组的长度 | 32/64bit |
对于对象头,我们再把焦点聚集到 Mark Word 中,我们来看一下Mark Word的大致内容
锁状态 | 存储 | 偏向位 | 锁标志位 |
---|---|---|---|
无锁状态 | 存储对象的哈希码、分代年龄等 | 0 | 01 |
偏向锁 | 当前线程指针ID | 1 | 01 |
轻量级锁 | 指向栈帧的Lock Record的指针 | — | 00 |
重量级锁 | 指向堆互斥量的指针 | — | 10 |
结合上面的图文,总结而言呢,就是synchronized是基于对象实现的,对象在内存中布局分为对象头、实例数据和填充对齐三个部分,在运行期间,JVM将线程与对象的对象头关联起来,对象头中的Mark Word此部分,随着标志位的改变,里面存储的信息也会改变。比如是当前线程ID,则表示线程拿到了对象锁,为偏向锁(这部分还有挺多知识的,比如详细的指令实现,我没有深入了解,只是知道这个大概和流程,大家觉得不够理解的,再去看看别的博主文章,我这主要是记录自己学习,加深印象,见谅)
2、四种状态锁
最开始对象是没有锁的,即无锁状态,当仅且一个线程A访问了同步代码区,此时没有其他竞争线程,则会升级为偏向锁;
而有多个线程时,JVM 会把锁升级为轻量级锁来避免用户态和内核态切换的性能消耗;如果发生线程竞争,线程则会自适应自旋,让线程自旋等待一段时间,不会立马进入堵塞状态,但是这样长时间自旋会消耗CPU,如果持续获取不到对象会升级为重量级锁,线程堵塞等待。
锁升级大致过程
- 偏向锁
偏向锁是由无锁状态升级而成, 当处于多线程环境时,为了性能优化而设计的,目的是如果线程得到锁之后,可以消除线程重入的开销,提高性能;当多线程竞争时,争抢的线程会执行CAS操作,希望争取到锁,如果没有争取到,则升级为轻量锁
偏向锁执行步骤
看下图可知执行偏向锁为大致两个方法:有锁、无锁
无锁:判断是否可偏向,如果可偏向,则执行CAS操作,替换对象头的MarkWord的线程ID,如果失败则说明被其他线程抢占了锁,现在把锁升级为轻量级锁
有锁:判断是否是偏向锁,如果是则判断是否是当前自己线程占据了锁,是,则直接执行同步代码,如果不是则执行CAS操作,转为第一步的流程;如果有锁,但不是偏向锁,则可能是轻量级锁,也可能是重量级锁,根据锁的状态执行对应的操作。
CAS是什么?
CAS全名 Compare And Swap ,如字面意思比较并交换,CAS属于一种乐观锁的操作策略。
CAS操作包含了三个操作数:V、A、N,其中V表示内存值,E表示期望值,N表示新值。当内存值和期望值时,才会把内存值更新为新值,如果不等,则CAS操作失败,不做更新。
- 轻量级锁
轻量级锁由偏向锁升级而来,当处于多线程环境时,如果发生竞争,那轻量级锁不会立马转为重量级锁,不会让线程立马堵塞等待。而是让线程进入自旋等待中,等待时间和自旋次数由上一次线程争抢状态决定,上一次容易争取到,就可能会自旋次数多点,反之则减少自旋次数。如果持续没有拿到锁,则升级为重量级锁。
当偏向锁升级为轻量级锁的过程中,需要等待全局安全点,让所有的线程挂起,这样JVM才能安全标记对象,当符合全局安全点时,通过Markword中的线程ID获取当前占据对象的线程,在该线程的栈帧中升级为轻量级锁并保存锁记录(Lock Record),最后把对象的MarkWord更新为指向该锁记录的地址
简而言之:在线程中保存锁记录(Lock Record),并把对象中的MarkWord更新,把线程ID更新为锁记录地址(Lock Record),即地址指向了栈帧中的锁记录地址,并把锁标志位更新为00,此时升级成功。
如果线程B此时来竞争,会先判断对象的锁状态,然后发现有锁,是轻量级锁,如果WarkWord指向本身线程栈帧的锁记录,则直接执行(这是重入),如果不是,则执行CAS操作,抢占锁,成功即执行同步代码,失败则线程自旋等待,一直不成功则升级为重量级锁。
自旋理解
你可以理解为就是等一小会,比如高铁上厕所,你到了厕所门口,发现有人,你没有立马回到座位上去,而是在门口等待了一小会,期望对方很快就会好。
这样做有什么好处?
避免用户线程和内核的频繁切换带来的消耗,提高性能。
- 重量级锁
重量级锁基于底层的互斥锁实现,竞争时,没拿到锁的线程需要阻塞等待锁,保证只有一个线程此时占用资源并修改Mark Word使指针指向ObjectMonitor,如果拥有锁的线程执行完,则会释放锁后唤醒其他线程继续竞争锁。此时操作系统会切换用态和内核态,所以重量级锁效率很低。
重量级加加锁过程
1. 现在对象处于无锁状态,直接获取锁
2. 现在锁被当前线程占用,直接执行,属于重入
3. 当前锁指向当前线程帧记录,说明当前线程是之前轻量级锁的持有者,直接执行,属于重入
4. 如果发现有锁,且不符合前面三条。则尝试获取锁,不成功,则自旋等待,尝试获取锁
5. 自旋加锁失败,则加入堵塞队列头部,并再次尝试加锁
6. 如果还是加锁失败,则线程挂起,等待唤醒
这里重量级锁升级和加锁过程我了解不多,大家看看这个博主的,就是需要点耐心
重量级锁原理深入剖析
- synchronized 的可重入锁
可重入的条件即是当前获取的对象,是当前本身线程占据,也可以叫递归锁
表示当线程占用该锁后,需要再次调用该方法,则不要解锁加锁等操作,可以直接执行,并记录一个数加一,表示重入次数,释放锁则需要该重入次数为零。
比如一个方法,内部函数调用该方法,则直接执行,并重入次数加一,执行完,则减一,继续执行,直到减为0,此时,线程释放锁。
总结
- 同步方法,只能被获取对象锁的线程执行,其他非同步方法可被其他线程执行
- 锁的细粒度大小是对象,synchronized 的锁基于对象的
- synchronized 可以修饰普通方法,静态方法、代码块
- synchronized 的四种锁,依次升级表示为无锁、偏向锁、轻量级锁、重量级锁