写点什么

干货!一文带你了解 Java 并发中的锁优化,让你的代码运行效率翻倍

发布于: 2021 年 07 月 07 日
干货!一文带你了解Java并发中的锁优化,让你的代码运行效率翻倍

今日分享开始啦,请大家多多指教~

为什么是锁优化?

Java 多线程为了实现线程同步,加入同步锁(synchronized 和 lock 机制)机制,同步锁的诞生虽然保证了操作的原子性、线程的安全性,但是(相比不加锁的情况下)造成了程序性能下降。所以,我们这里要做的一件事就是“锁优化”,即既要保证实现锁的功能(即保证多线程下操作安全)又要提高程序性能(即不要让程序因为安全而损失太大效率)。

下面来介绍 HotSpot 虚拟机(JVM)的锁优化措施,包括自旋与自适应自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)。这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

自旋锁与自适用自旋

自旋锁定义:如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程 “稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个慢循环(自旋),这项技术就是所谓的自旋锁。

自旋锁具体业务流程:自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时候很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是 10 次,用户可以使用参数 -XX:PreBlockSpin 来更改。

自适用自旋定义:自适应自旋是对自旋锁的改进,意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

自适用自旋具体业务流程:如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如 100 个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越 “聪明” 了。

锁消除

锁消除定义:锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除(即经过 JVM 做一个锁消除优化,将确定没用的锁消除,是一种 JVM 并发优化技术)。

如何判断某个锁可以消除?

锁消除的主要判定依据来源于逃逸分析的数据支持,如果判定在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行(即对于只有该线程可以访问到其他线程无法访问的到的代码,可以大胆的做所消除)。


结合.java 到.class 字节码文件可以知道,在 jdk8 的情况下,return a+b+c;这句程序实际上底层被转换为 StringBuilder 的追加操作。

每个 StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象(StringBuilder 新建的一个值为“”的空字符串对象)。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会 “逃逸” 道 concatString() 方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了(可以理解为 StringBuilder 虽然线程不安全,但是这里没有关系,因为整个执行过程都在 main 线程中,不会涉及任何线程同步,所以是安全的。这里底层使用 StringBuilder 就可以被认为是一种锁消除)。

锁粗化

锁粗化定义:这是一种 JVM 并发优化,如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这就是锁粗化。

解释:以上面的 return a+b+c;为例,底层被拆分为:

三个 append()追加,如果对每一个 append()都加锁操作,频繁地进行互斥同步操作也会导致不必要的性能损耗,这是 JVM 不乐意看到的,为了提高 JVM 并发性能,此时,JVM 会进行一个优化,就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了,这就是锁粗化。

轻量级锁

轻量级锁定义:一种 JVM 并发优化,是指在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

注意两个:

没有多线程竞争:是指这种轻量级锁只有在没有多线程竞争才能减少性能消耗(实际上,如果有多线程竞争,它的消耗比重量级锁更大,后面会讲)。

传统的重量级锁:是指 synchronized 关键字(和 lock 锁机制)。

引子:HotSpot 虚拟机的对象(对象头部分)的内存布局

HotSpot 虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄(Generational GC Age)等,这部分数据是长度在 32 位和 64 位的虚拟机中分别为 32 bit 和 64 bit,官方称它为 “Mark Word”,它是实现轻量级锁和偏向锁的关键。

另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Work 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。HotSpot 虚拟机对象头 Mark Word,如下表:

问题 1:上表中为什么有两个 01,是不是重复了?

上表的五个项中,第一个表示的是未锁定,其他四个均表示锁定,即第一个 01 表示的是未锁定,后面四个表示的都是锁定.

问题 2:未锁定和偏向锁标记的标记位都是 01,如何区分?

对,因为它们的标记位都是 01,所以根据其他位(biased lock flag 偏向锁标记)区分。

如果还是不懂,一图抵千言:

轻量级锁执行过程:

步骤一:在代码进入同步块的时候,如果此同步对象没有被锁定(如上表所述,锁标志位为 “01” 状态)虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这份拷贝加上了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如图:

然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。

步骤二( Mark Word 更新为指向 Lock Record 的指针,更新成功): 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位 (Mark Word 的最后 2bit)将转变为 “00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图:

一个完整的轻量级锁获取过程,起码需要经历 4 次 CAS 操作。所以,获取轻量级锁的 CAS 次数总是>=4 的。如果出现线程持续获取锁失败的情况,那么,轻量级锁就会执行膨胀,意思就是升级为重量级锁。且看步骤三。

步骤三(Mark Word 更新为指向 Lock Record 的指针,更新失败):如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象以及被其他线程线程抢占了。

如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,所标志的状态变为 “10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。这时候线程堆栈与对象头的状态如图:

小结:上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据,也可以说是一种侥幸。

为什么说既是一种经验,又是一种侥幸呢?

如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销;

如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

通过对轻量级锁的学习,我们知道,轻量级锁不是要取代重量级锁,而是对重量级锁的补充,只有在获取轻量级锁的 CAS 次数,出现线程持续获取锁失败的情况,轻量级锁就会执行膨胀,升级为重量级锁。

偏向锁

偏向锁定义:在轻量级锁的基础上,消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。

偏向锁的 “偏”,就是偏心的 “偏”、偏袒的 “偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

偏向锁执行流程:

假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiasedLocking,这是 JDK 1.6 的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 “01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程 ID 记录在对象的 Mark Word 之中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行如何同步操作(例如 Locking、Unlocking 及对 Mark Word 的 Update 等)。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为 “01”)或轻量级锁定(标志位为 “00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。

偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系如图:

对于上图的理解:这个图为我们提供从分配对象开始的整个锁安全的变化,先查看倒数第三位,biased lock flag 偏向锁标记若为 1,则表示偏向锁,若为 0,则表示无锁/未加锁,

对于偏向锁,初始锁定即确定了 threadID,撤销偏向即取消了、去掉了偏向,进入另一个状态,若对象未锁定,变为无锁/未加锁状态,若对象锁定,变为轻量级锁定状态,这里,无锁状态访问变量优先使用轻量级锁定状态,若轻量级锁定产生获取锁失败,会产生膨胀,变为重量级锁(一般使用 synchronized 锁定)。

对于无锁,优先使用轻量级锁定状态,若轻量级锁定产生获取锁失败,会产生膨胀,变为重量级锁(一般使用 synchronized 锁定)。

小结:偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。

本文主要介绍锁优化,对于传统的重量级锁(synchronized)提供一系列优化操作——自旋与自适应自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)。

今日份分享已结束,请大家多多包涵和指点!

用户头像

还未添加个人签名 2021.04.20 加入

Java工具与相关资料获取等WX: gsh950924(备注来源)

评论

发布
暂无评论
干货!一文带你了解Java并发中的锁优化,让你的代码运行效率翻倍