1.概述
JDK1.6 之后 Synchronized 的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
Java中的synchronized
的偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。当条件不满足时,锁会按偏向锁->轻量级锁->重量级锁 的顺序升级。JVM种的锁也是能降级的,只不过条件很苛刻,可以相当于没有了,策略是为了提高获得锁和释放锁的效率。
所以对Synchronized 的重点分析应该是其升级流程,以前是我觉得So easy,不就这几个状态升上去而已,不过在某天看了 死磕Synchronized底层实现 之后,发现我还是太嫩了,这才是真正的深入,也许对知识的求知就该如此不断的进行深入,对于Synchronized 还是有很多值得发现的知识,以下记录了学习到的一些笔记,大概对一整个锁的升级流程有了一些认识和了解。
- 锁升级的过程可以具体看该图,大致的流程框架图很清晰(文末已注明出处)
2.Synchronized 锁升级流程分析
2.1 偏向锁
目的:引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。但是不同是:轻量级锁在无竞争的情况下使用
CAS
操作去代替使用互斥量,而偏向锁在无竞争的情况下会把整个同步都消除掉。那么偏向锁是如何来减少不必要的CAS
操作呢?我们可以查看Mark work
的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID
即可。- 注意:Java并发编程的艺术中是这么讲的:HotSpot[1]的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
定义:偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!关于偏向锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。当
JVM
启用了偏向锁模式(JDK6
以上默认开启),新创建对象的Mark Word中的Thread Id
为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)。适用场合:但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用
JVM
参数来关闭延迟:-XX:BiasedLockingStartupDelay
=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM
参数关闭偏向锁:-XX:-UseBiasedLocking
=false,那么程序默认会进入轻量级锁状态。
2.1.1 Mark Work 结构
关于Mark work
结构,可以在任何一本关于Java内存结构的书中了解到很详细了,我们主要关注的是下面的几个字段:thread id
、lock flag、biased lock flag。
2.1.2 对象创建
当 JVM
启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(默认所有class的偏向模式都是是开启的),那新创建对象的mark word
将是可偏向状态,此时mark word中的thread id(参见上文偏向状态下的mark word
格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
2.1.3 偏向锁加锁
对于偏向锁的加锁,主要分为三种不同情况来看:
case 1
:当该对象第一次被线程获得锁的时候,发现是匿名偏向状态(可偏向未锁定),则会用CAS指令,将mark word
中的thread id
由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,即CAS
竞争锁失败,对象锁已经被其他线程占用,证明当前存在多线程竞争情况,当到达全局安全点(即为safepoint
,safepoint
是什么可以具体参考这篇文章:聊聊JVM(六)理解JVM的safepoint),将偏向锁撤销,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;case 2
:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程(对象头Mark Word中Thread Id是当前线程ID),在通过一些额外的检查后(细节见后面的文章),会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,用来统计重入的次数(如图为当对象所处于偏向锁时,当前线程重入3次,线程栈帧中Lock Record记录)。然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。- case 3.当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在
safepoint
中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word
改为无锁状态(unlocked
),之后再升级为轻量级锁。
由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。
2.1.4 偏向锁解锁
当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record
来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record
的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id
。
2.1.5 偏向锁获取锁
取自Java并发编程的艺术:
- 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行
CAS
操作来加锁和解锁,只需简单地测试一下对象头的Mark Word
里是否存储着指向当前线程的偏向锁。 - 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS
竞争
锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
网上参考了很多文章,发现说辞不一,十分混乱,大部分都各持己见,这让我看得很晕乎,于是还是更相信底层代码的逻辑,查看官方提供给的 JVM 底层C++代码: bytecodeInterpreter.cpp#1816,结合 farmerjohngit 大佬所给的一些解释,可以对整个底层实现有更加深刻的理解。
1 | CASE(_monitorenter): { |
JVM中的每个类也有一个类似mark word的prototype_header,用来标记该class的epoch和偏向开关等信息。上面的代码中lockee->klass()->prototype_header()
即获取class的prototype_header。
code 1
- 从当前线程的栈中找到一个空闲的
Lock Record
(即代码中的BasicObjectLock,下文都用Lock Record代指),判断Lock Record
是否空闲的依据是其obj字段 是否为null。注意这里是按内存地址从低往高找到最后一个可用的Lock Record
,换而言之,就是找到内存地址最高的可用Lock Record
。
code 2
- 获取到
Lock Record
后,首先要做的就是为其obj字段赋值。
code 3
- 判断锁对象的
mark word
是否是偏向模式,即低3位是否为101。
code 4
- 这里有几步位运算的操作
anticipated_bias_locking_value = (((uintptr_t)lockee->klass()->prototype_header() | thread_ident) ^ (uintptr_t)mark) & ~((uintptr_t) markOopDesc::age_mask_in_place);
这个位运算可以分为3个部分。- 第一部分
((uintptr_t)lockee->klass()->prototype_header() | thread_ident)
将当前线程id和类的prototype_header相或,这样得到的值为(当前线程id + prototype_header中的(epoch + 分代年龄 + 偏向锁标志 + 锁标志位)),注意prototype_header的分代年龄那4个字节为0 - 第二部分
^ (uintptr_t)mark
将上面计算得到的结果与锁对象的markOop进行异或,相等的位全部被置为0,只剩下不相等的位。 - 第三部分
& ~((uintptr_t) markOopDesc::age_mask_in_place)
markOopDesc::age_mask_in_place为…0001111000,取反后,变成了…1110000111,除了分代年龄那4位,其他位全为1;将取反后的结果再与上面的结果相与,将上面异或得到的结果中分代年龄给忽略掉。
- 第一部分
code 5
anticipated_bias_locking_value==0
代表偏向的线程是当前线程且mark word
的epoch等于class的epoch,这种情况下什么都不用做。
code 6
(anticipated_bias_locking_value & markOopDesc::biased_lock_mask_in_place) != 0
代表class的prototype_header或对象的mark word
中偏向模式是关闭的,又因为能走到这已经通过了mark->has_bias_pattern()
判断,即对象的mark word
中偏向模式是开启的,那也就是说class的prototype_header不是偏向模式。然后利用
CAS
指令Atomic::cmpxchg_ptr(header, lockee->mark_addr(), mark) == mark
撤销偏向锁,我们知道CAS
会有几个参数,1是预期的原值,2是预期修改后的值 ,3是要修改的对象,与之对应,cmpxchg_ptr
方法第一个参数是预期修改后的值,第2个参数是修改的对象,第3个参数是预期原值,方法返回实际原值,如果等于预期原值则说明修改成功。
code 7
- 如果epoch已过期,则需要重偏向,利用CAS指令将锁对象的
mark word
替换为一个偏向当前线程且epoch为类的epoch的新的mark word
。
code 8
- CAS将偏向线程改为当前线程,如果当前是匿名偏向则能修改成功,否则进入锁升级的逻辑。
code 9
- 这一步已经是轻量级锁的逻辑了。从上图的
mark word
的格式可以看到,轻量级锁中mark word
存的是指向Lock Record
的指针。这里构造一个无锁状态的mark word
,然后存储到Lock Record
(Lock Record
的格式可以看第一篇文章)。设置mark word
是无锁状态的原因是:轻量级锁解锁时是将对象头的mark word
设置为Lock Record
中的Displaced Mark Word
,所以创建时设置为无锁状态,解锁时直接用CAS替换就好了。
code 10
- 如果是锁重入,则将
Lock Record
的Displaced Mark Word
设置为null,起到一个锁重入计数的作用。
通过这部分代码,其实可以对偏向锁加锁的流程(包括部分轻量级锁的加锁流程)有一定的认识了,如果当前锁已偏向其他线程||epoch值过期||偏向模式关闭||获取偏向锁的过程中存在并发冲突,都会进入到InterpreterRuntime::monitorenter
方法, 在该方法中会对偏向锁撤销和升级。
2.1.6 偏向锁释放
偏向锁的释放入口:bytecodeInterpreter.cpp#1923
上面的代码结合注释理解起来应该不难,偏向锁的释放很简单,只要将对应Lock Record释放就好了,而轻量级锁则需要将Displaced Mark Word替换到对象头的mark word中。如果CAS失败或者是重量级锁则进入到InterpreterRuntime::monitorexit
方法中。
- 注意:撤销是指在获取偏向锁的过程因为不满足条件导致要将锁对象改为非偏向锁状态;释放是指退出同步块时的过程
2.1.7 偏向锁撤销
偏向锁的撤销采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程,检查持有偏向锁的线程状态(遍历当前
JVM
的所有线程,如果能找到,则说明偏向的线程还存活);JVM
维护了一个集合存放所有存活的线程,通过遍历该集合判断某个线程是否存活。 - 如果线程还存活,则检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁,进行CAS竞争锁;
- 注:每次进入同步块(即执行
monitorenter
)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record
,并设置偏向线程ID;每次解锁(即执行monitorexit
)的时候都会从最低的一个Lock Record
移除。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步代码块中的代码。
- 注:每次进入同步块(即执行
- 如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向:
- 如果不允许重偏向,则撤销偏向锁,将
Mark Word
设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行CAS竞争
锁; - 如果允许重偏向,设置为匿名偏向锁状态,
CAS
将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID);
- 如果不允许重偏向,则撤销偏向锁,将
- 唤醒暂停的线程,从安全点继续执行代码。
偏向锁撤销的具体流程如下所示:
2.1.8 批量重偏向与撤销
JVM中还增加了一种批量重偏向/撤销的机制,主要是解决如下两种情况:
- 重偏向(
bulk rebias
)机制解决的场景:一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。 - 批量撤销(
bulk revoke
)解决的场景:存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。
2.2 轻量级锁
- 描述:倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。== 轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。== 另外,轻量级锁的加锁和解锁都用到了CAS操作。 关于轻量级锁的加锁和解锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。
- 轻量级锁能够提升程序同步性能的依据:“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用
CAS
操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS
操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!
2.2.1 轻量级锁获取锁
其获取锁步骤如下:
- 判断当前对象是否处于无锁状态(
hashcode
、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record
)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word
);否则执行步骤(3); JVM
利用CAS
操作尝试将对象的Mark Word
更新为指向Lock Record
的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);- 判断当前对象的
Mark Word
是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;
栈帧 与 Mark Work
关系图示如下:
整体流程图示如下:
2.2.2 轻量级锁释放锁
其释放锁步骤如下(轻量级锁的释放也是通过CAS操作来进行的):
- 取出在获取轻量级锁保存在
Displaced Mark Word
中的数据; - 用
CAS
操作将取出的数据替换当前对象的Mark Word
中,如果成功,则说明释放锁成功,否则执行(3); - 如果
CAS
操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
其过程流程图如下所示:
2.2.3 轻量级锁膨胀
其过程流程图如下所示
- 一个问题:为什么在撤销轻量级锁的时候会有失败的可能?
- 假设
thread1
拥有了轻量级锁,Mark Word指向thread1
栈帧,thread2
请求锁的时候,就会膨胀初始化ObjectMonitor
对象,将Mark Word更新为指向ObjectMonitor
的指针,那么在thread1退出的时候,CAS
操作会失败,因为Mark Word不再指向thread1
的栈帧,这个时候thread1
自旋等待infalte
完毕,执行重量级锁的退出操作
- 假设
2.4 重量级锁
- 描述:重量级锁通过对象内部的监视器(
monitor
)实现,其中monitor
的本质是依赖于底层操作系统的Mutex Lock
实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
4.小结
4.1 总结锁的升级流程:
每一个线程在准备获取共享资源时:
- 第一步:检查
MarkWord
里面是不是放的自己的ThreadId
,如果是,表示当前线程是处于 “偏向锁” 。 - 第二步:如果
MarkWord
不是自己的ThreadId
,锁升级,这时候,使用CAS
来执行切换,新的线程根据MarkWord
里面现有的ThreadId
,通知之前线程暂停,之前线程将Markword
的内容置为空。 - 第三步:两个线程都把锁对象的
HashCode
复制到自己新建的用于存储锁的记录空间,接着开始通过CAS
操作, 把锁对象的MarKword
的内容修改为自己新建的记录空间的地址的方式竞争MarkWord
。 - 第四步:第三步中成功执行
CAS
的获得资源,失败的则进自旋 。 - 第五步:自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败进入第六步 。
- 第六步:进行重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
4.2 几种锁的优缺点对比
下图摘自:并发编程的艺术
以上参考文章:
- Java Synchronised机制
- 死磕Synchronized底层实现–偏向锁
- 聊聊JVM(六)理解JVM的safepoint
- 【死磕Java并发】—–深入分析synchronized的实现原理
- 书籍:Java 并发编程的艺术
- 书籍:深入理解Java虚拟机:JVM高级特性与最佳实践
- 书籍:深入浅出Java 多线程