偏向锁101
一旦代码第一次进入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的操作。
撤销偏向锁:
- 当线程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对象的类class对象中, 定义了一个偏向撤销计数器以及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对象的类class对象中的偏向标记**(注意是类中的偏向锁开启标记,而不是对象头中的偏向锁标记)**设置为禁用偏向锁。 后续该对象的new操作将直接走轻量级锁的逻辑。
偏向锁在进程一开始就启用了吗
即使你开启了偏向锁,但是这个偏向锁的启用是有延迟,大概 4s左右。
即java进程启动的4s内,都会直接跳过偏向锁,有同步代码块时直接使用轻量级锁。
原因是 JVM 初始化的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,jvm团队经过测试和评估, 选择了启动速度最快的方案, 即强制4s内禁用偏向锁,所以就有了这个延迟策略 (当然这个延迟时间也可以通过参数自己调整)
偏向锁的重要演变历史和思考
偏向锁在JDK6引入, 且默认开启偏向锁优化, 可通过JVM参数-XX:-UseBiasedLocking来禁用偏向锁。
jdk的演变过程中, 为偏向锁做了如上所述的批量升级、撤销等诸多动作。
但随着时代发展,发现偏向锁带来的维护、撤销成本, 远大于轻量级锁的少许CAS动作。
官方说明中有这么一段话: since the introduction of biased locking into HotSpot also change the amount of uncontended operations needed for that relation to remain true。
即随着硬件发展,原子指令成本变化,导致轻量级自旋锁需要的原子指令次数变少(或者cas操作变少 个人理解),所以自旋锁成本下降,故偏向锁的带来的优势就更小了。
于是jdk团队在Jdk15之后, 再次默认关闭了偏向锁。
也许你会问,那前面学习了那么一堆还有啥意义,都不推荐使用了。
但大部分java应用还是基于jdk8开发的, 并且偏向锁里的思想还是值得借鉴的。
还有就是奥卡姆剃刀原理, 如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差,将精力放在提升度更大的地方。
轻量级锁10
轻量级锁的markword如下所示,可以看到除了锁状态标记位,其他的都变成了一个栈帧中lockRecord记的地址。
原先markword中的信息都去哪里了?
之前提到markword中有分代年龄、cms_free、hashcode等固有属性。
这些信息会被存储到对应线程栈帧中的lockRecord中。
另外注意, 当轻量级锁未上锁时, 对象头中的markword存储的还是markword内容,并没有变成指针,只有当上锁过程中,才会变成指针。
解锁过程同理,通过CAS,将对象头替换回去。
轻量级锁如何处理线程重入问题?
对于同一个线程,如果反复进入同步块,在sync语义上来说是支持重入的(即持有锁的线程可以多次进入锁区域), 对轻量级锁而言,必须实现这个功能。
因此线程的lockRecord并非单一成员,他其实是一个lockRecord集合,可以存储多个lockRecord。
每当线程离开同步块,lockRecord减少1个, 直到这个lockReocrd中包含指针,才会做解锁动作。
轻量级锁加锁过程
根据上述CAS和重入相关,可以得到进入同步代码块时的加锁过程:
- 进入同步块前,检查是否已经储存了lockRecord地址,且地址和自己当前线程一致 。如果已经存了且一致,说明正处于重入操作,走重入逻辑,新增lockRecord
- 如果未重入,检查lockRecord是否被其他线程占用,如果被其他线程占用,则自旋等待,自旋超限后升级重量级锁
- 如果未重入,且也没被其他线程占用,则取出lockRecord中存的指针地址,然后再用自己的markword做CAS替换
- 替换失败,则尝试自旋重新CAS,失败次数达到上限,也一样升级
轻量级锁的解锁流程
自旋次数的上限一定是10次吗?
在JDK 6中对自旋锁的优化,引入了自适应的自旋。
自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。
另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了
重量级锁10
每个对象会有一个objectMonitor的C++对象生成, 通过地址指向对方,后面的逻辑都是通过C++来实现。
升级为重量级锁的条件
- 从轻量级锁升级为重量级锁的条件: 自旋超过10次 或者达到自适应自旋上限次数
- 从无锁/偏向锁直接升级为重量级锁的条件:调用了object.wait()方法,则会直接升级为重量级锁!
第二个条件容易被忽略的
markword去哪了
对象头中的markwod,和轻量级锁中的处理类似, 被存入了objectMonitor对象的header字段中了。
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
总结
锁升级之后,本来存储hashcode的位置被占用了,那么hashcode 去哪里了
- 无锁状态:当调用hashcode()之后,将永远不会升级为偏向锁,会跳过偏向锁升级为轻量级锁
- 偏向锁状态:收到hashcode()请求之后,会立刻撤销偏向状态,膨胀为重量级锁
- 轻量级锁状态:会存储在lock record空间中
- 重量级锁状态:会存储在ObjectMonitor对象中
synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。
实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式
锁消除和锁粗化
JIT(Just In Compiler)即时编译器对锁的优化
锁消除
分析synchronized锁对象是不是只可能被一个线程加锁,不存在其他线程来竞争加锁的情况。这时就可以消除该锁了,提升执行效率。
锁消除指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
例如:StringBuffer 的 append()
锁粗化
JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。
参考资料
https://www.bilibili.com/video/BV1ar4y1x727/?spm_id_from=333.337.search-card.all.click