7000+字图文并茂解带你深入理解java锁升级的每个细节

308 阅读21分钟

本文分享自华为云社区《对java锁升级,你是否还停留在表面的理解?7000+字和图解带你深入理解锁升级的每个细节》,作者:breakDawn 。

对于java锁升级,很多人都停留在比较浅层的表面理解,一定程度下也许够用,但如果学习其中的细节,我们更好地理解多线程并发时各种疑难问题的应对方式!

因此我将锁升级过程中可能涉及的大部分细节或者疑问都整合成了一篇文章,希望你能直接在这篇文章中,搞懂你当年学习这块时遗留的所有疑问。

为什么说线程切换会很慢?

所谓的用户态和内核态之间的切换仅仅是一方面, 并非全部, 更关键的在于, 多CPU的机器中,当你切换了线程,意味着线程在原先CPU上的缓存也许不再有用, 因为当线程重新触发时,可能在另一个CPU上执行了。

正因如此,不建议没事就挂起线程, 让线程自旋会比挂起后切换CPU好很多。

正与基于这一点,才有了后面的sync锁升级机制,理解了为什么要锁升级,才能逐步理解锁升级过程,

对象头中的mark-word

java每个对象的对象头中, 都有32或者64位的mark-word。

mark-word是理解锁升级过程的重要部分,且后面的锁升级过程都会涉及,因此这里会进行一个非常详细的解释。这部分只对一个对象必有的属性做解释(即一般不会随着锁状态变化而消失的属性)。对于各锁状态独有的属性,会在锁升级过程中做详细的解释。

cke_167.png

