synchronized锁升级过程分析
摘要
本文的目的在于从主流程上说明以下问题:
- synchronized修饰对象的锁一共有几种状态
- synchronized修饰对象锁升级的过程是怎样的
本文的研究方法是查看openjdk的官方文档,链接为https://wiki.openjdk.java.net/display/HotSpot/Synchronization#Synchronization-Russel06。建议大家也可以直接看官方文档,描述肯定会更加准确。
由于对象的锁信息和对象的内存布局有关,所以在论述正文之前,会先对对象的内存布局做一个介绍。
如何分析对象的内存结构?
对象分为2种,分别是普通对象和数组,本文只介绍普通对象。
要想深刻理解对象的内存布局,可以借助一个非常好用的工具,jol,maven依赖如下:
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
使用一行代码即可打印对象内存结构
Object o=new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());//打印完整布局
样例结果如下
对于上述样例结果,解析的方式如下:
- offset代表字节偏移量,并且右边的VALUE从左到右,offset依次提高(这一点和平常我们看到的左边的是高位有点不一样)。
对象内存结构是怎样的?
通过jol,我们可以打印出object的内存结构,包括4个部分,markword、类型指针、实例数据和对齐。其中,markword和类型指针统称为对象头。
-
markword:
- offset是0~7,占对象头的前8个字节。
-
类型指针:
- 压缩后4个字节,如果不开启压缩,则为8个字节
-
实例数据:
- 如果实例中有类型指针,每个4字节
-
对齐(padding):
-
当一个对象的大小不能被8整除,就会补齐。原因在于cpu读写数据时,按照总线的宽度来读(再细就不知道了),当一块内存大小是8的整数倍,读写的效率会更高。
-
对于一个空的Object实例,有4位用于对齐,此时在layout中会显示如下信息
loss due to the next object alignment
-
markword如何记录锁信息
java利用markword的最低3位来记录所信息,在不同的状态下,锁信息如下图所示
如上图所示,markword布局包含以下信息
-
hashCode信息
-
对象的分代年龄
-
锁信息,用最低的3位记录锁信息,其中第3位记录偏向锁信息,最低的2位记录锁状态。各种锁对应的标志:
- 001=无锁(1)
- 101=偏向锁(5)
- 000=自旋锁(0)
- 010=重量级锁(2)
这个结论建议强行记住,就像学英语记单词,非常重要。
synchronized锁升级过程(马老师版)
这一版升级过程参考马士兵的课程理论,基于jdk11进行的描述
基于上文的描述,对锁的四种状态进行简单的总结
- 采用synchronized进行加锁,锁状态一共有4个,分别是无锁、偏向锁、轻量级锁、重量级锁
- 无锁状态:
- 当一个对象被创建出来,如果还没有开启偏向锁机制,最低的3位是001,代表没有加锁
- 偏向锁状态:
- 当偏向锁机制启动(默认程序启动后过4s),新建的对象就会加上匿名偏向锁,锁信息记录的值是101。此时代表可偏向但是没有偏向具体的线程。
- 轻量级锁(自旋锁)状态
- 当有2个线程同时竞争一个锁时,jvm自动将偏向锁升级为轻量级锁
- 轻量级锁的好处在于不需要向内核申请锁,在用户态就可以完成;弊端在于,每个线程需要不断循环获取锁,会消耗CPU。
- 重量级锁
- 当线程的竞争比较激烈(JVM自动判断),就会将轻量级锁升级为重量级锁,此时会向内核申请一个锁,这个锁有一个等待队列,没有获取到锁的线程会加入等待队列,从而不需要消耗CPU
- 重量级锁的好处在于,高并发情况下,减少了线程自旋带来的cpu消耗,弊端在于需要向内核申请,带来了性能消耗
synchronized锁升级过程(官方版)
下面,再按照官方文档,描述一下锁升级的过程
官方的锁升级过程如下图所示(JDK8)
假设我们开启了偏向锁机制,并且对象是在偏向锁延时后创建的,则一个完整的锁升级过程如下
- 对象初始化:
- 当一个对象被创建后,markword的锁状态是101,并且记录线程ID=0,此时代表这个对象是可偏向并且尚未偏向
- 激活偏向锁:
- 在第一个线程尝试获得偏向锁时,通过CAS将自己的ID写入到markword中,此时可以说这个对象偏向了这个线程。后续这个线程再次进行加锁或者解锁时,不需要更新markword。
- 偏向锁只能用一次,偏向锁一旦加上,即使同步代码执行结束,也不会撤销,当对象执行hashcode方法或者由新的线程来竞争,才会撤销。
- 撤销偏向锁:
- 当另一个线程尝试获得同一个对象的锁时,jvm会立刻撤销偏向锁,并将对象设置为无锁状态,接下来进入轻量级锁的竞争机制。
- 轻量锁竞争:
- 当一个线程想要锁定一个对象时,会将对象的markword和对象指针记录到线程栈中的Lock Record中。
- 然后JVM会尝试通过CAS的方法,将这个线程的LR指针写入到对象的markword中,如果写入成功,这个线程就会拥有对象的锁,此时markword的最低2位是00,表示这个对象被锁定。此处要注意的是,无论这个对象是否持有这个锁,每次上锁时,JVM都会执行CAS操作。
- 如果JVM为某个线程执行CAS失败,则JVM首先会判断,对象的markword中LR指针是否指向当前线程的LR,如果是,则说明这个线程已经持有了这个锁,则可以安全地执行同步代码。这就是一个锁重入的过程。
- 轻量级锁最大的问题在于,每次执行锁操作时,都会在多核CPU中执行一次CAS操作,非常耗费性能,即使当前线程已经有锁了,还是会执行一次。
- 重量级锁:
- 当2个以上线程同时竞争一个对象的锁时,轻量级锁就会升级为一个重量级锁,重量级锁好像是一个重量级的监控器,可以调度等待的线程。
如果程序没有开启偏向锁机制或者对象在偏向锁还在延时的阶段创建,则直接跳过偏向锁,从轻量级锁开始竞争。
此外,还需要补充一个知识点如下:偏向锁机制延时开启原理
偏向锁机制的打开存在延时,默认是4s,注意,不是对象创建后的4s,而是程序运行后的4s,如果在4s内创建的对象,就不会开启偏向锁,直接升级到轻量级锁。
其他注意事项
- 如何开启或关闭偏向锁机制:-XX:+UseBiasedLocking;-XX:-UseBiasedLocking
- 如何设置偏向锁延时:-XX:BiasedLockingStartupDelay=0.
参考资料
- 马士兵多线程与高并发](https://www.bilibili.com/video/BV16Q4y1N7AU?p=4)
- open jdk官方说明【非常好用】