13张图,深入理解Synchronized
前言
Java并发编程系列第二篇Synchronized
,文章风格依然是图文并茂,通俗易懂,本文带读者们由浅入深理解Synchronized
,让读者们也能与面试官疯狂对线。
在并发编程中Synchronized
一直都是元老级的角色,Jdk 1.6
以前大家都称呼它为重量级锁,相对于J U C
包提供的Lock
,它会显得笨重,不过随着Jdk 1.6
对Synchronized
进行各种优化后,Synchronized
性能已经非常快了。
内容大纲
Synchronized使用方式
Synchronized
是Java
提供的同步关键字,在多线程场景下,对共享资源代码段进行读写操作(必须包含写操作,光读不会有线程安全问题,因为读操作天然具备线程安全特性),可能会出现线程安全问题,我们可以使用Synchronized
锁定共享资源代码段,达到互斥(mutualexclusion
)效果,保证线程安全。
共享资源代码段又称为临界区(critical section
),保证临界区互斥,是指执行临界区(critical section
)的只能有一个线程执行,其他线程阻塞等待,达到排队效果。
Synchronized
的食用方式有三种
修饰普通函数,监视器锁( monitor
)便是对象实例(this
)修饰静态静态函数,视器锁( monitor
)便是对象的Class
实例(每个对象只有一个Class
实例)修饰代码块,监视器锁( monitor
)是指定对象实例
普通函数
普通函数使用Synchronized
的方式很简单,在访问权限修饰符与函数返回类型间加上Synchronized
。
多线程场景下,thread
与threadTwo
两个线程执行incr
函数,incr
函数作为共享资源代码段被多线程读写操作,我们将它称为临界区,为了保证临界区互斥,使用Synchronized
修饰incr
函数即可。
public class SyncTest {
private int j = 0;
/**
* 自增方法
*/
public synchronized void incr(){
//临界区代码--start
for (int i = 0; i < 10000; i++) {
j++;
}
//临界区代码--end
}
public int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
SyncTest syncTest = new SyncTest();
Thread thread = new Thread(() -> syncTest.incr());
Thread threadTwo = new Thread(() -> syncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果
System.out.println(syncTest.getJ());
}
}
代码十分简单,incr
函数被synchronized
修饰,函数逻辑是对j
进行10000
次累加,两个线程执行incr
函数,最后输出j
结果。
被synchronized
修饰函数我们简称同步函数,线程执行称同步函数前,需要先获取监视器锁,简称锁,获取锁成功才能执行同步函数,同步函数执行完后,线程会释放锁并通知唤醒其他线程获取锁,获取锁失败「则阻塞并等待通知唤醒该线程重新获取锁」,同步函数会以this
作为锁,即当前对象,以上面的代码段为例就是syncTest
对象。
线程 thread
执行syncTest.incr()
前线程 thread
获取锁成功线程 threadTwo
执行syncTest.incr()
前线程 threadTwo
获取锁失败线程 threadTwo
阻塞并等待唤醒线程 thread
执行完syncTest.incr()
,j
累积到10000
线程 thread
释放锁,通知唤醒threadTwo
线程获取锁线程 threadTwo
获取锁成功线程 threadTwo
执行完syncTest.incr()
,j
累积到20000
线程 threadTwo
释放锁
静态函数
静态函数顾名思义,就是静态的函数,它使用Synchronized
的方式与普通函数一致,唯一的区别是锁的对象不再是this
,而是Class
对象。
多线程执行Synchronized
修饰静态函数代码段如下。
public class SyncTest {
private static int j = 0;
/**
* 自增方法
*/
public static synchronized void incr(){
//临界区代码--start
for (int i = 0; i < 10000; i++) {
j++;
}
//临界区代码--end
}
public static int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
Thread thread = new Thread(() -> SyncTest.incr());
Thread threadTwo = new Thread(() -> SyncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果
System.out.println(SyncTest.getJ());
}
}
Java
的静态资源可以直接通过类名调用,静态资源不属于任何实例对象,它只属于Class
对象,每个Class
在J V M
中只有唯一的一个Class
对象,所以同步静态函数会以Class
对象作为锁,后续获取锁、释放锁流程都一致。
代码块
前面介绍的普通函数与静态函数粒度都比较大,以整个函数为范围锁定,现在想把范围缩小、灵活配置,就需要使用代码块了,使用{}
符号定义范围给Synchronized
修饰。
下面代码中定义了syncDbData
函数,syncDbData
是一个伪同步数据的函数,耗时2
秒,并且逻辑不涉及共享资源读写操作(非临界区),另外还有两个函数incr
与incrTwo
,都是在自增逻辑前执行了syncDbData
函数,只是使用Synchronized
的姿势不同,一个是修饰在函数上,另一个是修饰在代码块上。
public class SyncTest {
private static int j = 0;
/**
* 同步库数据,比较耗时,代码资源不涉及共享资源读写操作。
*/
public void syncDbData() {
System.out.println("db数据开始同步------------");
try {
//同步时间需要2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("db数据开始同步完成------------");
}
//自增方法
public synchronized void incr() {
//start--临界区代码
//同步库数据
syncDbData();
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
//自增方法
public void incrTwo() {
//同步库数据
syncDbData();
synchronized (this) {
//start--临界区代码
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
}
public int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
//incr同步方法执行
SyncTest syncTest = new SyncTest();
Thread thread = new Thread(() -> syncTest.incr());
Thread threadTwo = new Thread(() -> syncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是20000
System.out.println(syncTest.getJ());
//incrTwo同步块执行
thread = new Thread(() -> syncTest.incrTwo());
threadTwo = new Thread(() -> syncTest.incrTwo());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是40000
System.out.println(syncTest.getJ());
}
}
先看看incr
同步方法执行,流程和前面没区别,只是Synchronized
锁定的范围太大,把syncDbData()
也纳入临界区中,多线程场景执行,会有性能上的浪费,因为syncDbData()
完全可以让多线程并行或并发执行。
我们通过代码块的方式,来缩小范围,定义正确的临界区,提升性能,目光转到incrTwo
同步块执行,incrTwo
函数使用修饰代码块的方式同步,只对自增代码段进行锁定。
代码块同步方式除了灵活控制范围外,还能做线程间的协同工作,因为Synchronized ()
括号中能接收任何对象作为锁,所以可以通过Object
的wait、notify、notifyAll
等函数,做多线程间的通信协同(本文不对线程通信协同做展开,主角是Synchronized
,而且也不推荐去用这些方法,因为LockSupport
工具类会是更好的选择)。
wait:当前线程暂停,释放锁 notify:释放锁,唤醒调用了wait的线程(如果有多个随机唤醒一个) notifyAll:释放锁,唤醒调用了wait的所有线程
Synchronized原理
public class SyncTest {
private static int j = 0;
/**
* 同步库数据,比较耗时,代码资源不涉及共享资源读写操作。
*/
public void syncDbData() {
System.out.println("db数据开始同步------------");
try {
//同步时间需要2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("db数据开始同步完成------------");
}
//自增方法
public synchronized void incr() {
//start--临界区代码
//同步库数据
syncDbData();
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
//自增方法
public void incrTwo() {
//同步库数据
syncDbData();
synchronized (this) {
//start--临界区代码
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
}
public int getJ() {
return j;
}
}
为了探究Synchronized
原理,我们对上面的代码进行反编译,输出反编译后结果,看看底层是如何实现的(环境Java 11、win 10系统)。
只截取了incr与incrTwo函数内容
public synchronized void incr();
Code:
0: aload_0
1: invokevirtual #11 // Method syncDbData:()V
4: iconst_0
5: istore_1
6: iload_1
7: sipush 10000
10: if_icmpge 27
13: getstatic #12 // Field j:I
16: iconst_1
17: iadd
18: putstatic #12 // Field j:I
21: iinc 1, 1
24: goto 6
27: return
public void incrTwo();
Code:
0: aload_0
1: invokevirtual #11 // Method syncDbData:()V
4: aload_0
5: dup
6: astore_1
7: monitorenter //获取锁
8: iconst_0
9: istore_2
10: iload_2
11: sipush 10000
14: if_icmpge 31
17: getstatic #12 // Field j:I
20: iconst_1
21: iadd
22: putstatic #12 // Field j:I
25: iinc 2, 1
28: goto 10
31: aload_1
32: monitorexit //正常退出释放锁
33: goto 41
36: astore_3
37: aload_1
38: monitorexit //异步退出释放锁
39: aload_3
40: athrow
41: return
ps:对上面指令感兴趣的读者,可以百度或google一下“JVM 虚拟机字节码指令表”
先看incrTwo
函数,incrTwo
是代码块方式同步,在反编译后的结果中,我们发现存在monitorenter
与monitorexit
指令(获取锁、释放锁)。
monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入到同步代码块的结束位置,J V M
需要保证每一个 monitorenter
都有monitorexit
与之对应。
任何对象都有一个监视器锁(monitor
)关联,线程执行monitorenter
指令时尝试获取monitor
的所有权。
如果 monitor
的进入数为0
,则该线程进入monitor
,然后将进入数设置为1
,该线程为monitor
的所有者如果线程已经占有该 monitor
,重新进入,则monitor
的进入数加1
线程执行 monitorexit
,monitor
的进入数-1,执行过多少次monitorenter
,最终要执行对应次数的monitorexit
如果其他线程已经占用 monitor
,则该线程进入阻塞状态,直到monitor
的进入数为0,再重新尝试获取monitor
的所有权
回过头看incr
函数,incr
是普通函数方式同步,虽然在反编译后的结果中没有看到monitorenter
与monitorexit
指令,但是实际执行的流程与incrTwo
函数一样,通过monitor
来执行,只不过它是一种隐式的方式来实现,最后放一张流程图。
Synchronized优化
Jdk 1.5
以后对Synchronized
关键字做了各种的优化,经过优化后Synchronized
已经变得原来越快了,这也是为什么官方建议使用Synchronized
的原因,具体的优化点如下。
锁粗化 锁消除 锁升级
锁粗化
互斥的临界区范围应该尽可能小,这样做的目的是为了使同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,锁粗化就是将「多个连续的加锁、解锁操作连接在一起」,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
J V M
会检测到一连串的操作都对同一个对象加锁(for
循环10000
次执行j++
,没有锁粗化就要进行10000
次加锁/解锁),此时J V M
就会将加锁的范围粗化到这一连串操作的外部(比如for
循环体外),使得这一连串操作只需要加一次锁即可。
锁消除
Java
虚拟机在JIT
编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。
代码中使用Object
作为锁,但是Object
对象的生命周期只在incrFour()
函数中,并不会被其他线程所访问到,所以在J I T
编译阶段就会被优化掉(此处的Object
属于没有逃逸的对象)。
锁升级
Java
中每个对象都拥有对象头,对象头由Mark World
、指向类的指针、以及数组长度三部分组成,本文,我们只需要关心Mark World
即可,Mark World
记录了对象的HashCode
、分代年龄和锁标志位信息。
Mark World简化结构
锁状态 | 存储内容 | 锁标记 |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
读者们只需知道,锁的升级变化,体现在锁对象的对象头Mark World
部分,也就是说Mark World
的内容会随着锁升级而改变。
Java1.5
以后为了减少获取锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,Synchronized
的升级顺序是 「无锁-->偏向锁-->轻量级锁-->重量级锁,只会升级不会降级」
偏向锁
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁,其目标就是在只有一个线程执行同步代码块时,降低获取锁带来的消耗,提高性能(可以通过J V M参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态)。
线程执行同步代码或方法前,线程只需要判断对象头的Mark Word
中线程ID
与当前线程ID
是否一致,如果一致直接执行同步代码或方法,具体流程如下
无锁状态,存储内容「是否为偏向锁( 0
)」,锁标识位01
CAS
设置当前线程ID到Mark Word
存储内容中是否为偏向锁 0
=> 是否为偏向锁1
执行同步代码或方法 偏向锁状态,存储内容「是否为偏向锁( 1
)、线程ID」,锁标识位01
对比线程 ID
是否一致,如果一致执行同步代码或方法,否则进入下面的流程如果不一致, CAS
将Mark Word
的线程ID
设置为当前线程ID
,设置成功,执行同步代码或方法,否则进入下面的流程CAS
设置失败,证明存在多线程竞争情况,触发撤销偏向锁,当到达全局安全点,偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后在安全点的位置恢复继续往下执行。
轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,持有锁时间也不长的场景。因为阻塞线程需要C P U
从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失,所以干脆不阻塞这个线程,让它自旋一段时间等待锁释放。
当前线程持有的锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。轻量级锁的获取主要有两种情况:① 当关闭偏向锁功能时;② 多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
无锁状态,存储内容「是否为偏向锁( 0
)」,锁标识位01
关闭偏向锁功能时 CAS
设置当前线程栈中锁记录的指针到Mark Word
存储内容锁标识位设置为 00
执行同步代码或方法 释放锁时,还原来 Mark Word
内容轻量级锁状态,存储内容「线程栈中锁记录的指针」,锁标识位 00
(存储内容的线程是指"持有轻量级锁的线程")CAS
设置当前线程栈中锁记录的指针到Mark Word
存储内容,设置成功获取轻量级锁,执行同步块代码或方法,否则执行下面的逻辑设置失败,证明多线程存在一定竞争,线程自旋上一步的操作,自旋一定次数后还是失败,轻量级锁升级为重量级锁 Mark Word
存储内容替换成重量级锁指针,锁标记位10
重量级锁
轻量级锁膨胀之后,就升级为重量级锁,重量级锁是依赖操作系统的MutexLock
(互斥锁)来实现的,需要从用户态转到内核态,这个成本非常高,这就是为什么Java1.6
之前Synchronized
效率低的原因。
升级为重量级锁时,锁标志位的状态值变为10
,此时Mark Word
中存储内容的是重量级锁的指针,等待锁的线程都会进入阻塞状态,下面是简化版的锁升级过程。
历史好文推荐
关于我
这里是阿星,一个热爱技术的Java程序猿,公众号 「程序猿阿星」 里将会定期分享操作系统、计算机网络、Java、分布式、数据库等精品原创文章,2021,与您在 Be Better 的路上共同成长!。
非常感谢各位小哥哥小姐姐们能看到这里,原创不易,文章有帮助可以关注、点个赞、分享与评论,都是支持(莫要白嫖)!
愿你我都能奔赴在各自想去的路上,我们下篇文章见
- END -