锁状态标志位 +偏向锁标记位(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垃圾收集机制中, 决定年轻代什么时候进入老年代的根据之一, 就是确认他的分代年龄是否达到阈值,如下图所示。

cke_168.png

分代年龄只有4bit可以看出,最大值只能是15。因此我们设置的进入老年代年龄阈值 -XX:MaxTenuringThreshold 最大只能设置15。

cms_free

在无锁和偏向锁中,还可以看到有1bit的cms_free。

实际上就是只有CMS收集器用到的。但最新java11中更多用的是G1收集器了,这一位相当于不怎么常用,因此提到的也非常少。

从上述可以看出, 只有锁状态标记位、 hashcode、 分代年龄、cms_free是必有的, 但是从markword最初的示意图来看, hashcode、 分代年龄、cms_free似乎并非一直存在,那么他们去哪了呢?会在后面的锁升级过程进行详细解释。

锁升级四个阶段超级详解

无锁

无锁状态的markword如下所示,可以看到上文提到的信息都存在

cke_169.png

处于无锁状态的条件或者时机是什么?

无锁状态用于对象刚创建,且还未进入过同步代码块的时候

这一点很重要, 意味着如果你没有同步代码块或者同步方法, 那么将是无锁状态。

对象从没进入同步块,为什么偏向锁标志位却是1?

上面这个问题说过,没进入同步块, 不会上偏向锁。

但是我们如果用java的jol工具测试打印新对象,会看到低3位是101

cke_170.png

这其实是jvm后面加入的一种优化, 对每个新对象,预置了一个**“可偏向状态”,也叫做匿名偏向状态**,是对象初始化中,JVM 帮我们做的。

注意此时 markword中高位是不存在ThreadID的, 都是0, 说明此时并没有线程偏向发生,因此也可以理解成是无锁。

好处在于后续做偏向锁加锁时,无需再去改动偏向锁标记位,只需要对线程id做cas即可。

偏向锁

一旦代码第一次进入sync同步方法块,就可能从无锁状态进入偏向锁状态。

另外很多人应该都知道, 偏向锁只存储了当前偏向的线程id, 只有线程id不同的才会触发升级。

但这是非常简化的说法, 实际上中间的细节和优化非常之多!这里将为你详细讲述。

为什么要有偏向锁?

理解这个才能理解偏向锁中的各种设计。 假设我们new出来的对象带有同步代码块方法,但在整个生命周期中只被一个线程访问,那么是否有必要做消耗消耗的竞争动作,甚至引入额外的内存开销?没有必要。

因此针对的是 对象有同步方法调用,但是实际不存在竞争的场景

偏向锁的markword详解

cke_171.png

这个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来调用。

那么这时候,有必要马上升级轻量级锁吗?

没必要!因为未来仍然是单线程调用,仅仅是线程不同而已,也许可以尝试仍旧用偏向锁?

于是就有了如下的撤销偏向锁的动作:

  1. 当线程B发现是偏向锁,且线程id不为自己时,开始撤销操作
  2. 首先,线程B会一直等待 对象obj 到达jvm安全点
  3. 到达安全点后, 线程B检查线程A是否正处在obj的同步代码块内。
  4. 如果线程A正在同步代码块中, 则没得商量了,直接升级为轻量级锁。
  5. 如果线程A不在同步代码块中, 那么线程B还有机会, 它先把偏向锁改成无锁状态,然后再用CAS的方式尝试重新竞争,如果能竞争到,那么就会偏向自己。

完整过程如下图所示:

cke_172.png

为什么要等待安全点,才能做撤销操作?

这是为了保证撤销操作的安全性。否则可能出现jvm正在撤销的时候, 另一个线程又开始对该对象做操作,引发错误。

为什么要先退化成无锁状态,再试图竞争成偏向锁?不能直接偏向吗?

因为你无法预测A是否会卷土重来,置成无锁后, A和B可以公平竞争。

为什么原偏向线程在同步代码块中时,就必须升级为轻量级锁?能否同样撤销无锁来竞争?

不可以,因为同步代码块还在执行的话,那B线程此时是注定无法立刻得到锁的,注定了它必须升级为轻量级锁,通过轻量级锁中的循环能力来做获取锁的操作。

批量重偏向,以及epoch的应用

上文提到, 线程B重新抢偏向锁时,会试图等待安全点,撤销成无锁,再做公平抢占。这个动作还是比较费时的。

假设有一个场景, 我们new 了30个obj对象, 最初都是由A线程使用,后面通过for循环都由B线程使用,那么会发现在很短的时间内,连续发生了偏向锁撤销为无锁,且未因同步块竞争而发生轻量升级的情况。

那么,jvm猜测此时后面都是类似的情况,于是B线程调用obj对象时,不再撤销了,直接CAS竞争threadId,因为jvm预测A不会来抢了,具体步骤如下所示:

  1. jvm会在obj对象的类类对象中, 定义了一个偏向撤销计数器以及epoch偏向版本。
  2. 每当有一个对象被撤销偏向锁, 都会让偏向撤销计数器+1。
  3. 一旦加到20, 则认为出现大规模的锁撤销动作。于是class类对象中的epoch值+1(但是epoch一般只有2位即0~3)。
  4. 接着, jvm会找到所有正处在同步代码块中的obj对象, 让他的epoch等于class类对象的epoch。
  5. 其他不在同步代码块中的obj对象,则不修改epoch。
  6. 当B线程来访问时,发现obj对象的epoch和class对象的epoch不相等,则不再做撤销动作,直接CAS抢占。因为当epoch不等时,这说明该obj对象之前一直没被原主人使用, 但它的兄弟们之前纷纷投降倒戈了, 那我应该直接尝试占用就好,没必要那么谨慎了!

详细过程如下图所示:

cke_173.png

批量撤销

但如果短时间内该类的撤销动作超过40个, jvm会认为这个数量太多了, 不保险,数量一多,预测就不准了。

jvm此时会将 obj对象的类类对象中的偏向标记**(注意是类中的偏向锁开启标记,而不是对象头中的偏向锁标记)**设置为禁用偏向锁。后续该对象的new操作将直接走轻量级锁的逻辑。

cke_174.png

偏向锁在进程一开始就启用了吗

即使你开启了偏向锁,但是这个偏向锁的启用是有延迟,大概 4s左右。

即java进程启动的4s内,都会直接跳过偏向锁,有同步代码块时直接使用轻量级锁。

原因是 JVM 初始化的代码有很多地方用到了同步,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,jvm 团队经过测试和评估, 选择了启动速度最快的方案, 即强制4s内禁用偏向锁,所以就有了这个延迟策略 (当然这个时间延迟也可以通过参数自己调整)

偏向锁的重要演变历史和思考

偏向锁在JDK6引入, 且默认开启偏向锁优化, 可通过JVM参数-XX:-UseBiasedLocking来禁用偏向锁。

jdk的演变过程中, 为偏向锁做了如上所述的批量升级、撤销等诸多动作。

但随着时代发展,发现偏向锁带来的维护、撤销成本, 远大于轻量级锁的少许CAS动作。

官方说明中有这么一段话: 由于在 HotSpot 中引入有偏差的锁定也改变了该关系保持真实所需的未处理操作的数量。

随着硬件发展,原子指令成本变化,导致轻量级自旋锁需要的原子指令次数变少(或者cas操作变少 个人理解),所以自旋锁成本下降,故偏向锁的带来的优势就更小了

于是jdk团队在Jdk15之后, 再次默认关闭了偏向锁

也许你会问,那前面学习了那么一堆还有啥意义,都不推荐使用了。

但大部分java应用还是基于jdk8开发的, 并且偏向锁里的思想还是值得借鉴的。

还有就是奥卡姆剃刀原理, 如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差,将精力放在提升度更大的地方。

轻量级锁

轻量级锁的markword如下所示,可以看到除了锁状态标记位,其他的都变成了一个栈帧中lockRecord记的地址。

cke_175.png

原先markword中的信息都去哪里了?

之前提到markword中有分代年龄、cms_free、hashcode等固有属性。

这些信息会被存储到对应线程栈帧中的lockRecord中。

lockRecord格式以及存储/交换过程如下:

cke_176.png

另外注意, 当轻量级锁未上锁时, 对象头中的markword存储的还是markword内容,并没有变成指针,只有当上锁过程中,才会变成指针。

因此轻量级锁是存在反复的加锁解锁操作的(偏向锁只有在更换偏向线程时才会有类似动作)

解锁过程同理,通过CAS,将对象头替换回去。

轻量级锁如何处理线程重入问题?

对于同一个线程,如果反复进入同步块,在sync语义上来说是支持重入的(即持有锁的线程可以多次进入锁区域), 对轻量级锁而言,必须实现这个功能。

因此线程的lockRecord并非单一成员,他其实是一个lockRecord集合,可以存储多个lockRecord

每当线程离开同步块,lockRecord减少1个, 直到这个lockReocrd中包含指针,才会做解锁动作。

cke_177.png

轻量级锁加锁过程

根据上述CAS和重入相关,可以得到进入同步代码块时的加锁过程:

  1. 进入同步块前,检查是否已经储存了lockRecord地址,且地址和自己当前线程一致 。如果已经存了且一致,说明正处于重入操作,走重入逻辑,新增lockRecord

  2. 如果未重入,检查lockRecord是否被其他线程占用,如果被其他线程占用,则自旋等待,自旋超限后升级重量级锁

  3. 如果未重入,且也没被其他线程占用,则取出lockRecord中存的指针地址,然后再用自己的markword做CAS替换

  4. 替换失败,则尝试自旋重新CAS,失败次数达到上限,也一样升级

    cke_178.png

轻量级锁的解锁流程

根据上面重入的问题,可以得到轻量级锁的退出流程如下:

cke_179.png

自旋次数的上限一定是10次吗?

在JDK 6中对自旋锁的优化,引入了自适应的自旋。

自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。

另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了

重量级锁

重量级锁如下:

每个对象会有一个objectMonitor的C++对象生成, 通过地址指向对方,后面的逻辑都是通过C++来实现。

cke_180.png

升级为重量级锁的条件

  1. 从轻量级锁升级为重量级锁的条件: 自旋超过10次 或者达到自适应自旋上限次数
  2. 从无锁/偏向锁直接升级为重量级锁的条件:调用了object.wait()方法,则会直接升级为重量级锁!

第二个条件容易被忽略的

markword去哪了

对象头中的markwod,和轻量级锁中的处理类似, 被存入了objectMonitor对象的header字段中了。

重量级锁同步的原理图解

每个对象的重量级锁指向一个独有的objectMonitor

这个对象是C++实现的

里面的东西比较多,内容非常复杂,里面关于cxq、entryList、qmod之间的关系非常复杂,这里只会简单解释部分过程,不一定全部正确或者包含所有细节。

因此特地拿出一句我认为说的很好的话:

“与其费劲心机研究C++实现的objectMonitor,不如去研究使用java实现的AQS原理,二者的核心思想大部分一致,AQS源码在语言上对java人而言更为友好 ,能让你更好理解线程排队、等待、唤醒的各种过程”

但这篇文章毕竟说的是sync关键字,所以还是简要说一下monitor的过程:

  1. 当线程第一次调用monitorEntry执行且是重量级锁的情况下,会先进入cxq队列

    cke_181.png

  2. 当涉及锁的频繁竞争且需要阻塞时,需要进入entryList队列中。

    cke_182.png

  3. 如果线程能CAS竞争到onwer指针,就说明占有同步代码块成功, 如果CAS竞争不到,则block阻塞。

    cke_183.png

  4. monitorExit退出时,会让entryList中block阻塞的线程唤醒重新竞争

    cke_184.png

  5. 如果调用了object.wait()方法, onwer线程会进入等待队列(注意,因为竞争失败的线程,不会进入waitSet,waitSet只服务于那些wait()方法引发的线程)

    cke_185.png

  6. 当调用的object.notify()或者notifyAll, 等待队列中的线程会根据qmod模式的不同,进入cxq或者进入entryList。

    cke_186.png

简要版流程如下:

cke_187.png

关于同步关键字的思考

终于写完了,说点其他的。

众所周知,随着jdk的不断升级, 官方提供的JUC以及衍生同步组件越来越强大, sync与其相比,功能相当少,背后逻辑却异常复杂,甚至因为过于复杂,还在中间对偏向锁的功能进行了默认关闭的操作。

那么这个关键字是否还有存在的必要呢?

首先,很多历史代码以及内部某些jdk代码实现,都还是会依赖这个关键字进行同步处理,没法全部替换成AQS。

另外,不考虑背后升级的复杂逻辑, sync使用起来绝对是比JUC简单很多的, 当你的场景很简单,但确实有同步的问题, 用sync会提升不少开发的效率。

点击关注,第一时间了解华为云新鲜技术~