好文收录 - 死磕Synchronized底层实现--概论
死磕Synchronized底层实现—概论
关于synchronized
的底层实现,网上有很多文章了。但是很多文章要么作者根本没看代码,仅仅是根据网上其他文章总结、照搬而成,难免有些错误;要么很多点都是一笔带过,对于为什么这样实现没有一个说法,让像我这样的读者意犹未尽。
本系列文章将对HotSpot的synchronized
锁实现进行全面分析,内容包括偏向锁、轻量级锁、重量级锁的加锁、解锁、锁升级流程的原理及源码分析,希望给在研究synchronized
路上的同学一些帮助。主要包括以下几篇文章:
更多文章见个人博客:https://github.com/farmerjohngit/myblog
大概花费了两周的实现看代码(花费了这么久时间有些忏愧,主要是对C++、JVM底层机制、JVM调试以及汇编代码不太熟),将synchronized
涉及到的代码基本都看了一遍,其中还包括在JVM中添加日志验证自己的猜想,总的来说目前对synchronized
这块有了一个比较全面清晰的认识,但水平有限,有些细节难免有些疏漏,还望请大家指正。
本篇文章将对synchronized
机制做个大致的介绍,包括用以承载锁状态的对象头、锁的几种形式、各种形式锁的加锁和解锁流程、什么时候会发生锁升级。需要注意的是本文旨在介绍背景和概念,在讲述一些流程的时候,只提到了主要case,对于实现细节、运行时的不同分支都在后面的文章中详细分析。
本人看的JVM版本是jdk8u,具体版本号以及代码可以在这里看到。
synchronized简介
Java中提供了两种实现同步的基础语义:synchronized
方法和synchronized
块, 我们来看个demo:
1 | public class SyncTest { |
当SyncTest.java被编译成class文件的时候,synchronized
关键字和synchronized
方法的字节码略有不同,我们可以用javap -v
命令查看class文件对应的JVM字节码信息,部分信息如下:
1 | { |
从上面的中文注释处可以看到,对于synchronized
关键字而言,javac
在编译时,会生成对应的monitorenter
和monitorexit
指令分别对应synchronized
同步块的进入和退出,有两个monitorexit
指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac
为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit
命令释放锁。而对于synchronized
方法而言,javac
为其生成了一个ACC_SYNCHRONIZED
关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED
修饰,则会先尝试获得锁。
在JVM底层,对于这两种synchronized
语义的实现大致相同,在后文中会选择一种进行详细分析。
因为本文旨在分析synchronized
的实现原理,因此对于其使用的一些问题就不赘述了,不了解的朋友可以看看这篇文章。
锁的几种形式
传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用mutex
互斥锁,最底层实现依赖于futex
,关于futex
可以看我之前的文章,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized
关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。
在JDK 1.6之前,synchronized
只有传统的锁机制,因此给开发者留下了synchronized
关键字相比于其他同步机制性能不好的印象。
在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
在看这几种锁机制的实现前,我们先来了解下对象头,它是实现多种锁机制的基础。
对象头
因为在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(比如当前哪个线程持有锁,哪些线程在等待)。一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:需要对map做线程安全保障,不同的synchronized
之间会相互影响,性能差;另外当同步对象较多时,该map可能会占用比较多的内存。
所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。
在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word
和类型指针。另外对于数组而言还会有一份记录数组长度的数据。
类型指针是指向该对象所属类对象的指针,mark word
用于存储对象的HashCode、GC分代年龄、锁状态等信息。在32位系统上mark word
长度为32bit,64位系统上长度为64bit。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下:
可以看到锁信息也是存在于对象的mark word
中的。当对象状态为偏向锁(biasable)时,mark word
存储的是偏向的线程ID;当状态为轻量级锁(lightweight locked)时,mark word
存储的是指向线程栈中Lock Record
的指针;当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。
重量级锁
重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。
重量级锁的状态下,对象的mark word
为指向一个堆中monitor对象的指针。
一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。
其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter
对象插入到cxq的队列的队首,然后调用park
函数挂起当前线程。
当持有锁的线程释放锁时,会从cxq或EntryList中挑选一个线程唤醒:
- 如果EntryList的首元素非空,就取出来调用ExitEpilog方法,该方法会唤醒ObjectWaiter对象的线程,然后立即返回
- 如果EntryList的首元素为空,就将cxq的所有元素放入到EntryList中,然后再从EntryList中取出来队首元素执行ExitEpilog方法,然后立即返回
被选中唤醒的线程叫做Heir presumptive
即假定继承人(应该是这样翻译),就是图中的Ready Thread
,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平的,所以假定继承人不一定能获得锁(这也是它叫”假定”继承人的原因)。
如果线程获得锁后调用Object#wait
方法,则会将线程对应的ObjectWaiter加入到WaitSet中,然后释放锁。当被Object#notify
唤醒后,会将线程从WaitSet移动到cxq或EntryList中去。
需要注意的是,当调用一个锁对象的wait
或notify
方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
以上只是对重量级锁流程的一个简述,其中涉及到的很多细节,比如ObjectMonitor对象从哪来?释放锁时是将cxq中的元素移动到EntryList的尾部还是头部?notfiy时,是将ObjectWaiter移动到EntryList的尾部还是头部?
关于具体的细节,会在重量级锁的文章中分析。
轻量级锁
JVM的开发者发现现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record
,其包括一个用于存储对象头中的 mark word
(官方称之为Displaced Mark Word
)以及一个指向对象的指针。下图右边的部分就是一个Lock Record
。
加锁过程
1.在线程栈中创建一个Lock Record
,将其obj
(即上图的Object reference)字段指向锁对象。
2.直接通过CAS指令将Lock Record
的地址存储在对象头的mark word
中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。
3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record
第一部分(Displaced Mark Word
)为null,起到了一个重入计数器的作用。然后结束。
4.走到这一步说明发生了竞争,需要膨胀为重量级锁。
解锁过程
1.遍历线程栈,找到所有obj
字段等于当前锁对象的Lock Record
。
2.如果Lock Record
的Displaced Mark Word
为null,代表这是一次重入,将obj
设置为null后continue。
3.如果Lock Record
的Displaced Mark Word
不为null,则利用CAS指令将对象头的mark word
恢复成为Displaced Mark Word
。如果成功,则continue,否则膨胀为重量级锁。
偏向锁
Java是支持多线程的语言,因此在很多二方包、基础库中为了保证代码在多线程的情况下也能正常运行,也就是我们常说的线程安全,都会加入如synchronized
这样的同步语义。但是在应用在实际运行时,很可能只有一个线程会调用相关同步方法。比如下面这个demo:
1 | import java.util.ArrayList; |
在这个demo中为了保证对list操纵时线程安全,对addString方法加了synchronized
的修饰,但实际使用时却只有一个线程调用到该方法,对于轻量级锁而言,每次调用addString时,加锁解锁都有一个CAS操作;对于重量级锁而言,加锁也会有一个或多个CAS操作(这里的’一个‘、’多个‘数量词只是针对该demo,并不适用于所有场景)。
在JDK1.6中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。我们来看看偏向锁是如何做的。
对象创建
当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(什么时候会关闭一个class的偏向模式下文会说,默认所有class的偏向模式都是是开启的),那新创建对象的mark word
将是可偏向状态,此时mark word中
的thread id(参见上文偏向状态下的mark word
格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
加锁过程
case 1:当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word
中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。
case 2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后(细节见后面的文章),会往当前线程的栈中添加一条Displaced Mark Word
为空的Lock Record
中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized
关键字带来的性能开销基本可以忽略。
case 3.当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint
中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word
改为无锁状态(unlocked),之后再升级为轻量级锁。
由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。
解锁过程
偏向锁的解锁很简单,仅仅将栈中的最近一条lock record
的obj
字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。
下图展示了锁状态的转换流程:
另外,偏向锁默认不是立即就启动的,在程序启动后,通常有几秒的延迟,可以通过命令 -XX:BiasedLockingStartupDelay=0
来关闭延迟。
批量重偏向与撤销
从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point
时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。
safe point
这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的(大概这么个意思),详细可以看这篇文章。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。
存在如下两种情况:(见官方论文第4小节):
1.一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。
2.存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。
批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。
其做法是:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch
字段,每个处于偏向锁状态对象的mark word中
也有该字段,其初始值为创建该对象时,class中的epoch
的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch
字段改为新值。下次获得锁时,发现当前对象的epoch
值和class的epoch
不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word
的Thread Id 改成当前线程Id。
当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
End
Java中的synchronized
有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。当条件不满足时,锁会按偏向锁->轻量级锁->重量级锁 的顺序升级。JVM种的锁也是能降级的,只不过条件很苛刻,不在我们讨论范围之内。该篇文章主要是对Java的synchronized
做个基本介绍,后文会有更详细的分析。
评论区
josek43326 commented on Feb 25, 2019
关于 锁 能不能降级的问题;我在《java并发编程的艺术》这本书中2.2.2节的第四行中看到,
“锁可以升级,但是不能降级”,与这篇文章的End里面的描述有点矛盾,想再次请教一下,谢谢啦
farmerjohngit commented on Feb 27, 2019
@JoseK-43326 这是个好问题,先说结论,在openjdk的hotsopt jdk8u里是有锁降级的机制的,锁降级是什么时候加入到hotspot的这个我没去关注,所以我只说看过代码的jdk8u版本,另外根据R大的这个回答,我相信sunj dk也一样。
然后再详细说:
锁降级的代码在deflate_idle_monitors
方法中,其调用点在进入SafePoint的方法SafepointSynchronize::begin()
中。
在deflate_idle_monitors
中会找到已经idle
的monitor(也就是重量级锁的对象),然后调用deflate_monitor
方法将其降级。
因为锁降级是发生在safepoint的,所以如果降级时间过长会导致程序一直处于STW的阶段。在这里有篇文章讨论了优化机制。jdk8中本身也有个MonitorInUseLists
的开关,其影响了寻找idle monitor
的方式,对该开关的一些讨论看这里。
至于为什么《java并发编程的艺术》中说锁不能降级,我猜测可能该书作者看的jdk版本还没有引入降级机制。