35、synchronized(下)
内容来自王争 Java 编程之美
有赞技术团队:Java 锁与线程的那些事
美团技术团队:不可不说的 Java "锁" 事
上一节,我们讲解了 synchronized 底层用到的重量级锁的实现原理,重量级锁要维护等待队列(_cxq、_EntryList)
并且还要调用操作系统的系统调用(例如 pthread_mutext_lock、pthread_cond_wait、pthread_cond_signal、pthread_mutext_unlock)来阻塞和唤醒线程
涉及到用户态和内核态的切换,因此加锁、解锁比较耗时
在 JDK 1.6 版本中,Java 对 synchronized 做了较大的优化,引入了:偏向锁、轻量级锁、锁消除、锁粗化等优化手段,进一步提高了加锁、解锁的性能
本节我们就来详细讲解一下这些优化手段
1、偏向锁
引入偏向锁、轻量级锁,是基于这样一个推断:尽管我们需要对存在线程安全问题的代码加锁,但是这只是出于防御的目的
实际上,出现同一时刻多个线程竞争锁的概率很小,甚至一个锁在大部分情况下都只被一个线程使用
1.1、synchronized 锁
对于一个 synchronized 锁
- 如果它只被一个线程使用,那么 synchronzied 锁底层使用 "偏向锁" 来实现
- 如果它被多个线程交叉使用(你用完我再用),不存在竞争使用的情况,那么 synchronized 锁底层使用 "轻量级锁" 来实现
- 如果它存在被竞争使用的情况,那么 synchronized 锁底层使用 "重量级锁" 来实现
1.2、Mark Word 结构
上一节我们讲到,重量级锁需要用到对象头的 Mark Word,实际上,偏向锁和轻量级锁也需要用到 Mark Word
Mark Word 是一个可变字段,在不同的情况下,存储不同的内容,这样做的目的是为了节省存储空间,减少对象对内存空间的占用
因为 64 位 JVM 是主流,32 位 JVM 已经很少使用,所以我们不再对 32 位 JVM 的 Mark Word 做介绍
在 64 位 JVM 中,Mark Word 长度为 8 字节,也就是 64bits,其结构如下所示
根据锁标志位的不同,Mark Word 存储的内容也不同,例如锁标志位为 10 时,Mark Word 存储的是指向 Monitor 锁的指针
1.3、偏向锁的实现原理
大概了解了 Mark Word 的结构之后,接下来我们先来看下偏向锁的实现原理
- 当一个对象刚被创建时,Mark Word 处于无锁状态,并随即很快变为偏向锁状态
如果我们设置 JVM 参数 -XX:BiasedLockingStartupDelay=0,那么 Mark Word 会在对象被创建之后,直接进入偏向锁状态
总之,新诞生的对象在没有任何操作之前,最终会进入偏向锁状态,此时 Mark Word 字段中的 threadID 为 0,意思是还没有线程持有偏向锁
这里你可能会有点疑惑,新创建的对象不应该进入无锁状态吗?为什么会进入偏向锁状态呢?关于这一点,我们待会再解释 - 如果某个线程在某个对象上使用 synchronized 关键字,发现这个对象的 Mark Word 处于偏向锁状态,并且 threadID 为 0
那么这就说明这个偏向锁还没有被使用过,这个线程就会使用 CPU 提供的 CAS 原子操作来竞争这个偏向锁
这里的 CAS 操作指的是:先检查 Mark Word 值是否等于 5(5 就表示偏向锁状态,并且 threadID 是 0)
如果 Mark Word 等于 5,再设置 threadID 的值为自己的线程 ID,获取偏向锁成功
以上 CAS 操作需要使用硬件层面提供的 CPU 指令来完成,以保证原子性和线程安全性 - 按照前面的假设,大部分情况下,一个锁只被一个线程使用,因此大部分情况下,线程执行 CAS 操作都会成功获取到了偏向锁
如果线程执行 CAS 操作失败,说明其他线程先它一步,设置了 threadID,抢占了偏向锁
对于获取偏向锁失败的情况,涉及到的偏向锁的升级,稍后再讲,我们先看线程成功获取到偏向锁这种情况 - 线程成功获取到偏向锁之后,就去执行业务代码了(也就是 synchronized 关键字所包围的代码)
执行完业务代码之后,线程并不会解锁偏向锁,也就是不会更改 Mark Word 字段将 threadID 设置为 0
这是偏向锁有别于轻量级锁和重量级锁,非常独特的一点,这样做的目的是提高加锁的效率 - 当同一个线程再次请求这个偏向锁时,如下代码所示
线程查看 Mark Word,发现 Mark Word 处于偏向锁状态,并且 threadID 值就是自己的线程 ID,这时线程不需要做任何加锁操作,就直接可以去执行业务代码了
public class Demo { private static int count = 0; private static Object obj = new Object(); public static void main(String[] args) { synchronized (obj) { // 处于偏向锁状态 count++; } // ... synchronized (obj) { // 再次请求偏向锁 count--; } } }
上一节我们讲到,为了保证 CAS 操作的原子性和线程安全性
CAS 操作一般使用硬件层面提供的 CPU 指令来实现,本质上就是在硬件层面上对 CAS 操作加锁(总线锁),在多核计算机上,这样的做法的执行效率比较低
因此减少 CAS 操作也会大大提高加锁的性能,而这正是偏向锁相对于轻量级锁更加高性能的地方(轻量级锁虽然也不需要排队线程、不需要通过操作系统的系统调用去阻塞和唤醒线程,但仍然需要大量的 CAS 操作)
线程只需要在第一次获取偏向锁时,使用一次 CAS 操作,之后再次加锁,就不再需要执行 CAS 操作了
1.4、非理想情况
以上讲的是理想情况,即在对象有限的生命周期里,这个对象对应的锁只被一个线程使用,接下来我们再来看看非理想情况,非理想情况有两种
- 前面已经提到了一种:对象诞生之后处于偏向锁状态,但还没被任何线程获取过
两个线程通过 CAS 操作竞争偏向锁,一个线程获取到偏向锁,另一个线程没有获取到偏向锁,这个时候,另一个线程该咋办? - 我们再来看第二种非理想情况:一个线程获取了某个偏向锁,但之后又有另一个线程请求这个偏向锁,如下代码所示
这个时候,另一个线程该怎么办?实际上,第一种情况是第二种情况的特殊情况
public class Demo { private static int count = 0; private static Object obj = new Object(); public static void main(String[] args) throws InterruptedException { // 主线程获取了偏向锁 synchronized (obj) { count++; } Thread t = new Thread(new Runnable() { @Override public void run() { // 线程 t 又请求偏向锁 synchronized (obj) { count--; } } }); t.start(); t.join(); } }
对于以上两种非理想情况,显然已经不再符合偏向锁的应用场景了(一个锁只被一个线程使用),这个时候,"请求偏向锁的线程" 就会将 "偏向锁" 升级为 "轻量级锁"
前面讲到,偏向锁不会主动解锁,线程使用完偏向锁(退出 synchronized 作用范围)之后,仍然保持持有状态(Mark Word 中的 threadID 的值仍然是这个线程的 ID)
因此 "升级偏向锁" 时
- 虚拟机需要 "暂停持有偏向锁的线程",然后查看它 "是否还在使用这个偏向锁"(是否还在执行 synchronized 代码块中的代码)
- 如果线程已经 "不再使用这个偏向锁了",那么虚拟机就将 Mark Word 设置为 "无锁" 状态
- 如果线程 "还在使用这个偏向锁",那么虚拟机就将 "偏向锁升级为轻量级锁"
关于以上升级过程,有几点需要进一步解释一下
1、首先:偏向锁升级时,为什么要暂停持有偏向锁的线程?
这是因为虚拟机要 "根据持有偏向锁的线程是否正在使用偏向锁",来决定是将 "偏向锁" 转为 "无锁状态" 还是 "轻量级锁"
实际上,这个过程也是 "先检查后设置" 这类复合操作,但是检查持有偏向锁的线程是否正在使用偏向锁,这个过程比较复杂,无法使用 CPU 提供的原子 CAS 指令来实现
于是这个过程就存在线程安全问题,如下图所示,为了解决这个问题,偏向锁升级时,虚拟机需要暂停持有偏向锁的线程
2、其次:如何暂停持有偏向锁的线程?
当然我们可以使用操作系统提供的挂起线程的系统调用来实现,但是这类系统调用在不同平台上表现不一样,在某些平台上,会导致 IO 操作出问题
因此虚拟机最终选择复用垃圾回收器中的 STW(Stop The World)功能,来暂停持有偏向锁的线程
3、最后:如果持有偏向锁的线程没有在使用偏向锁
那么能否不把 Mark Word 变为无锁状态,而继续保持偏向锁状态(只把 threadID 设置为 0),将偏向锁移交给另一个是线程使用?
之所以没有这么做,是因为 STW 不仅仅会暂停持有偏向锁的线程,还会暂停所有的其他线程
偏向锁升级代价非常大,耗时远超节省下来的时间,还不如最开始就直接使用重量级锁
偏向锁发挥优势的场景是只有一个线程用到这个偏向锁,一旦多个线程用到这个偏向锁,那么偏向锁就毫无优势了
如果一个线程释放了偏向锁,另一个线程继续使用偏向锁,就有可能带来更多的 STW 操作
这里我们再补充讲一个知识点
实际上,synchronized 使用的锁只能升级不能降级,也就是只能从偏向锁,升级为轻量级锁或无锁,再升级为重量级锁
在这个升级链路中,一旦升级为更加严格的锁,就不能再被降级,比如一旦升级为重量级锁之后,就不能再降级为轻量级锁
除此之外不像偏向锁,轻量级锁是会主动解锁的,解锁之后的状态就是无锁状态,四种锁状态的转化如下图所示
2、轻量级锁
当一个线程去竞争锁时,它会先检查 Mark Word 的的锁标志位
如果锁标志位是 01 并且相邻偏向位为 0(无锁状态)或锁标志位是 00(轻量级锁状态),那么这就说明锁已经升级到了轻量级锁
2.1、Mark Word 处于无锁状态
如果 Mark Word 处于无锁状态,这时线程会先在自己栈中创建一个 Lock Record (锁定记录)结构
并将 Mark Word(也就是 8 个字节,跟一个 long、double、引用地址大小一样)拷贝到 Lock Record 结构中的 Displaced Mark Word 中
Lock Record 的作用主要是为了:轻量级锁解锁时快速恢复为无锁状态
实际上,Lock Record 就是一个存储 Mark Word 副本的内存单元
它既可以当成对象存储在堆上,也可以当成局部变量存储在栈上,虚拟机选择了后者,这是因为相比堆,栈上数据的创建和销毁更加快速
做完拷贝 Mark Word 到 Displaced Mark Word 的工作之后,线程会通过 CAS 操作去竞争轻量级锁,这里的 CAS 操作指的是
先检查 Mark Word 的低 3 位二进制是否为 001(无锁状态),如果是的话,再将 Mark Word 中的 Lock Record 指针,设置为指向自己的 Lock Record
以上 CAS 操作同样需要使用硬件层面提供的 CPU 指令来完成,以保证原子性和线程安全性
2.2、Mark Word 处于轻量级锁状态
以上是理想情况,也就是轻量级锁期望的应用场景:两个线程交叉使用锁,但不会竞争锁,每个线程在请求轻量级锁时,总是能成功
但如果一个线程在请求轻量级锁时,另一个线程已经持有了这个轻量级锁,也就是锁标志位是 00 这种情况,这个时候该怎么办呢?
按理来说,这已经不符合轻量级锁的使用场景了,应该升级为重量级锁,但是线程抱有侥幸心理,觉得持有轻量级锁的线程会很快释放锁
毕竟升级为重量级锁是件很麻烦的事情,又要创建 ObjectMonitor,又要排队,而且调用操作系统的系统调用阻塞和唤醒内核线程,还会导致用户态和内核态的切换,比较耗时
因此线程就采用自旋的方式,如下示例代码所示,循环执行 CAS 操作
如果执行了很多次(比如 10 次,这个值可以通过 JVM 参数设置),仍然没有等到另一个线程释放轻量级锁,那么它就只能将轻量级锁升级为重量级锁了
int count = 0; while (count < 10) { // ... do CAS to get lightweight lock ... }
2.3、自适应自旋
那么自旋多少次才合适呢?
- 如果自旋次数太少:有可能刚升级为重量级锁,另一个线程就释放了轻量级锁,这样就很可惜
- 如果自旋次数太多:就会浪费 CPU 资源做很多无用功
针对这个问题,Java 发明了一种特殊的自旋:自适应自旋
- 如果上次自旋之后成功等到了另一个线程释放轻量级锁,那么下次自旋的次数就增加
- 如果上次自旋没有等到等到另一个线程释放轻量级锁,那么下次自旋的次数就减少
- 你可能会说:如果自旋没成功等到轻量级锁,那么就会升级为重量级锁,就没有下次自旋这一说了
实际上这里说的自旋不是针对一个轻量级锁,而是针对所有在用的轻量级锁
2.4、轻量级锁升级为重量级锁
线程自旋等待轻量级锁失败,只能将轻量级锁升级为重量级锁了
跟偏向锁的升级不同,轻量级锁的升级不需要 STW,因为所有的操作都可以使用硬件提供的原子 CAS 指令来完成
在升级的过程中,持有轻量级锁的线程继续干它该干的事情
请求轻量级锁的线程负责升级任务:创建 Monitor 锁,将自己放到 Monitor 锁的 _cxq 中,然后调用操作系统提供系统调用阻塞自己
实际上,这就是上一节中讲到的没有获取到重量级锁的线程要做的事情
2.5、轻量级锁的解锁
上面讲解了轻量级锁获取和升级的过程,我们再来讲下轻量级锁的解锁过程
持有轻量级锁的线程,在释放轻量级锁时,会先检查锁标记位,此时会有两种情况
- 如果锁标记位为 00,说明轻量级锁没有被升级,那么线程只需要使用 CAS 操作来解锁即可,这里的 CAS 操作指的是
先检查锁标记位是否是 00,如果是,再将 Displaced Mark Word(之前的无锁状态)赋值给 Mark Word - 如果锁标志位为 10,说明轻量级锁已经升级为重量级锁,那么解锁的过程就变为
持有轻量级锁的线程去唤醒等待重量级锁的线程,实际上这就是上一节中讲到的重量级锁的解锁过程
3、锁消除
搞定了偏向锁和轻量级锁,synchronized 的另外两个优化(锁消除和锁粗化)相比而言就简单多了
虚拟机在执行 JIT 编译时,会根据对代码的分析(逃逸分析,这个在 JVM 模块中再讲),去掉某些没有必要的锁,如下示例代码所示
为了保证多线程操作的安全性,StringBuffer 中的 append() 函数在设计实现时加了锁
但是在下面的代码中,strBuffer 是局部变量,不会被多线程共享,更不会在多线程环境下调用它的 append() 函数,因此 append() 函数的锁可以被优化消除
public class Demo { public String concat(String s1, String s2) { StringBuffer strBuffer = new StringBuffer(); strBuffer.append(s1); strBuffer.append(s2); return strBuffer.toString(); } }
4、锁粗化
上一节中,当讲到 synchronized 作用于代码块时,我们提到,缩小加锁范围能够提高程序的并发程度,提高多线程环境下的程序的执行效率
但是在有些情况下,虚拟机在执行 JIT 编译时,会扩大加锁范围,将对多个小范围代码的加锁,合并一个对大范围代码的加锁,这样的做法叫做 "锁粗化"
如下所示代码所示,执行 10000 次 append() 函数,会加锁解锁 10000 次,通过锁粗化,编译器将 append() 函数的锁去掉,移到 for 循环外面,这样就只需要加锁解锁 1 次即可
public class Demo { private StringBuffer strBuffer = new StringBuffer(); public void reproduce(String s) { for (int i = 0; i < 10000; i++) { strBuffer.append(s); } } }
5、课后思考题
当 Mark Word 处于偏向锁状态时,Mark Word 就无法记录 hashCode 值了:hashCode 值需要实时计算
当 Mark Word 处于轻量级锁、重量级锁状态时,hashCode、cms_free、GC age 统统无法记录,如果虚拟机或者程序中需要用到这些信息,那么该怎么办呢?
hashCode、cms_free、GC age 这些信息记录在 Lock Record 或 ObjectMinitor 中
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17481646.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步