彻底理解Synchronized
synchronized
是依赖于对象实现的锁功能(对象头以及Monitor
),而从官方的虚拟机规范文档上,能看到关于同步的描述是这样的:
Java
虚拟机中的同步(Synchronization
)基于进入和退出管程(Monitor
)对象实现。
可以看到Java
中的synchronized
同步,的确是基于Monitor
(管程)对象来实现的。
- 获取锁:进入管程对象(显式型:
monitorenter
指令) - 释放锁:退出管程对象(显式型:
monitorexit
指令)
不过要明白一点,当我们使用synchronized
修饰方法时,无法通过javap
看到进入/退出管程对象的指令。因为当synchronized
修饰方法时,是通过调用指令,读取运行时常量池中方法的ACC_SYNCHRONIZED
标志来实现的,synchronized
修饰方法时使用的隐式同步。
不过无论是显式同步,还是隐式同步,都是依靠进入/退出管程对象来实现的同步(关于显式和隐式稍后会分析),不过值得一提的是:在Java
中关于同步的概念,并不仅仅在synchronized
中体现,synchronized
只是同步的一种实现,它并不能完全代表Java
的同步机制。
关于Java
对象头则是synchronized
底层实现的关键要素
从上图中可以看到,
- 当状态为偏向锁时,
MarkWord
存储的是偏向的线程ID
。 - 当状态为轻量级锁时,
MarkWord
存储的是指向线程栈中LockRecord
的指针, - 当状态为重量级锁时,则存放指向
monitor
对象的指针
LockRecord
是什么呢?由于MarkWord
的空间有限,随着对象状态的改变,原本存储在对象头里的一些信息,如HashCode
、对象年龄等,就没有足够的空间存储。这时为了保证这些数据不丢失,就会拷贝一份原本的MarkWord
放到线程栈中,这个拷贝过去的MarkWord
叫作Displaced Mark Word
,同时会配合一根指向对象的指针,形成LockRecord
(锁记录),而原本对象头中的MarkWord
,就只会存储一根指向LockRecord
的指针
1、重量级锁
这里我们主要先分析一下重量级锁,也就是通常说的synchronized
对象锁,锁标识位为10
,其中指针指向的是monitor
对象(也称为管程或监视器锁)的起始地址。每个Java
对象都存在着一个monitor
对象与之关联。对象与其monitor
之间的关系,有存在多种实现方式,如monitor
可以与对象一起创建销毁,或当线程试图获取对象锁时自动生成,但当一个monitor
被某个线程持有后,它便处于锁定状态。
在HotSpot
虚拟机中,monitor
是由ObjectMonitor
实现的。Monitor
存在于堆中,什么是Monitor
?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,但是它的本质就是一个特殊的对象。
1 | 位置:openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp |
万物皆对象,而Java
的所有对象都是天生的Monitor
,每一个Java
对象都有成为Monitor
的潜质。因为在Java
的设计中,每一个Java
对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor
锁。
Monitor
是线程私有的数据结构,每一个线程都有一个可用monitor record
列表,同时还有一个全局的可用列表。每一个被锁住的对象,都会和一个monitor
关联(对象头的MarkWord
中的LockWord
,指向monitor
的起始地址),同时monitor
中有一个Owner
字段,存放拥有该锁的线程唯一标识,表示该锁被这个线程占用,Monitor
内部结构如下:
Contention List(_cxq)
:竞争队列,所有请求锁的线程,首先被放在这个竞争队列中,后续1.8
版本中的_cxq
Entry List
:Contention List
中那些有资格成为候选资源的线程被移动到Entry List
中。Wait Set
:调用Object.wait()
方法后,被阻塞的线程被放置在这里。OnDeck
:任意时刻,最多只有一个线程正在竞争锁资源,该线程被称为OnDeck
。Owner
:初始时为NULL
,表示当前没有任何线程拥有该monitor record
,当线程成功拥有该锁后,保存线程唯一标识,当锁被释放时,又设置为NULL
,当前已经获取到所资源的线程被称为Owner
。
ObjectMonitor
中有两个队列,_WaitSet
和_EntryList
,用来保存ObjectWaiter
对象列表( 每个等待锁的线程都会被封装成ObjectWaiter
对象),_owner
指向持有ObjectMonitor
对象的线程,当多个线程同时访问一段同步代码时,首先会加入_EntryList
集合,当线程获取到对象的monitor
后进入_Owner
区域,并把monitor
中的owner
变量设置为当前线程,同时monitor
中的计数器count+1
。
cxq
和_EntryList
队列的区别
cxq用来存放所有尝试竞争锁的线程,如果目前锁未被持有,新线程会尝试获取一次锁,失败会放入该队列;如果一条线程到来时,锁处于持有状态,会直接进入该队列。
cxq队列主要用于存储竞争锁的线程,而EntryList则是用来存放“预候选者”线程,比如目前持有锁的线程释放了锁,那么cxq队列中所有尝试竞争锁的线程,会被移动到EntryList队列,最后接受唤醒,从中选出一个假定继承人。
你可以这样理解,锁资源是一个售票窗口,cxq是一个等待区,而EntryList是一个排队区,当一个人去买票时,如果窗口有人,则会先进入等候区等待,当该窗口的人买票完成后,所有等候区的人,会被转移到排队区进行排队购票
若线程调用Object.wait()
方法,将释放当前持有的monitor
,owner
变量恢复为null
,count
自减1
,同时该线程进入WaitSet
集合中等待被唤醒。若当前线程执行完毕,也将释放monitor
(锁)并复位变量的值,以便其他线程进入获取monitor
。如下图所示:
由此看来,monitor
对象存在于堆空间内,每个Java
对象的对象头,其中markword
存放指向Monitor
对象的指针,synchronized
关键字便是通过这种方式获取锁的,这也是为什么Java
中任意对象可以作为锁的原因。
同时也是notify/notifyAll/wait
等方法,存在于顶级对象Object
中的原因(这点稍后会进一步分析),有了上述知识基础后,下面我们将进一步分析synchronized
在字节码层面的具体语义实现。
从字节码理解synchronized修饰代码块的原理
先来看看编译前的Java
源文件:
1 | public class SyncDemo{ |
跟synchronized
有关的指令,只需关注如下字节码:
1 | 3: monitorenter // monitorenter进入同步 |
从字节码中可知,synchronized
修饰代码块,是基于进入管程monitorenter
和退出管程monitorexit
指令实现的,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
当执行monitorenter
指令时,当前线程将试图获取objectref
(即对象锁)所对应的monitor
的持有权,当objectref
的monitor
计数器为0
,那线程可以尝试占有monitor
,如果将计数器值成功设置为1
,表示获取锁成功
但值得注意的是:如果当前线程已经拥有objectref
的monitor
的持有权,那它可以重入这个 monitor
(关于重入性稍后会分析),重入时计数器的值也会+1
。
倘若其他线程已经拥有objectref
的monitor
的所有权,那当前线程将被阻塞,直到持有的线程执行完毕,即monitorexit
指令被执行,前一个线程将释放monitor
(锁),并设置计数器值为0
,其他线程将有机会持有monitor
。
同时,JVM
将会确保无论方法通过何种方式结束,方法中调用过的每条monitorenter
指令获取锁,都有执行其对应monitorexit
指令释放锁。说人话就是:无论这个方法是正常结束,还是异常结束,都会保证线程释放锁。这也是为什么大家在上述字节码文件中,能看到两个monitorexit
指令的原因。
为了保证在方法异常结束时,monitorenter
和monitorexit
指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器可处理所有的异常,它的目的就是用来执行monitorexit
指令释放锁。
从字节码中看到的第二个monitorexit
指令,它就是异常结束时,会被执行的释放monitor
指令,确保在方法执行过程中,由于异常导致的方法意外结束时,不出现死锁现象。
从字节码理解synchronized修饰方法的原理
方法级的同步是隐式锁,即无需通过字节码指令来控制的,获取锁、释放锁的实现位置,分别位于方法调用和返回操作时。JVM
可以从方法常量池中的method_info Structure
方法表结构中,靠ACC_SYNCHRONIZED
访问标志来区分一个方法是否为同步方法。
当方法调用时,调用指令时将会检查方法的ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,线程执行前,将需要先持有monitor
(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法结束时释放monitor
。
在方法执行期间,执行线程持有了monitor
,其他任何线程都无法再获得同一个monitor
。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor
,将在异常抛到同步方法之外时自动释放。
下面我们看看字节码层面如何实现,编译前Java
源文件:
1 | public class SyncDemo{ |
从字节码中可以看出,synchronized
修饰的方法,并没有出现monitorenter
指令和monitorexit
指令,取得代之的是:在flags: ACC_PUBLIC
之后增加了一个ACC_SYNCHRONIZED
标识。这个标识指明了当前方法是一个同步方法,JVM
通过这个ACC_SYNCHRONIZED
访问标志,来辨别一个方法是否为同步方法,从而执行相应的同步调用。这便是synchronized
修饰在方法上的实现原理。
同时,大家还得明白,在Java
早期版本中,synchronized
属于重量级锁,效率低下,因为monitor
监视器锁,依赖于底层操作系统的Mutex Lock
来实现,而操作系统实现线程之间的切换时,需要从用户态转换到内核态,这个切态过程需要较长的时间,并且更方面成本较高,这也是早期的synchronized
性能效率低的原因。
不过值得庆幸的是在Java6
之后,Java
官方从JVM
层面对synchronized
进行了优化,所以现在的synchronized
锁,效率也十分不错了。Java6
之后,为了减少获得锁、释放锁带来的性能消耗,引入了轻量级锁和偏向锁,下面简单了解一下官方对synchronized
锁的优化。
Java6对于synchronized的优化:锁膨胀
1、无锁态
当我们在Java程序中new
一个对象时,会默认启动匿名偏向锁,但是值得注意的是有个小细节,偏向锁的启动有个延时,默认是4
秒,也就是:JVM
启动四秒之后才会开启匿名偏向锁,在JVM
启动的前四秒内,new
的对象不会启动匿名偏向锁,why?
因为JVM
虚拟机自己有一些默认启动的线程,里面有好多sync
代码,这些sync
代码启动时,就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。
还有一点值得注意,对于一个对象而言,就算启动了匿名偏向锁,这个对象的脑袋里,也没有任何的线程ID
。因为是新创建的对象,所以对于一个新new
对象而言,不管有没有启动匿名偏向锁,都被称为概念上的无锁态对象。
毕竟就算启动了匿名偏向锁,但是在没有成为真正的偏向锁之前,markword
信息中的threadID
是空的,因为此时没有线程获取该锁(但是当对象成为匿名偏向锁时,mrakword
中的锁标志位仍然会改为101
,偏向锁的标志)。
JVM的匿名偏向锁,是延迟启动的,需要等待JVM启动4秒后,才会开启偏向锁的支持,如果JVM刚启动,就直接获取锁了,此时锁状态会直接变为轻量级锁。也就是说在JVM启动4秒前,匿名偏向锁还未启动时,如果此时就算只有一个线程来获取锁,也会直接膨胀为轻量级锁
2、偏向锁
偏向锁是Java6
之后加入的新锁,它是一种针对加锁操作的优化手段。当状态为偏向锁时,MarkWord
存储的是偏向的线程ID
。
经过官方研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了减少同一线程获取锁的代价,如CAS
操作带来的耗时等,从而引入了偏向锁。
偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word
的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即不需要再走获取锁的流程,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。
换句通俗易懂的话说:偏向锁其中的“偏”是偏心的偏,就是这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所持有,也没有其他线程来竞争该锁,那么持有偏向锁的线程,将永远不需要进行获取锁操作。在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不需要再做加锁或者解锁动作,而是会做以下操作:
Load-and-test
,就是简单判断一下当前线程id
是否与Markword
中的线程id
是否一致;- 如果一致,则说明此线程持有的偏向锁,没有被其他线程覆盖,直接执行下面的代码;
- 如果不一致,则要检查一下对象是否还属于可偏向状态,即检查“是否偏向锁”标志位;
- 如果还未偏向,则利用
CAS
操作来竞争锁,再次将ID
放进去,即重复第一次获取锁的动作。
但是当第二个线程来尝试获取锁时,如果此对象已经偏向了,并且不是偏向自己,则说明出现了竞争。此时会根据该锁的竞争情况,可能会产生偏向撤销,重新偏向的现象。
但大部分情况下,就是直接膨胀成轻量级锁了。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,毕竟这样场合,极有可能每次申请锁的线程都不相同,为此这种场合下,可以通过JVM
参数关闭偏向锁,否则会得不偿失。
3、轻量级锁
倘若偏向锁失败,Synchronized
并不会立即升级为重量级锁,它会先进入轻量级锁状态,此时MarkWord
的结构也变为轻量级锁的结构。轻量级锁能提升程序性能的依据是:“对于绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。
轻量级锁膨胀过程
当膨胀为轻量级锁时,首先根据markwork
判断是否有线程持有锁,如果有,则在当前线程栈中创建一个lock record
复制mark word
,并且通过cas
机制,把当前线程栈的lock record
地址,放到对象头中。
第一次拿到偏向锁后,因为对象头的空间有限,对象变成偏向锁后,mrakword里面就需要保存线程ID,而存了线程ID后,其他数据就存不下了,所以会把原本mrakword里面的数据,拷贝到自己的线程栈中,形成一个lockRecord结构。
第二次进入偏向锁,需要加一个空的MarkWord,就类似于锁重入的效果,记录一下重入了多少次细节:之前持有偏向锁的线程,会优先进行
cas
,尝试设置mrakword
中的锁信息指针。
如果成功,则说明获取到轻量级锁;如果失败,则说明锁已经被其他持有了,此时记录线程的重入次数(把lock record
的markword
设置为null
),此时线程会自旋(自适应自旋),确保在竞争不激烈的情况下,仍然可以不膨胀为真正意义上的“内核态重量级锁”,从而减少消耗。
如果自旋后还未等到锁,则说明目前竞争较重,需要膨胀为重量级的锁
不过需要了解的是,轻量级锁适用的场景是:线程交替执行同步块的场合,如果同一时间存在多个线程访问同一把锁,就会导致轻量级锁膨胀为重量级锁。但在JDK1.4
之后,膨胀到重量级锁阶段后,最开始的重量级锁不会直接进入内核态级别的重量锁,而是会进入一个“自旋锁”阶段,后续被优化成了自适应自旋。
轻量级锁小细节
轻量级锁主要有自旋、自适应自旋两种类型。
①自旋锁:所谓自旋,是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程挂起阻塞,直到那个持有锁的线程释放锁之后,这个线程就可以马上尝试获取锁。
注意,线程在原地自旋时,会消耗
cpu
,就相当于在执行一个啥也没有的for
循环。
所以,轻量级锁适用于那些同步代码块执行时长很短的场景,这样,线程原地等待很短的时间,就能够获得锁了。经验表明,大部分同步代码块执行的时间都特别短,也正是基于这个原因,才有了轻量级锁这么个东西。
不过自旋锁会存在一些问题,如下:
- 如果同步代码块执行的很慢,需要等待很长时间,这时其他线程自旋会消耗大量
CPU
; - 本来前一个线程释放锁后,当前线程是能够拿到锁的,但假如这时有好几个线程都在自旋等待这把锁,那就有可能造成当前线程拿不到锁,还得继续原地空循环消耗
CPU
,甚至有可能一直获取不到锁;
基于这些问题,我们必须通过-XX:PreBlockSpin
给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。默认情况下,自旋的次数为10
次,或者自旋线程超过CPU
核数一半时,会发生锁膨胀(自旋锁是在JDK1.4.2
时引入的)。
②自适应自旋锁:所谓自适应自旋锁,就是线程空循环的次数并非固定的,而是会动态根据实际情况来改变自旋等待的次数,其大概原理是这样的(在重量级锁阶段自旋):
假如
T1
线程刚刚成功拿到锁,当它把锁释放后,T2
线程获得该锁,并且T2
在运行的过程中,此时T1
又过来拿锁了,但T2
还没有释放该锁,所以T1
只能阻塞等待,但是虚拟机认为:由于T1
刚刚获得过该锁,那么虚拟机会觉得T1
这次自旋,也很有可能再次成功拿到该锁,所以会延长T1
自旋的次数。
另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接跳过自旋过程,直接走重量级锁的逻辑,以免空循环等待浪费资源。
同时,当锁资源的竞争已经非常激烈后,自适应自旋存在的意义已经没有必要了,因为存在大量线程竞争同一把锁,就算自旋一段时间,其他线程还需要继续自旋等待,此时自旋带来的开销,已经大于在内核态挂起线程的开销了。所以,在竞争很激烈的情况下,自适应自旋的次数可能会为0
,也就是不再尝试自旋,而是直接膨胀为真正意义上的“内核态重量级锁”。
4、重量级锁
关于重量级锁,在前面已经详细分析过了,重量级锁就是传统意义的互斥锁了,当出现较大竞争、锁膨胀为重量级锁时,对象头的markword
指向堆中的monitor
,此时会将线程封装为一个ObjectWaiter
对象,并插入到monitor
的_cxq
队列中,然后挂起当前线程。
当持有锁的线程释放后,会把_cxq
里面的所有线程(ObjectWaiter
对象),转移到EntryList
中去,并且会从EntryList
中挑选一个线程唤醒,被选中的线程叫做Heir Presumptive
假定继承人,就是图中的Ready Thread
,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平锁,所以假定继承人不一定能获得锁(这也是它叫”假定”继承人的原因):
如果线程获得锁后,调用Object.wait()
方法,则会将线程加入到WaitSet
中,当被Object.notify()
唤醒后,会将线程从WaitSet
移动到_cxq
或EntryList
中去。
需要注意:当调用一个锁对象的
wait、notify
方法时,如当前锁的状态是偏向锁或轻量级锁,则会先膨胀成重量级锁,因为wait、notify
方法要依赖于Monitor
对象实现。
总结
- 无锁态:
JVM
启动后四秒内的普通对象,和四秒后的匿名偏向锁对象 - 偏向锁状态:只有一个线程进入临界区
- 轻量级锁状态:多个线程交替进入临界区
- 重量级锁:多个线程同时进入临界区
下面来张图,总结一下锁膨胀/升级的过程: