synchronized依赖于对象实现的锁功能(对象头以及Monitor),而从官方的虚拟机规范文档上,能看到关于同步的描述是这样的:

Java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。

可以看到Java中的synchronized同步,的确是基于Monitor(管程)对象来实现的。

  • 获取锁:进入管程对象(显式型:monitorenter指令)
  • 释放锁:退出管程对象(显式型:monitorexit指令)

不过要明白一点,当我们使用synchronized修饰方法时,无法通过javap看到进入/退出管程对象的指令。因为当synchronized修饰方法时,是通过调用指令,读取运行时常量池中方法的ACC_SYNCHRONIZED标志来实现的synchronized修饰方法时使用的隐式同步

不过无论是显式同步,还是隐式同步,都是依靠进入/退出管程对象来实现的同步(关于显式和隐式稍后会分析),不过值得一提的是:在Java中关于同步的概念,并不仅仅在synchronized中体现,synchronized只是同步的一种实现,它并不能完全代表Java的同步机制。

关于Java对象头则是synchronized底层实现的关键要素

image.png

从上图中可以看到,

  • 当状态为偏向锁时,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
位置:openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp
实现:C/C++
代码:

ObjectMonitor() {
_header = NULL; //markOop对象头
_count = 0; //记录个数
_waiters = 0, //等待线程数
_recursions = 0; //重入次数
_object = NULL; //监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
_owner = NULL; //指向获得ObjectMonitor对象的线程或基础锁
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL;
FreeNext = NULL;
_EntryList = NULL; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ; // _owner is (Thread *) vs SP/BasicLock
_previous_owner_tid = 0; // 监视器前一个拥有者线程的ID
}

万物皆对象,而Java的所有对象都是天生的Monitor,每一个Java对象都有成为Monitor的潜质。因为在Java的设计中,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象,都会和一个monitor关联(对象头的MarkWord中的LockWord,指向monitor的起始地址),同时monitor中有一个Owner字段,存放拥有该锁的线程唯一标识,表示该锁被这个线程占用,Monitor内部结构如下:

image.png

  • Contention List(_cxq):竞争队列,所有请求锁的线程,首先被放在这个竞争队列中,后续1.8版本中的_cxq
  • Entry ListContention 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()方法,将释放当前持有的monitorowner变量恢复为nullcount自减1,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕,也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor。如下图所示:

image.png

由此看来,monitor对象存在于堆空间内,每个Java对象的对象头,其中markword存放指向Monitor对象的指针,synchronized关键字便是通过这种方式获取锁的,这也是为什么Java中任意对象可以作为锁的原因。

同时也是notify/notifyAll/wait等方法,存在于顶级对象Object中的原因(这点稍后会进一步分析),有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。

从字节码理解synchronized修饰代码块的原理

先来看看编译前的Java源文件:

1
2
3
4
5
6
7
8
public class SyncDemo{
int i;
public void incr(){
synchronized(this){
i++;
}
}
}

synchronized有关的指令,只需关注如下字节码:

1
2
3
3: monitorenter        // monitorenter进入同步
15: monitorexit // monitorexit退出同步
21: monitorexit // 第二次出现monitorexit退出同步

从字节码中可知,synchronized修饰代码块,是基于进入管程monitorenter和退出管程monitorexit指令实现的,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。

当执行monitorenter指令时,当前线程将试图获取objectref(即对象锁)所对应的monitor的持有权,当objectrefmonitor计数器为0,那线程可以尝试占有monitor,如果将计数器值成功设置为1,表示获取锁成功

但值得注意的是:如果当前线程已经拥有objectrefmonitor的持有权,那它可以重入这个 monitor(关于重入性稍后会分析),重入时计数器的值也会+1

倘若其他线程已经拥有objectrefmonitor的所有权,那当前线程将被阻塞,直到持有的线程执行完毕,即monitorexit指令被执行,前一个线程将释放monitor(锁),并设置计数器值为0,其他线程将有机会持有monitor

同时,JVM将会确保无论方法通过何种方式结束,方法中调用过的每条monitorenter指令获取锁,都有执行其对应monitorexit指令释放锁。说人话就是:无论这个方法是正常结束,还是异常结束,都会保证线程释放锁。这也是为什么大家在上述字节码文件中,能看到两个monitorexit指令的原因。

为了保证在方法异常结束时,monitorentermonitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器可处理所有的异常,它的目的就是用来执行monitorexit指令释放锁。

从字节码中看到的第二个monitorexit指令,它就是异常结束时,会被执行的释放monitor指令,确保在方法执行过程中,由于异常导致的方法意外结束时,不出现死锁现象。

从字节码理解synchronized修饰方法的原理

方法级的同步是隐式锁,即无需通过字节码指令来控制的,获取锁、释放锁的实现位置,分别位于方法调用和返回操作时。JVM可以从方法常量池中的method_info Structure方法表结构中,靠ACC_SYNCHRONIZED访问标志来区分一个方法是否为同步方法。

当方法调用时,调用指令时将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,线程执行前,将需要先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法结束时释放monitor

在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor,将在异常抛到同步方法之外时自动释放。

下面我们看看字节码层面如何实现,编译前Java源文件:

1
2
3
4
5
6
public class SyncDemo{
int i;
public synchronized void reduce(){
i++;
}
}

从字节码中可以看出,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 recordmarkword设置为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是非公平锁,所以假定继承人不一定能获得锁(这也是它叫”假定”继承人的原因):

image.png
如果线程获得锁后,调用Object.wait()方法,则会将线程加入到WaitSet中,当被Object.notify()唤醒后,会将线程从WaitSet移动到_cxqEntryList中去。

需要注意:当调用一个锁对象的wait、notify方法时,如当前锁的状态是偏向锁或轻量级锁,则会先膨胀成重量级锁,因为wait、notify方法要依赖于Monitor对象实现。

总结

  • 无锁态JVM启动后四秒内的普通对象,和四秒后的匿名偏向锁对象
  • 偏向锁状态:只有一个线程进入临界区
  • 轻量级锁状态:多个线程交替进入临界区
  • 重量级锁:多个线程同时进入临界区

下面来张图,总结一下锁膨胀/升级的过程:
image.png