JAVA锁的优化和膨胀过程
转自:https://www.cnblogs.com/dsj2016/p/5714921.html
https://cloud.tencent.com/developer/article/1036756
复杂过程见转的另一篇:https://i.cnblogs.com/PostDone.aspx?postid=10150063&actiontip=%e5%8f%91%e5%b8%83%e6%88%90%e5%8a%9f
首先说一下锁的优化策略。
1,自旋锁
自选锁其实就是在拿锁时发现已经有线程拿了锁,自己如果去拿会阻塞自己,这个时候会选择进行一次忙循环尝试。也就是不停循环看是否能等到上个线程自己释放锁。这个问题是基于一个现实考量的:很多拿了锁的线程会很快释放锁。因为一般敏感的操作不会很多。当然这个是一个不能完全确定的情况,只能说总体上是一种优化。
举个例子就好比一个人要上厕所发现厕所里面有人,他可以:1,等一小会。2,跑去另外的地方上厕所。等一小会不一定能等到前一个人出来,不过如果跑去别的厕所的花费的时间肯定比等一小会结果前一个人出来了长。当然等完了结果那个人没出来还是要跑去别的地方上厕所这是最慢的。
然后是基于这种做法的一个优化:自适应自旋锁。也就是说,第一次设置最多自旋10次,结果在自旋的过程中成功获得了锁,那么下一次就可以设置成最多自旋20次。道理是:一个锁如果能够在自旋的过程中被释放说明很有可能下一次也会发生这种事。那么就更要给这个锁某种“便利”方便其不阻塞得锁(毕竟快了很多)。同样如果多次尝试的结果是完全不能自旋等到其释放锁,那么就说明很有可能这个临界区里面的操作比较耗时间。就减小自旋的次数,因为其可能性太小了。
2,锁粗化
试想有一个循环,循环里面是一些敏感操作,有的人就在循环里面写上了synchronized关键字。这样确实没错不过效率也许会很低,因为其频繁地拿锁释放锁。要知道锁的取得(假如只考虑重量级MutexLock)是需要操作系统调用的,从用户态进入内核态,开销很大。于是针对这种情况也许虚拟机发现了之后会适当扩大加锁的范围(所以叫锁粗化)以避免频繁的拿锁释放锁的过程。
3,锁消除
通过逃逸分析发现其实根本就没有别的线程产生竞争的可能(别的线程没有临界量的引用),而“自作多情”地给自己加上了锁。有可能虚拟机会直接去掉这个锁。
膨胀过程
4,偏向锁和轻量级锁
这两个锁既是一种优化策略,也是一种膨胀过程所以一起说。首先它们的关系是:最高效的是偏向锁,尽量使用偏向锁,如果不能(发生了竞争)就膨胀为轻量级锁,这样优化的效率不如原来高不过还是一种优化(对比重量级锁而言)。所以整个过程是尽可能地优化。
首先说说偏向锁。
HotSpot的研究人员发现大多数情况下虽然加了锁,但是没有竞争的发生,甚至是同一个线程反复获得这个锁。那么偏向锁就为了针对这种情况。
举个例子,一个仓库管理员管着钥匙,然而每一次都是老王去借,仓库管理员于是就认识了老王,直接和他说,“行,你直接拿就是不用填表格了我记得你”。
讲一下偏向锁的具体过程。首先JVM要设置为可用偏向锁。然后当一个进程访问同步块并且获得锁的时候,会在对象头和栈帧的锁记录里面储存取得偏向锁的线程ID。
下一次有线程尝试获取锁的时候,首先检查这个对象头的MarkWord是不是储存着这个线程的ID。如果是,那么直接进去而不需要任何别的操作。如果不是,那么分为两种情况。1,对象的偏向锁标志位为0(当前不是偏向锁),说明发生了竞争,已经膨胀为轻量级锁,这时使用CAS操作尝试获得锁(这个操作具体是轻量级锁的获得锁的过程下面讲)。2,偏向锁标志位为1,说明还是偏向锁不过请求的线程不是原来那个了。这时只需要使用CAS尝试把对象头偏向锁从原来那个线程指向目前求锁的线程。这种情况举个例子就是老王准备退休了,他儿子接替他来拿钥匙,于是仓库管理员认识了他儿子,他儿子每次来也不用登记注册了。
这个CAS失败了呢?首先必须明确这个CAS为什么会失败,也就是说发生了竞争,有别的线程和它抢锁并且抢赢了,那么这个情况下,它就会要求撤销偏向锁(因为发生了竞争)。接着它首先暂停拥有偏向锁的线程,检查这个线程是否是个活动线程,如果不是,那么好,你拿了锁但是没在干事,锁还记录着你,那么直接把对象头设置为无锁状态重新来过。如果还是活动线程,先遍历栈帧里面的锁记录,让这个偏向锁变为无锁状态,然后恢复线程。
再说轻量级锁。这是偏向锁膨胀之后的产物。
加锁的过程:JVM在当前线程的栈帧中创建用于储存锁记录的空间(LockRecord),然后把MarkWord放进去,同时生成一个叫Owner的指针指向那个加锁的对象,同时用CAS尝试把对象头的MarkWord成一个指向锁记录的指针。成功了就拿到了锁。那么失败了呢?失败了的说法比较多。主流有《深入理解JVM》的说法和《并发编程的艺术》的说法。
《深入理解JVM》的说法:
失败了,去查看MarkWord的值。有2种可能:1,指向当前线程的指针,2,别的值。
如果是1,那么说明发生了“重入”的情况,直接当做成功获得锁处理。
其实这个有个疑问,为什么获得锁成功了而CAS失败了,这里其实要牵扯到CAS的具体过程:先比较某个值是不是预测的值,是的话就动用原子操作交换(或赋值),否则不操作直接返回失败。在用CAS的时候期待的值是其原本的MarkWord。发生“重入”的时候会发现其值不是期待的原本的MarkWord,而是一个指针,所以当然就返回失败,但是如果这个指针指向这个线程,那么说明其实已经获得了锁,不过是再进入一次。如果不是这个线程,那么情况2:
如果是2,那么发生了竞争,锁会膨胀为一个重量级锁(MutexLock)
《并发编程的艺术》的说法:
失败了直接自旋。期望在自旋的时间内获得锁,如果还是不能获得,那么开始膨胀,修改锁的MarkWord改为重量级锁的指针,并且阻塞自己。
解锁过程:(那个拿到锁的线程)用CAS把MarkWord换回到原来的对象头,如果成功,那么没有竞争发生,解锁完成。如果失败,表示存在竞争(之前有线程试图通过CAS修改MarkWord),这时要释放锁并且唤醒阻塞的线程。
重量级锁
重量级锁(heavy weight lock
),是使用操作系统互斥量(mutex
)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate
)为重量锁时,就不能再退回到轻量级锁。
java中每个对象都可作为锁,锁有四种级别,按照量级从轻到重分为:无锁、偏向锁、轻量级锁、重量级锁。每个对象一开始都是无锁的,随着线程间争夺锁,越激烈,锁的级别越高,并且锁只能升级不能降级。
一、java对象头
锁的实现机制与java对象头息息相关,锁的所有信息,都记录在java的对象头中。用2字(32位JVM中1字=32bit=4baye)存储对象头,如果是数组类型使用3字存储(还需存储数组长度)。对象头中记录了hash值、GC年龄、锁的状态、线程拥有者、类元数据的指针。
二、偏向锁
在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。
那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步,退出同步也,无需每次加锁解锁都去CAS更新对象头,如果不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候需要锁膨胀为轻量级锁,才能保证线程间公平竞争锁。
1.加锁
偏向锁加锁发生在偏向线程第一次进入同步块时,CAS原子操作尝试更新对象的Mark Word(偏向锁标志位为"1",记录偏向线程的ID)。
2.撤销偏向锁
当有另一个线程来竞争锁的时候,就不能再使用偏向锁了,要膨胀为轻量级锁。
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(此时不会执行任何代码)撤销偏向锁。
三、轻量级锁
轻量锁与偏向锁不同的是:
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
- 每次进入退出同步块都需要CAS更新对象头
- 争夺轻量级锁失败时,自旋尝试抢占锁
可以看到轻量锁适合在竞争情况下使用,其自旋锁可以保证响应速度快,但自旋操作会占用CPU,所以一些计算时间长的操作不适合使用轻量级锁。
1.加锁
加锁过程和偏向锁加锁差不多,也是CAS修改对象头,只是修改的内容不同。
- 在MarkWord中保存当前线程的指针
- 修改锁标识位为“00”
采用CAS操作的原因是,不想在加锁解锁上再加同步
如果对象处于无锁状态(偏向锁标志位为"0",锁标志位为"01"),会在线程的栈中开辟个锁记录空间(Lock Record),将Mark Word拷贝一份到Lock Record中,称为Displaced Mark Word,在Lock Record中保存对象头的指针(owner)。
接下来CAS更新MarkWord,将MarkWord指向当前线程,owner指向MarkWord,如果失败了,则意味着出现了另一个线程竞争锁,此时需要锁膨胀为轻量级锁。
2.解锁
用CAS操作锁置为无锁状态(偏向锁位为"0",锁标识位为"01"),若CAS操作失败则是出现了竞争,锁已膨胀为重量级锁了,此时需要释放锁(持有重量级锁线程的指针位为"0",锁标识位为"10")并唤醒重量锁的线程。
3.膨胀为重量级锁
当竞争线程尝试占用轻量级锁失败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。
三、重量级锁
重量级锁的加锁、解锁过程和轻量级锁差不多,区别是:竞争失败后,线程阻塞,释放锁后,唤醒阻塞的线程,不使用自旋锁,不会那么消耗CPU,所以重量级锁适合用在同步块执行时间长的情况下。
偏向锁、轻量级锁和重量级锁对比
锁类型 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁、解锁不需要额外资源消耗,效率较高 | 如果线程间存在锁竞争,会带来额外的解锁消耗 | 适用只有一个线程访问同步块的情景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序响应速度 | 如果获取锁失败,会进入自旋消耗cpu | 针对锁占用时间短,对响应时间比较敏感的情况 |
重量级锁 | 线程竞争不使用自旋,不消耗cpu | 线程会被阻塞,影响响应时间 | 锁占用时间较长,对吞吐量要求较高 |