7000+ 字图文并茂解带你深入理解 java 锁升级的每个细节
本文分享自华为云社区《对java锁升级,你是否还停留在表面的理解?7000+字和图解带你深入理解锁升级的每个细节》,作者:breakDawn 。
对于 java 锁升级,很多人都停留在比较浅层的表面理解,一定程度下也许够用,但如果学习其中的细节,我们更好地理解多线程并发时各种疑难问题的应对方式!
因此我将锁升级过程中可能涉及的大部分细节或者疑问都整合成了一篇文章,希望你能直接在这篇文章中,搞懂你当年学习这块时遗留的所有疑问。
为什么说线程切换会很慢?
所谓的用户态和内核态之间的切换仅仅是一方面, 并非全部, 更关键的在于, 多 CPU 的机器中,当你切换了线程,意味着线程在原先 CPU 上的缓存也许不再有用, 因为当线程重新触发时,可能在另一个 CPU 上执行了。
正因如此,不建议没事就挂起线程, 让线程自旋会比挂起后切换 CPU 好很多。
正与基于这一点,才有了后面的 sync 锁升级机制,理解了为什么要锁升级,才能逐步理解锁升级过程,
对象头中的 mark-word
java 每个对象的对象头中, 都有 32 或者 64 位的 mark-word。
mark-word 是理解锁升级过程的重要部分,且后面的锁升级过程都会涉及,因此这里会进行一个非常详细的解释。这部分只对一个对象必有的属性做解释(即一般不会随着锁状态变化而消失的属性)。对于各锁状态独有的属性,会在锁升级过程中做详细的解释。
锁状态标志位 +偏向锁标记位(2bit + 1bit)
除了 markword 中的 2 位锁状态标志位, 其他 62 位都会随着锁状态标志位的变化而变化。
这里先列出各锁状态标志位代表的当前对象所用锁的情况。后面会详细解释各种锁的含义和运行原理。
锁状态标志位为 01: 属于无锁或者偏向锁状态。因此还需要额外的偏向锁标记位 1bit 来确认是无锁还是偏向锁
锁状态标志位为 00: 轻量级锁
锁状态标志位为 10: 重量级锁
锁状态标志位为 11: 已经被 gc 标记,即将释放
为什么无锁/偏向锁的标志位是 01,而轻量级锁的标志位是 00?
即按理说,无锁是锁状态的初始情况,为什么标志位不是从 00 开始?
个人查询到的一个解释,是因为 轻量级锁除了锁标志位外,另外 62 位都是一个指针地址。
如果将轻量级锁标志位设置为 00, 那么在判断标志位为 00 后, m 无需再额外做一次 markWord>>2 的操作,而是直接将 markWord 拿来当作地址使用即可!
可以从这里看到 jvm 的设计者还是非常细节的,并没有随意地定义各状态的标志位。
哈希码(31 位)
哈希 code 很容易理解,将对象存储到一些 map 或者 set 里时,都需要哈希 code 来确认插入位置。
但 markword 里的 hashcode,和我们平时经常覆写的 hashCode()还是有区别的。
markword 中的 hashcode 是哪个方法生成的?
很多人误以为,markword 中的哈希码是由我们经常覆写的哈希码()方法生成的。
实际上, markword 中的 hashcode 只由底层 JDK C++ 源码计算得到(java 侧调用方法为 System.identityHashCode() ), 生成后固化到 markword 中,如果你覆写了 hashcode()方法, 那么每次都会重新调用 hashCode()方法重新计算哈希值。
根本原因是因为你覆写哈希 code()之后,该方法中很可能会利用被修改的成员来计算哈希值,所以 jvm 不敢将其存储到 markword 中。
因此,如果覆写了 hashcode()方法,对象头中就不会生成 hashcode,而是每次通过 hashcode()方法调用
markword 中的哈希码是什么时候生成?
很容易误以为会是对象一创建就生成了。
实际上,是采用了延迟加载技术,只有在用到的时候才生成。
毕竟有可能对象创建出来使用时,并不需要做哈希的操作。
hashcode 在其他锁状态中去哪了?
这个问题会在后面锁升级的 3 个阶段中,解释 hashcode 的去向。其他的例如分代年龄同理。
gc 分代年龄(4bit)
在 jvm 垃圾收集机制中, 决定年轻代什么时候进入老年代的根据之一, 就是确认他的分代年龄是否达到阈值,如下图所示。
分代年龄只有 4bit 可以看出,最大值只能是 15。因此我们设置的进入老年代年龄阈值 -XX:MaxTenuringThreshold 最大只能设置 15。
cms_free
在无锁和偏向锁中,还可以看到有 1bit 的 cms_free。
实际上就是只有 CMS 收集器用到的。但最新 java11 中更多用的是 G1 收集器了,这一位相当于不怎么常用,因此提到的也非常少。
从上述可以看出, 只有锁状态标记位、 hashcode、 分代年龄、cms_free 是必有的, 但是从 markword 最初的示意图来看, hashcode、 分代年龄、cms_free 似乎并非一直存在,那么他们去哪了呢?会在后面的锁升级过程进行详细解释。
锁升级四个阶段超级详解
无锁
无锁状态的 markword 如下所示,可以看到上文提到的信息都存在
处于无锁状态的条件或者时机是什么?
无锁状态用于对象刚创建,且还未进入过同步代码块的时候
这一点很重要, 意味着如果你没有同步代码块或者同步方法, 那么将是无锁状态。
对象从没进入同步块,为什么偏向锁标志位却是 1?
上面这个问题说过,没进入同步块, 不会上偏向锁。
但是我们如果用 java 的 jol 工具测试打印新对象,会看到低 3 位是 101
这其实是 jvm 后面加入的一种优化, 对每个新对象,预置了一个**“可偏向状态”,也叫做匿名偏向状态**,是对象初始化中,JVM 帮我们做的。
注意此时 markword 中高位是不存在 ThreadID 的, 都是 0, 说明此时并没有线程偏向发生,因此也可以理解成是无锁。
好处在于后续做偏向锁加锁时,无需再去改动偏向锁标记位,只需要对线程 id 做 cas 即可。
偏向锁
一旦代码第一次进入 sync 同步方法块,就可能从无锁状态进入偏向锁状态。
另外很多人应该都知道, 偏向锁只存储了当前偏向的线程 id, 只有线程 id 不同的才会触发升级。
但这是非常简化的说法, 实际上中间的细节和优化非常之多!这里将为你详细讲述。
为什么要有偏向锁?
理解这个才能理解偏向锁中的各种设计。 假设我们 new 出来的对象带有同步代码块方法,但在整个生命周期中只被一个线程访问,那么是否有必要做消耗消耗的竞争动作,甚至引入额外的内存开销?没有必要。
因此针对的是 对象有同步方法调用,但是实际不存在竞争的场景
偏向锁的 markword 详解
这个 markword 和无锁对比, 偏向标志位变成了 1, hashcode 没了,多了个 epoch 和线程 id。
markword 中的当前线程 id
这个 id 就是在进入了对象同步代码块的线程 id。
java 的线程 id 是一个 long 类型, 按理说是 64 位,但为什么之类的线程 id 只有 54 位?
具体没有找到解释,可能是 jvm 团队认为 54 位线程 id 足够用了,不至于会创建 2^54 那么多的线程,真的有需要创建这么频繁的程序,也会优先采用线程池池才对。
线程 id 如何写入?
线程 id 是直接写入 markword 吗? 不对, 一定要注意到这时候是存在同时写的可能的。
因此会采用 CAS 的方式进行线程 id 的写入。 简而言之, 就是先取原线程 id 后,再更新线程 id,更新后检查一下是否和预期一致,不一致则说明被人改动过,则线程 id 写入失败,说明存在竞争,升级为轻量级锁。
哈希 code 去哪了
我们注意到无锁时的 hashcode 不见了。
对于偏向锁而言, 一旦在对象头中设置过 hashcode, 那么进入同步块时就不会进入偏向锁状态,会直接跳到轻量级锁,毕竟偏向锁里没有存放 hashcode 的地方(下文的轻量级锁和重量级锁则有存储的地方)
因此凡是做过类似 hashmap.put(k,v)操作且没覆写 hashcode 的 k 对象, 以后加锁时,都会直接略过偏向锁。
epoch 是什么?
这个属性很多人叫它“偏向时间戳”, 却鲜有人进行详细解释。
主要是因为它涉及到了偏向锁中非常重要的 2 个优化(批量重偏向和批量撤销)
对于这个 epoch,放到下文的偏向锁解锁过程进行解释。
你可以先简单理解为,通过 epoch,jvm 可以知道这个对象的偏向锁是否过期了,过期的情况下允许直接试图抢占,而不进行撤销偏向锁的操作。
偏向锁运作详解
偏向锁上锁时,如何避免冲突和竞争?
我们知道偏向锁其实就是将线程 id 设置了进去,但是如果存在冲突怎么办?
因此,jmv 会通过 CAS 来设置偏向线程 id,一旦设置成功那么这个偏向锁就算挂上了。
后面每次访问时,检查线程 id 一致,就直接进入同步代码块执行了。
CAS 概念补充:
CAS 是一个原子性操作, 调用者需要给定修改变量的期望值 和 最终值
当内存中该变量的值和期望值相等时,才更新为最终值, 这个相等的比较和更新的操作是原子操作
对于到偏向锁加锁过程, 其实就是先取出线程 id 部分, 如果为空, 则进行(期望值:空 , 最终值:当前线程 id)的 CAS 操作, 如果发现期望值不匹配,就说明被抢先了 。
离开同步代码块时, markword 中的线程 id 会重新变为 0 吗?
并不会,这个偏向锁线程 id 会一直挂着, 后面只要识别到 id 一致,就不用做特殊处理。
偏向锁发生竞争时的切锁或者升级操作。
但当有其他线程来访问时,之前设置的偏向锁就有问题了,说明存在多线程访问同一个对象的情况。
注意!!!这里并非像很多资料里说的那样, 一旦发生多线程调用, 偏向锁就升级成轻量级锁,而是做了很多的细节处理,来尽可能避免轻量级锁这种耗费 CPU 的操作。
首先,jvm 考虑到了这种场景:
最开始 1h 内,都是线程 A 在调用大量的对象 obj, 于是偏向锁一直都是线程 A。
后来线程 A 不跑了, 对象 obj 的调用交给了线程 B,即未来都是线程 B 来调用。
那么这时候,有必要马上升级轻量级锁吗?
没必要!因为未来仍然是单线程调用,仅仅是线程不同而已,也许可以尝试仍旧用偏向锁?
于是就有了如下的撤销偏向锁的动作:
当线程 B 发现是偏向锁,且线程 id 不为自己时,开始撤销操作
首先,线程 B 会一直等待 对象 obj 到达 jvm 安全点。
到达安全点后, 线程 B 检查线程 A 是否正处在 obj 的同步代码块内。
如果线程 A 正在同步代码块中, 则没得商量了,直接升级为轻量级锁。
如果线程 A 不在同步代码块中, 那么线程 B 还有机会, 它先把偏向锁改成无锁状态,然后再用 CAS 的方式尝试重新竞争,如果能竞争到,那么就会偏向自己。
完整过程如下图所示:
为什么要等待安全点,才能做撤销操作?
这是为了保证撤销操作的安全性。否则可能出现 jvm 正在撤销的时候, 另一个线程又开始对该对象做操作,引发错误。
为什么要先退化成无锁状态,再试图竞争成偏向锁?不能直接偏向吗?
因为你无法预测 A 是否会卷土重来,置成无锁后, A 和 B 可以公平竞争。
为什么原偏向线程在同步代码块中时,就必须升级为轻量级锁?能否同样撤销无锁来竞争?
不可以,因为同步代码块还在执行的话,那 B 线程此时是注定无法立刻得到锁的,注定了它必须升级为轻量级锁,通过轻量级锁中的循环能力来做获取锁的操作。
批量重偏向,以及 epoch 的应用
上文提到, 线程 B 重新抢偏向锁时,会试图等待安全点,撤销成无锁,再做公平抢占。这个动作还是比较费时的。
假设有一个场景, 我们 new 了 30 个 obj 对象, 最初都是由 A 线程使用,后面通过 for 循环都由 B 线程使用,那么会发现在很短的时间内,连续发生了偏向锁撤销为无锁,且未因同步块竞争而发生轻量升级的情况。
那么,jvm 猜测此时后面都是类似的情况,于是 B 线程调用 obj 对象时,不再撤销了,直接 CAS 竞争 threadId,因为 jvm 预测 A 不会来抢了,具体步骤如下所示:
jvm 会在 obj 对象的类类对象中, 定义了一个偏向撤销计数器以及 epoch 偏向版本。
每当有一个对象被撤销偏向锁, 都会让偏向撤销计数器+1。
一旦加到 20, 则认为出现大规模的锁撤销动作。于是 class 类对象中的 epoch 值+1(但是 epoch 一般只有 2 位即 0~3)。
接着, jvm 会找到所有正处在同步代码块中的 obj 对象, 让他的 epoch 等于 class 类对象的 epoch。
其他不在同步代码块中的 obj 对象,则不修改 epoch。
当 B 线程来访问时,发现 obj 对象的 epoch 和 class 对象的 epoch 不相等,则不再做撤销动作,直接 CAS 抢占。因为当 epoch 不等时,这说明该 obj 对象之前一直没被原主人使用, 但它的兄弟们之前纷纷投降倒戈了, 那我应该直接尝试占用就好,没必要那么谨慎了!
详细过程如下图所示:
批量撤销
但如果短时间内该类的撤销动作超过 40 个, jvm 会认为这个数量太多了, 不保险,数量一多,预测就不准了。
jvm 此时会将 obj 对象的类类对象中的偏向标记**(注意是类中的偏向锁开启标记,而不是对象头中的偏向锁标记)**设置为禁用偏向锁。后续该对象的 new 操作将直接走轻量级锁的逻辑。
偏向锁在进程一开始就启用了吗
即使你开启了偏向锁,但是这个偏向锁的启用是有延迟,大概 4s 左右。
即 java 进程启动的 4s 内,都会直接跳过偏向锁,有同步代码块时直接使用轻量级锁。
原因是 JVM 初始化的代码有很多地方用到了同步,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,jvm 团队经过测试和评估, 选择了启动速度最快的方案, 即强制 4s 内禁用偏向锁,所以就有了这个延迟策略 (当然这个时间延迟也可以通过参数自己调整)
偏向锁的重要演变历史和思考
偏向锁在 JDK6 引入, 且默认开启偏向锁优化, 可通过 JVM 参数-XX:-UseBiasedLocking 来禁用偏向锁。
jdk 的演变过程中, 为偏向锁做了如上所述的批量升级、撤销等诸多动作。
但随着时代发展,发现偏向锁带来的维护、撤销成本, 远大于轻量级锁的少许 CAS 动作。
官方说明中有这么一段话: 由于在 HotSpot 中引入有偏差的锁定也改变了该关系保持真实所需的未处理操作的数量。
即随着硬件发展,原子指令成本变化,导致轻量级自旋锁需要的原子指令次数变少(或者 cas 操作变少 个人理解),所以自旋锁成本下降,故偏向锁的带来的优势就更小了。
于是 jdk 团队在 Jdk15 之后, 再次默认关闭了偏向锁。
也许你会问,那前面学习了那么一堆还有啥意义,都不推荐使用了。
但大部分 java 应用还是基于 jdk8 开发的, 并且偏向锁里的思想还是值得借鉴的。
还有就是奥卡姆剃刀原理, 如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差,将精力放在提升度更大的地方。
轻量级锁
轻量级锁的 markword 如下所示,可以看到除了锁状态标记位,其他的都变成了一个栈帧中 lockRecord 记的地址。
原先 markword 中的信息都去哪里了?
之前提到 markword 中有分代年龄、cms_free、hashcode 等固有属性。
这些信息会被存储到对应线程栈帧中的 lockRecord 中。
lockRecord 格式以及存储/交换过程如下:
另外注意, 当轻量级锁未上锁时, 对象头中的 markword 存储的还是 markword 内容,并没有变成指针,只有当上锁过程中,才会变成指针。
因此轻量级锁是存在反复的加锁解锁操作的(偏向锁只有在更换偏向线程时才会有类似动作)
解锁过程同理,通过 CAS,将对象头替换回去。
轻量级锁如何处理线程重入问题?
对于同一个线程,如果反复进入同步块,在 sync 语义上来说是支持重入的(即持有锁的线程可以多次进入锁区域), 对轻量级锁而言,必须实现这个功能。
因此线程的 lockRecord 并非单一成员,他其实是一个 lockRecord 集合,可以存储多个 lockRecord。
每当线程离开同步块,lockRecord 减少 1 个, 直到这个 lockReocrd 中包含指针,才会做解锁动作。
轻量级锁加锁过程
根据上述 CAS 和重入相关,可以得到进入同步代码块时的加锁过程:
进入同步块前,检查是否已经储存了 lockRecord 地址,且地址和自己当前线程一致 。如果已经存了且一致,说明正处于重入操作,走重入逻辑,新增 lockRecord
如果未重入,检查 lockRecord 是否被其他线程占用,如果被其他线程占用,则自旋等待,自旋超限后升级重量级锁
如果未重入,且也没被其他线程占用,则取出 lockRecord 中存的指针地址,然后再用自己的 markword 做 CAS 替换
替换失败,则尝试自旋重新 CAS,失败次数达到上限,也一样升级
轻量级锁的解锁流程
根据上面重入的问题,可以得到轻量级锁的退出流程如下:
自旋次数的上限一定是 10 次吗?
在 JDK 6 中对自旋锁的优化,引入了自适应的自旋。
自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续 100 次忙循环。
另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了
重量级锁
重量级锁如下:
每个对象会有一个 objectMonitor 的 C++对象生成, 通过地址指向对方,后面的逻辑都是通过 C++来实现。
升级为重量级锁的条件
从轻量级锁升级为重量级锁的条件: 自旋超过 10 次 或者达到自适应自旋上限次数
从无锁/偏向锁直接升级为重量级锁的条件:调用了 object.wait()方法,则会直接升级为重量级锁!
第二个条件容易被忽略的
markword 去哪了
对象头中的 markwod,和轻量级锁中的处理类似, 被存入了 objectMonitor 对象的 header 字段中了。
重量级锁同步的原理图解
每个对象的重量级锁指向一个独有的 objectMonitor
这个对象是 C++实现的
里面的东西比较多,内容非常复杂,里面关于 cxq、entryList、qmod 之间的关系非常复杂,这里只会简单解释部分过程,不一定全部正确或者包含所有细节。
因此特地拿出一句我认为说的很好的话:
“与其费劲心机研究 C++实现的 objectMonitor,不如去研究使用 java 实现的 AQS 原理,二者的核心思想大部分一致,AQS 源码在语言上对 java 人而言更为友好 ,能让你更好理解线程排队、等待、唤醒的各种过程”
但这篇文章毕竟说的是 sync 关键字,所以还是简要说一下 monitor 的过程:
当线程第一次调用 monitorEntry 执行且是重量级锁的情况下,会先进入 cxq 队列
当涉及锁的频繁竞争且需要阻塞时,需要进入 entryList 队列中。
如果线程能 CAS 竞争到 onwer 指针,就说明占有同步代码块成功, 如果 CAS 竞争不到,则 block 阻塞。
monitorExit 退出时,会让 entryList 中 block 阻塞的线程唤醒重新竞争
如果调用了 object.wait()方法, onwer 线程会进入等待队列(注意,因为竞争失败的线程,不会进入 waitSet,waitSet 只服务于那些 wait()方法引发的线程)
当调用的 object.notify()或者 notifyAll, 等待队列中的线程会根据 qmod 模式的不同,进入 cxq 或者进入 entryList。
简要版流程如下:
关于同步关键字的思考
终于写完了,说点其他的。
众所周知,随着 jdk 的不断升级, 官方提供的 JUC 以及衍生同步组件越来越强大, sync 与其相比,功能相当少,背后逻辑却异常复杂,甚至因为过于复杂,还在中间对偏向锁的功能进行了默认关闭的操作。
那么这个关键字是否还有存在的必要呢?
首先,很多历史代码以及内部某些 jdk 代码实现,都还是会依赖这个关键字进行同步处理,没法全部替换成 AQS。
另外,不考虑背后升级的复杂逻辑, sync 使用起来绝对是比 JUC 简单很多的, 当你的场景很简单,但确实有同步的问题, 用 sync 会提升不少开发的效率。
版权声明: 本文为 InfoQ 作者【华为云开发者联盟】的原创文章。
原文链接:【http://xie.infoq.cn/article/220b3ceadcaff7b17098d30a0】。文章转载请联系作者。
评论