Java synchronized实现原理与优化:偏向锁、轻量级锁和重量级锁
Java的 synchronized 关键字是实现线程同步的一个非常重要的方式,其实现原理和优化也是一个重点。本文不会对相关概念流程一一详解,而是着重讲解其中的关键点和可能的理解误区。
首先,重中之重,请牢记以下三点:
偏向锁适用于期望只有一个线程执行的场景
轻量级锁适用于多个线程交替执行的场景
重量级锁适用于多个线程同时执行的场景
掌握 synchronized 实现的过程中,可以先假设再求证。但任何时刻的任何理解,如果违背了上述三个适用原则,那一定是进行了错误的解读,多半是臆想了不存在的优化手段。
锁的升级膨胀方向为 无锁(偏向锁) => 轻量级锁 => 重量级锁,这个过程是单向的,对象头中的 markword 记录了当前锁状态信息。
偏向锁
Thread ID | Epoch | Age | Biasable | Lock
分别简写为 TI、E、A、B、L。
偏向锁是无锁的一种,与无锁一样,L 标志位为01,B 标志位表明当前是否可偏向:可偏向为1,不可偏向为0。不可偏向可能是由于系统未启用偏向锁,或者当前类已不可使用偏向锁,或者该对象已经不可偏向。
如果启用了偏向锁,对象初始化后,L=01,B=1,即偏向锁可偏向。
常规获取锁流程
第一个线程A第一次进入同步块获取锁,此时锁为匿名偏向模式,即 L=01 && B=1, E 失效(后面解释),TI=0,CAS操作成功获得锁,偏向锁现在偏向于线程A,然后执行同步块。同步块执行完后,线程A并不会主动释放偏向锁,也就是偏向锁保持偏向于线程A。
线程A再次进入同步块获取锁时,TI=A线程ID,E 为最新值有效,L 和 B 不变,表明锁当前已偏向于线程A,此时可以继续执行同步块。
偏向锁的期望是一直只有线程A执行,这样锁将始终偏向于线程A。
但这仅仅是期望而已,现实必然是残酷的。
某刻,线程B也要进入该同步块,将试图获取锁。如果是与线程A同时,则如前述线程A流程,但CAS操作会失败。如果是在线程A已获得偏向锁后,由于锁当前偏向于线程A,即 L=01 && B=01,E 有效,TI=A线程ID,线程B将无法执行CAS操作来获取锁。此时,会采用启发式方式决定接下来流程:或者执行单个撤销偏向,或者执行批量重偏向,或者执行批量撤销偏向。方式是一样的,以对象和线程B作为参数构造一个VM_Operation vo,交给VM_Thread去执行,线程B会被挂起。
直到运行至全局安全点,vo 会被执行,撤销偏向。此时有多种情况:线程A可能已经结束,线程A已经退出同步块,线程A还在同步块。三种情况 vo 都会修改 markword 将 B 设为0,偏向锁不可偏向。第三种情况还会升级到轻量级锁,线程A将持有该轻量级锁。全局安全点后,各线程恢复运行,线程B将被唤醒,继续往下执行,进入轻量级锁获取流程。
以上是偏向锁期望的常规情况,我们可以看到,一旦有另外一个线程加入,就会进行撤销偏向锁,这就表明偏向锁已经不适用于该场景。偏向锁转成不可偏向是不可逆的。
优化手段
常规之外,Java也对偏向锁进行了优化,批量重偏向和批量撤销偏向就是为此设计的。上面说到epoch有效或失效的情况,那怎么才是有效,怎么才是失效呢?
每个class元数据里都有一个epoch记为 Ec,还有个偏向计数器 Bc,注意这个 Bc 是class概念上的。每进行一次撤销偏向,Bc +1。每个对象头 markword 也有一个epoch,即前面所说的 E。
批量重偏向
当 Bc=20时,系统认为该class类型的对象可能有不合适的使用情况,比如一个线程创建了大量对象并初始化,执行了同步块获取了偏向锁,但之后就将对象交给了线程B去处理。这种情况再也不需要偏向于线程A,转而偏向于线程B会非常合理。所以这时候,会执行批量重偏向。
具体操作是,将class元数据上的 Ec+1,并遍历所有线程的线程栈。因为当前是在全局安全点,所以该操作是安全的。找到所有线程栈中该class的实例对象,如果还在锁着,就将该对象上的epoch设为 Ec。结果就是所有还被占用着的偏向锁对象的 E=Ec,此为 E 有效,而已经退出同步块的 E!=Ec,此为 E 失效。E 有效的情况, markword 的 B 也将设为1,而不是撤销偏向的0。
这样,L=01 && B=1,E 失效,后面另外的线程B获取该偏向锁时,就可以重偏向于线程B。
批量撤销偏向
批量重偏向后,如果继续遇到撤销偏向锁操作,Bc 继续+1,如果在一定时间间隔(25秒)内,并没有达到阈值,这表明批量重偏向起到了很好的效果,Bc 将被清零重新开始。但如果达到了新的阈值 Bc=40,表明该class类型对象设计使用上有问题,不再适合使用偏向锁模式,将执行批量撤销偏向。首先将class元数据置为无锁(偏向锁不可偏向)模式,然后遍历所有线程的线程栈,将所有该class的实例对象撤销偏向,使偏向锁不可偏向。
轻量级锁
Lock Record | Lock
分别简写为 LR、L。
每次获取轻量级锁,都会创建新的线程锁记录,无论是否重入。
如果当前对象处于无锁即 L=01,线程CAS操作成功获取锁,LR=当前线程锁记录指针,拷贝之前的对象 markword 到锁记录,即 displaced markword,记为 dhw。
如果是重入,即线程递归获取锁,锁记录 dhw 置为0。
非以上情况,或CAS操作失败,则进入升级膨胀成重量级锁流程。很多文章都写到,轻量级锁获取阶段,如果CAS失败会自旋一段时间,这是错误的。轻量级锁过程中没有自旋,若CAS失败则说明遇到了冲突,违背了前面三个适用原则中的轻量级锁适用于线程交替的场景。由此也可见,偏向锁CAS失败后,有可能最后直接升级成重量级锁。
线程退出同步块会主动释放轻量级锁。如果是重入(dhw=0),什么也不做,否则执行CAS操作释放锁,成功则对象回到无锁状态(偏向锁不可偏向),失败则意味着对象正处于升级膨胀中,或已经升级成重量级锁,需要唤醒已经挂起的线程。
重量级锁
Monitor Ptr | Lock
分别记为 MP、L。
升级膨胀
竞争轻量级锁失败,轻量级锁解锁失败,以及Object wait都可能进入升级膨胀成重量级锁流程。膨胀过程采用自旋,但这自旋还是并非一直占用CPU,间隔还是会挂起释放CPU的。膨胀过程中,如果已是重量级锁,则直接返回对象监视器;如果 INFLATING 状态则自旋等待;如果是轻量级锁中,则先CAS操作将对象 markword 置为 INFLATING,成功则由该线程执行后续膨胀操作,即构造对象监视器,设置相关数据,修改对象 markword 置为重量级锁信息,然后返回该对象监视器;如果是无锁(轻量级锁已释放),则同样构造对象监视器,设置相关数据,然后CAS修改对象 markword 置为重量级锁信息。
INFLATING
为什么需要 INFLATING 状态?可能多个线程都会尝试升级膨胀,处于轻量级锁中时,只有CAS成功设置 INFLATING 的线程只能真正执行膨胀操作,其他线程都会自旋等待膨胀完成。更深层次的原因是,升级膨胀需要CAS修改对象的 markword 置为重量级锁信息,其中包括完整的对象监视器指针。这意味着,要么在CAS之前构造好完整的对象监视器,但对象监视器需要 dhw 数据记为header,这时候可能轻量级锁已解锁,对象 markword 已改变, 那就无法由此获取到正确的 dhw。所以先CAS操作置对象 markword 为 INFLATING,成功后再去构造对象监视器。因为 INFLATING 时,轻量级锁解锁会失败,线程会进入升级膨胀流程,对象 markword 从而是稳定的不会改变,自然也就能获取到正确的 dhw 了。而处于无锁(轻量级锁已释放)时,对象 markword 本身就是对象监视器需要的 header,所以可以先构造对象监视器后,再进行CAS操作修改对象 markword。成功则膨胀完成,失败可能是因为别的线程已经成功执行了该操作,也可能是又被其他线程获得了轻量级锁,那么就释放掉该对象监视器,然后走前述处于轻量级锁中时的膨胀流程。
加锁解锁
升级膨胀后只是成功构造了对象监视器,进入重量级锁阶段,但加锁解锁还需要多个线程再去竞争。
竞争重量级锁,是通过CAS操作将对象监视器owner置为当前线程,成功则获得锁,或者是重入则记录递归次数,否则进入真正的锁竞争机制,这就是另外篇幅的内容了。
一旦升级成重量级锁,加锁解锁就不再影响对象 markword,保持为 MP=对象监视器指针,L=10。
结语
以上所有内容基于 OpenJDK 9 的实现,目的在于阐释其间的重点、难点和可能的思维误区。
为便于宏观的把握和理解,一些细节做了简化,如hashcode对以上流程的影响等,本文不作讨论。