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