我们来聊聊锁升级吧
前言
在很久之前,我在面实习生的时候,就有人问过我 synchronized 的锁升级过程,我当时只是浅浅了解,后面其实了解了锁升级的流程。但其实我并不是很明白,究竟优化了哪里,究竟是针对哪种场景进行优化,我其实更想得到这个锁升级过程中的引入场景。尤其是看到 JDK 15 废弃并禁用了偏向锁之后,我其实在想为什么要移除这项技术,是 JDK 有了更好的优化,还是这项技术不再适用于现在。这里直接说答案吧,答案就在 JEP 374 中。我本来想直接贴答案的,但是考虑到有的同学还不清楚 synchronized 的升级流程,这里还是先简单的讲一下锁升级的流程。
其实这也是一道面试常见的问题,但是常常是面试官问我锁升级的过程,而不会问哪些场景会从锁升级中受益,这也是我常常疑惑的地方,不去问 why,而是问 what。
总有些口口流传的优化,大家都愿意相信,但是我们都要相信那句话: 没有调查就没有发言权。
synchronized 锁简介
这里我们简单的复习一下 synchronized, synchronized 是我们遇到的第一个同步工具,它有许多别名: 内部锁、排他锁、悲观锁。它能够保障原子性、可见性和有序性。synchronized 关键字修饰的方法就被称为同步方法(Synchronized Method), synchronized 修饰的静态实例方法就被称为同步实例方法。同步方法的整个方法称为临界区。
Java 平台的任何一个对象都有唯一一个与之关联的锁。 线程进入临界区需要申请锁,那么锁放在哪里呢? 答案是对象头,一个普通的 Java 对象的内部构造如下图所示:
一般来说,我们对这个锁的认知是,多个线程进入临界区的时候,会申请获得这个锁,如果锁已经被其他线程所获取,那么这个线程会陷入阻塞状态。
更为准确的描述是 JVM 会为每个内部锁分配一个入口集,用于记录等待获得相应内部锁的线程,当这些线程申请的锁被其持有线程释放的时候,该锁入口集中的一个任意线程会被 JVM 唤醒。看到这里可能有同学会问,上面提到了获得锁和释放锁,JVM 是怎么处理的呢。 这个其实要借助反编译指令,这里我们以同步代码块来观察 synchronized 的内部实现:
然后找到这个类对应的字节码所在的文件夹,打开命令行执行如下指令
monitorenter 代表进入临界区和申请锁指令,monitorexit 代表出临界区,释放锁指令。那为什么会有两个释放锁指令,这个问题问的好,最下面的那个释放锁指令是为临界区的代码出了异常准备的。JVM 在实现 monitorenter 和 monitorexit 的时候需要借助一个原子操作(CAS),这个代价比较昂贵。
锁升级概述
但是我们知道 Java 中的线程是被映射到操作系统层面的线程的,所以唤醒还需要请求操作系统,如果一个线程持有锁的时间不长,让线程陷入沉睡,再由操作系统去唤醒的代价就有些高。由此就引出了锁升级:
刚开始对象头的锁状态是无锁,线程在进入临界区执行代码的时候,如果获取锁成功,JVM 就会为每个对象维护一个偏好(Bias), 即一个对象对应的内部锁第一次被一个线程获得,那么这个线程就会被记录为该对象的偏好线程(Biased Thread). 这个线程后续无论是再次申请还是释放锁,这个偏好线程都无需借助原先昂贵的原子操作,从而减少了锁的申请与释放的开销。
这种优化基于这样一种观测:锁在大多数情况下没有争用,并且这些锁在整个生命周期至多只会被一个线程持有。其实网上多数博客也是基于这种情况来介绍为什么引入偏向锁的。其实看到这句话,我很不理解,我用 synchronization 就是为了解决多线程竞争资源所带来的问题,那上面这种观测是基于哪种场景的呢?要回答这个问题,我首先提出一个问题: Java 线程安全的集合有哪些? 一般的同学可能会回答:
ConcurrentHashMap
CopyOnWriteArrayList
CopyOnWriteArraySet
这些集合都是实现比较精巧的并发集合,于 JDK1.5 被引入,其实还有一些不为人所熟知的并发安全集合:
Hashtable
Vector
这两个集合是 Java 的原始集合,于 JDK 1.0 被引入,一般不会有人选择使用这两个集合,原因在于,这两个在线程安全上的实现是简单又粗暴,给每个方法加上 synchronized。我看了一下 ArrayList 和 HashMap 的引入时间,是 JDK 1.2,所以早期的 Java 程序员根本没有选择,只有 Hashtable、Vector 可以使用,即使是单线程的使用场景,也没有 ArrayList、HashMap 可以用。Java 是一个向前兼容的语言,即使现在 JDK 19 已经快发布了,有些项目的 JDK 仍然停留在 JDK5、6。所以 JDK 6 引入偏向锁就是优化早期 JDK 代码的性能。这也是 JDK 15 移除偏向锁的原因之一,更为致命的原因是启用偏向锁,会导致性能下降。
为什么移除偏向锁
让我们仔细的看下 JEP 374 这个提案为什么要移除偏向锁:
Biased locking is an optimization technique used in the HotSpot Virtual Machine to reduce the overhead of uncontended locking. It aims to avoid executing a compare-and-swap atomic operation when acquiring a monitor by assuming that a monitor remains owned by a given thread until a different thread tries to acquire it. The initial lock of the monitor biases the monitor towards that thread, avoiding the need for atomic instructions in subsequent synchronized operations on the same object. When many threads perform many synchronized operations on objects used in a single-threaded fashion, biasing the locks has historically led to significant performance improvements over regular locking techniques.
The performance gains seen in the past are far less evident today. Many applications that benefited from biased locking are older, legacy applications that use the early Java collection APIs, which synchronize on every access (e.g., Hashtable
and Vector
). Newer applications generally use the non-synchronized collections (e.g., HashMap
and ArrayList
), introduced in Java 1.2 for single-threaded scenarios, or the even more-performant concurrent data structures, introduced in Java 5, for multi-threaded scenarios. This means that applications that benefit from biased locking due to unnecessary synchronization will likely see a performance improvement if the code is updated to use these newer classes. Furthermore, applications built around a thread-pool queue and worker threads generally perform better with biased locking disabled. (SPECjbb2015 was designed that way, e.g., while SPECjvm98 and SPECjbb2005 were not). Biased locking comes with the cost of requiring an expensive revocation operation in case of contention. Applications that benefit from it are therefore only those that exhibit significant amounts of uncontended synchronized operations, like those mentioned above, so that the cost of executing cheap lock owner checks plus an occasional expensive revocation is still lower than the cost of executing the eluded compare-and-swap atomic instructions. Changes in the cost of atomic instructions since the introduction of biased locking into HotSpot also change the amount of uncontended operations needed for that relation to remain true. Another aspect worth noting is that applications won't have noticeable performance improvements from biased locking even when the previous cost relation is true when the time spend on synchronized operations is still only a small fraction of the total application workload.
Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well. This complexity is a barrier to understanding various parts of the code and an impediment to making significant design changes within the synchronization subsystem. To that end we would like to disable, deprecate, and eventually remove support for biased locking.
这里直接放谷歌翻译吧:
偏向锁定是 HotSpot 虚拟机中使用的一种优化技术,用于减少非竞争锁定的开销。它旨在避免在获取监视器时执行比较和交换原子操作,方法是假设监视器仍然由给定线程拥有,直到不同的线程尝试获取它。监视器的初始锁定将监视器偏向该线程,从而避免在对同一对象的后续同步操作中需要原子指令。当许多线程对以单线程方式使用的对象执行许多同步操作时,与常规锁定技术相比,偏向锁定在历史上会导致显着的性能改进。
过去看到的性能提升在今天远不那么明显。受益于偏向锁定的许多应用程序是使用早期 Java 集合 API 的较旧的遗留应用程序,这些 API 在每次访问时都会同步(例如 Hashtable 和 Vector)。较新的应用程序通常使用非同步集合(例如,HashMap 和 ArrayList),在 Java 1.2 中针对单线程场景引入,或者在 Java 5 中针对多线程场景引入了性能更高的并发数据结构。这意味着如果更新代码以使用这些较新的类,由于不必要的同步而受益于偏向锁定的应用程序可能会看到性能改进。此外,围绕线程池队列和工作线程构建的应用程序通常在禁用偏向锁定的情况下性能更好。(例如,SPECjbb2015 就是这样设计的,而 SPECjvm98 和 SPECjbb2005 则不是)。偏向锁定伴随着在争用情况下需要昂贵的撤销操作的成本。因此,受益于它的应用程序只有那些表现出大量非竞争同步操作的应用程序,如上面提到的那些,因此,执行廉价的锁所有者检查加上偶尔昂贵的撤销的成本仍然低于执行躲避的比较和交换原子指令的成本。自从将偏向锁定引入 HotSpot 以来,原子指令成本的变化也改变了保持该关系正确所需的非竞争操作的数量。另一个值得注意的方面是,应用程序不会从偏向锁定中获得显着的性能改进,即使之前的成本关系是正确的,而花在同步操作上的时间仍然只是应用程序总工作负载的一小部分。自从将偏向锁定引入 HotSpot 以来,原子指令成本的变化也改变了保持该关系正确所需的非竞争操作的数量。另一个值得注意的方面是应用程序不会从偏向锁定中获得显着的性能改进,即使之前的成本关系是正确的,而花在同步操作上的时间仍然只是应用程序总工作负载的一小部分。自从将偏向锁定引入 HotSpot 以来,原子指令成本的变化也改变了保持该关系正确所需的非竞争操作的数量。另一个值得注意的方面是应用程序不会从偏向锁定中获得显着的性能改进,即使之前的成本关系是正确的,而花在同步操作上的时间仍然只是应用程序总工作负载的一小部分。
偏向锁定在同步子系统中引入了许多复杂的代码,并且还侵入了其他 HotSpot 组件。这种复杂性是理解代码各个部分的障碍,也是在同步子系统内进行重大设计更改的障碍。为此,我们希望禁用、弃用并最终移除对偏向锁定的支持。
总结一下,引入偏向锁的目的,主要为了优化 JDK 1.2 之前的 HashTable、Vector,这两个集合就是我们上面说的,对应的锁在整个集合生命周期,有的时候只会被一个线程所获取。现在移除偏向锁是因为基本没人用这俩集合,再加上撤销偏向锁也需要高昂的成本,所以 JDK 15 决定移除此特性。
轻量级锁到重量级锁?
那偏向锁我们这里不提,我们接着聊锁升级的流程:
前面是由无锁升级到偏向锁,假设有其他线程访问偏向锁申请获得锁,那么此时偏向锁升级到轻量级锁,这个轻量级锁的具体表现为获取锁失败的线程,并不会陷入阻塞状态,而是会自旋,即不停的循环的去获取锁,但是长时间的自选比较消耗 CPU 的资源,所以到达一定次数之后,就会到达重量级锁,如果锁处于重量级锁状态,获取锁失败的线程将会进入阻塞状态。
《Java 多线程编程 实战指南》中看到的一种描述:
存在锁争用的情况下,一个线程申请一个锁的时候如果这个锁恰好被其他线程持有,那么这个线程就需要等待该锁被其持有线程释放,实现这种等待的一种保守方法就是暂停线程,但是暂停线程会导致上下文切换,因此对于一个具体锁实例来说,这种实现策略比较适合于系统中绝大多数线程对该锁的持有选时间较长的场景,这样才能够抵消上下文切换的开销。另一个实现方法就是采用忙等,所谓忙等相当于如下代码所示的一个循环体为空的循环语句:
while(lockIsHeldByOtherThread){}.
可见,忙等是通过反复执行空操作直到所需的条件成立为止而实现等待的。这种策略的好处是不会导致上下文切换,缺点是比较消耗处理器资源。
事实上,JVM 也不是非要在上述两种实现策略之中择其一,它可以综合使用上述两种策略。对于一个具体的锁实例,JVM 会根据其运行过程中收集到的信息来判断这个锁是属于被线程持有时间“较长”的还是“较短”的。对于被线程是由时间“较长”的锁,JVM 会选取暂停等待策略,对于线程持有时间“较短的锁”,JVM 会选取忙等等待策略。JVM 也可能先采用忙等等待策略,在忙等失败的情况下再采用暂停等待策略,JVM 虚拟机的这种优化被称为适应性锁。
其实我读到这段的时候,我在想,JVM 还会先采取暂停等待策略,再调整为忙等等待策略? 遍寻全网还是没找到这块的论述,相关的论述只是在自适应锁上 , 自适应锁于 JDK 1.6 被引入,适应性锁可以理解为适应性自选锁,自适应意味着自选的时间次数不再固定,而是由前一次在同一个锁上的自选时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自选等待刚刚成功获得锁,并且持有锁的线程正在运行中, 那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将自旋等待持续相对更长时间。如果对于某个锁,自旋很少成功获得过,那么以后尝试获取这个锁很可能将省略掉自选过程,直接阻塞线程,避免浪费处理器资源。
其实到这里问题基本上已经结束了,我们已经基本上回答了锁升级的流程,在 JDK 8 之后的锁升级流程,应当是无锁 到 偏向锁,然后自选,JVM 根据自旋的成功率,如果自旋的成功率高,那么接着自旋,如果自旋获取锁的成功率比较低,比较消耗资源,进入重量级锁。
锁升级流程的标准答案
如果面试官问锁升级的流程,我认为标准的回答如下:
在 JDK 8 到 14 是由无锁 转 偏向锁 自适应锁, 所谓自适应锁指的是 JVM 会根据运行过程中收集的信息来决定自旋还是阻塞线程,如果自旋获得锁的成功率比较高,那么就是由偏向锁升级到轻量级锁。如果自旋获取锁的失败率比较高,代表单个线程持有锁的时间很长,那么 JVM 就会从轻量级锁转为重量级锁。
在 JDK 15 移除了偏向锁,原因在于引入偏向锁,主要是为了优化 JDK 1.0 的那两个集合相关的代码,但是现在看来这两个集合很少有人用到,况且 JVM 撤销偏向锁状态比较消耗资源,所以 JDK 15 撤销了偏向锁。所以 JDK 15 的锁升级流程为 无锁到 轻量级锁 再到 重量级锁。
写在最后
其实原本打算写一下锁降级,但是锁降级牵扯到一个 safe point,而要介绍 safe point 又跟 GC 混在一起,本来打算介绍,但是后来想了一下,一篇介绍 safe point 不大现实。
评论