Java 多线程 - 锁优化(轻量级锁、偏向锁原理及锁的状态流转
前言
记录在学习Java 多线程中 锁优化的有关知识点。
为了进一步改进高效并发,HotSpot虚拟机开发团队在JDK1.6版本上花费了大量精力实现各种锁优化。如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。(主要指的是synchronized的优化)。
适应性自旋 (自旋锁)
为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。引入自旋锁的原因是互斥同步对性能最大的影响是阻塞的实现,管钱线程和恢复线程的操作都需要转入内核态中完成,给并发带来很大压力。自旋锁让物理机器有一个以上的处理器的时候,能让两个或以上的线程同时并行执行。我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在 JDK 1.6之前,自旋次数默认是10次,用户可以使用参数-XX:PreBlockSpin
来更改。
JDK1.6引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。(这个应该属于试探性的算法)。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行清除。锁清除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步枷锁自然就无需进行。
简单来说,Java 中使用同步 来保证数据的安全性,但是对于一些明显不会产生竞争的情况下,Jvm会根据现实执行情况对代码进行锁消除以提高执行效率。
举例说明
对于一些看起来没有加锁的代码,其实隐式的加了很多锁,这些也是锁消除优化的对象。例如下面的字符串拼接代码就隐式加了锁:
imageString 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为StringBuffer
对象的连续 append()
操作:
每个 append()
方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString()
方法内部。也就是说,sb
的所有引用永远不会逃逸到concatString()
方法之外,其他线程无法访问到它,因此可以进行消除。
锁粗化
- 如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
- 当多个彼此靠近的同步块可以合并到一起,形成一个同步块的时候,就会进行锁粗化。该方法还有一种变体,可以把多个同步方法合并为一个方法。如果所有方法都用一个锁对象,就可以尝试这种方法。
轻量级锁 (@重点知识点)
JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
重量级排序 :重量级锁 > 轻量级锁 > 偏向锁 > 无锁
先介绍一下HotSpot 虚拟机对象头的内存布局:
image上面这些数据被称为Mark Word - 标记关键词。 其中 tag bits 对应了五个状态,这些状态的含义在右侧的 state 表格中给出。除了 marked for gc 状态(gc标记状态),其它四个状态已经在前面介绍过了。
下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。
image简单来讲,轻量锁就是先通过CAS
操作进行同步,因为绝大部分的锁,在整个同步周期都是不存在线程去竞争的。
获取轻量锁过程当中会当前线程的虚拟机栈中创建一个Lock Record
的内存区域去存储获取锁的记录(类似于操作记录?),然后使用CAS
操作将锁对象的Mark Word更新成指向刚刚创建的Lock Record
的内存区域的指针,如果这个操作成功,就说明线程获取了该对象的锁,把对象的Mark Word 标记成 00
,表示该对象处于轻量级锁状态。失败情况就如上所述,会判断是否是该线程之前已经获取到锁对象了,如果是就进入同步块执行。如果不是,那就是有多个线程竞争这个所对象,那轻量锁就不适用于这个情况了,要膨胀成重量级锁。
下图是对象处于轻量级锁的状态。
image偏向锁 (@重点知识点)
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 |1|01|
(前面内存布局图中说明了,这属于偏向锁状态)。同时使用 CAS
操作将线程 ID (ThreadID)
记录到 Mark Word 中,如果 CAS
操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
引用《阿里手册:码出高效》的描述再理解一次:
- 偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。
- 在锁对象的对象头中有一个
ThreadId
字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即ThreadId
字段为空,那么JVM让其持有偏向锁,并将ThreadId
字段的值设置为该线程的ID
。当下一次获取锁的时候,会判断ThreadId
是否相等,如果一致就不会重复获取锁,从而提高了运行效率。 - 如果存在锁的竞争情况,偏向锁就会被撤销并升级为轻量级锁。
可以结合下面这张锁的状态流转图理解一下:
image上图实际上是摘自《深入理解Java虚拟机》,自己重新画了一次。在画图的过程当中,发现图中有两个点不是很理解,书中也没有对应的解释。就是偏向锁的重偏向和撤销偏向时如果判断对象是否已经锁定?
后面经过一段时间的查询才知道,HotSpot支持存储释放偏向锁,以及偏向锁的批量重偏向和撤销。这个特性可以通过JVM的参数进行切换,而且这是默认支持的。
Unlock状态下Mark Word的一个比特位用于标识该对象偏向锁是否被使用或者是否被禁止。如果该bit位为0,则该对象未被锁定,并且禁止偏向;如果该bit位为1,则意味着该对象处于以下三种状态:
-
匿名偏向(Anonymously biased)
在此状态下thread pointer
为NULL(0)
,意味着还没有线程偏向于这个锁对象。第一个试图获取该锁的线程将会面临这个情况,使用原子CAS
指令可将该锁对象绑定于当前线程。这是允许偏向锁的类对象的初始状态。 -
可重偏向(Rebiasable)
在此状态下,偏向锁的epoch
字段是无效的(与锁对象对应的class的mark_prototype的epoch值不匹配)。下一个试图获取锁对象的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。在批量重偏向的操作中,未被持有的锁对象都被至于这个状态,以便允许被快速重偏向。 -
已偏向(Biased)
这种状态下,thread pointer
非空,且epoch
为有效值——意味着其他线程正在持有这个锁对象。
这部分因为我目前暂时不想钻研这么深,就简单描述了一下状态流转机制,就当给自己留个坑先记录一下。想要更深的理解知识的话请需要参考下面的文章(使用关键词"bias revocation"进行搜索观看,第二篇写的很好,之后肯定要全篇好好拜读):
-
Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing (这个是pdf版ppt文件,需要翻墙哦)
-
Evaluating and improving biased locking in the HotSpot Virtual Machine(这篇我还查到了中文翻译,只不过只翻译了一点,也不保证翻译质量,看原文实际上更好点,讲的很透彻,详细讲了JVM偏向锁的机制,原理,批量重偏向、撤销偏向的操作,相关章节就在下方截图)
image
StackOverflow上关于这个议题还有一个很有意思的问题,有兴趣的可以去看看。Does Java ever rebias an individual lock
通俗点总结
- 偏向锁是适用于很长一段时间(抽象意义上)都是只有一个线程进入临界区的情况,使用
ThreadId
进行标记; - 轻量锁是适用于会较轻的锁竞争情况,多个线程交替进入临界区,使用
CAS
获取锁对象; - 重量级锁(互斥同步)适用于较严重的锁竞争情况,多个线程同时进入临界区,这个情况下多个线程如果是等待轻量级锁的话,就需要多个线程一直自旋,CPU时间会损失很多;