从底层理解synchronized
上一章了解了synchronized的基本使用方式之后,接下来我们来深入了解了解其底层原理,并且说明对它的优化。
一、synchronized底层实现原理
首先给出一个不是结论的结论,synchronized
的实现基础是:JAVA
对象头和Monitor
,理解了这两者的作用就理解了synchronized
的实现原理。下面进行详细讲解。
⭐然后在正式开始之前,先介绍一下锁的内存语义:
- 当线程释放锁时,JAVA内存模型会把该线程对应额本地内存中的共享变量刷新到主内存中
- 当线程获取锁时,JAVA内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
在JAVA内存模型-线程共享这篇文章中介绍了对象头里面的基本构成。
我们着重看一下对象头,下面两个这里不需要关心。我们可以看到一个关键字:锁状态标志。因此Mark Word
是实现锁的关键了。
我们也知道,Mark Word
是一个可变的结构,可变的部分主要有如下:
其中,偏向所和轻量级锁是JDK1.6之后对synchronized
优化所新加的,后文会探讨对synchronized
的优化。
OK,到这里我们知道了每个对象区域的对象头这一块存储了关于锁的信息,即锁状态。仔细看表格,比如重量级锁,就是我们熟知的synchronized
对象锁,它的说明是:指向重量级锁的指针。那这个锁是什么呢?指向的是什么位置呢?这个就不得不提及第二个关键字啦:Monitor
Monitor
:每个对象打娘胎生下来就自带了一把看不见的锁,成为内部锁或者Monitor
锁,也称为管程或者监视器锁。我们可以理解为一种同步工具,也可以理解为同步对象。
那么回到上面的问题上来,这个指针指向的就是Monitor
对象的起始地址,因此,每个对象都会存在一个Monitor
与之关联,当这个Monitor
被一个线程持有时,它就会处于锁定状态。
在Hotspot
虚拟机中,这个Monitor
是由ObjectMonitor
实现的,位于虚拟机源码中,用C++
实现。我们一起来看看吧!
这个源码地址为:objectMonitor.hpp
我们看到了几个比较重要的关键字,首先,每个等待获取锁的线程都会被封装为ObjectWaiter
对象。_WaitSet
就是之前说的所有wait
状态的线程都会被放在这里等待唤醒再去竞争锁;_EntryList
就是所有等待获取锁的线程对象存放的地方。_owner
指向的是当前获取到锁的线程对象。_count
为计数,这个就跟可重入相关了,线程进来一次就加一次,为0的时候就说明释放锁了,那么此时处于_EntryList
池中的线程都可以去竞争这把锁了。
将上面文字转换为图来理解就是:
以上就是Synchronized
实现锁的原理。
二、synchronized在字节码层面的语义
我们拿下面这段程序作为示例:
我们对这两个方法进行javap
的分析,针对第一个同步代码块:
我们可以看出来,synchronized
同步代码块实现同步的关键指令是monitorenter
和monitorexit
。这恰好与上面说的monitor
锁对应上,即多个线程在_EntryList
中竞争,看谁能拿到monitor
锁的指向全,拿到了就可以进来,拿不到就阻塞在monitorenter
处继续等待。知道这个锁被释放了为止。
那么对于synchronized
修饰的方法呢?
如果是同步方法,在字节码层面的表示是略有不同的。我们注意到,是在某个标识位上给其打上ACC_SYNCHRONIZED
标志,表示这是一个synchronized
修饰的同步方法,那么下面对于锁竞争啥的都与上面一样,所以只是字节码层面的表示不同而已,原理都一样。
三、对synchronized的优化
对于synchronized
的性能,在以前一直是嗤之以鼻的,这种观念从老一代的程序猿们口口相传到如今,可谓是根深蒂固,在以前的版本中,确实是很慢,原因如下:
- 早期版本中,
synchronized
属于重量级锁,依赖于Mutex Lock
实现 - 线程之间的切换需要从用户态转换到核心态,开销较大
jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
3.1 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
何谓自旋锁?
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning
开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin
来调整;
如果通过参数-XX:preBlockSpin
来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
3.2 适应自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
3.3 锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer
、Vector
、HashTable
等,这个时候会存在隐形的加锁操作。比如StringBuffer
的append()
方法,Vector
的add()
方法:
1 | public void vectorTest(){ |
在运行这段代码时,JVM可以明显检测到变量vector
没有逃逸出方法vectorTest()
之外,所以JVM可以大胆地将vector
内部的加锁操作消除。
3.4 锁粗化
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的,LZ也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector
每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector
)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for
循环之外。
3.5 偏向锁
在大多数情况下,锁不存在多线程竞争,总是由同一个线程多次获得。
⭐⭐⭐核心的思想是:如果一个线程获得了锁,那么锁就会进入偏向模式,此时
Mark Word
的结构也变为偏向锁结构,当该结构再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word
的锁标记位位偏向锁以及当前线程ID等于Mark Word
的ThreadId
即可,这样省去了大量有关锁申请的操作。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID
的时候依赖一次CAS
原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS
原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
它的思想可以理解为CAS
,因此这种锁不适合于锁竞争比较激烈的多线程场合。
偏向锁的获取和释放:
- 访问 Mark Word 中偏向锁的标识位是否为1,如果是1,则确定为偏向锁。
- 如果偏向锁的标识位为0,说明此时是处于无锁状态,则当前线程通过CAS操作尝试获取偏向锁,如果获取锁成功,则将Mark Word中的偏向线程ID设置为当前线程ID;并且将偏向标识位设为1。
- 如果偏向锁的标识位不为1,也不为0(此时偏向锁的标识位没有值),说明发生了竞争,偏向锁已经膨胀为轻量级锁,这时使用CAS操作尝试获得锁。
- 如果是偏向锁,则判断 Mark Word 中的偏向线程ID是否指向当前线程,如果偏向线程ID指向当前线程,则表明当前线程已经获取到了锁;
- 如果偏向线程ID并未指向当前线程,则通过CAS操作尝试获取偏向锁,如果获取锁成功,则将 Mark Word 中的偏向线程ID设置为当前线程ID;
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点时(在这个时间点上没有正在执行的字节码),获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
- 偏向锁的释放:
- 当其它的线程尝试获取偏向锁时,持有偏向锁的线程才会释放偏向锁。
- 释放偏向锁需要等待全局安全点(在这个时间点上没有正在执行的字节码)。
- - 首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,
- - 如果线程还活着,说明此时发生了竞争,则偏向锁升级为轻量级锁,然后刚刚被暂停的线程会继续往下执行同步代码。
3.6 轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
⭐轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
轻量级锁的加锁过程:
1.当使用轻量级锁(锁标识位为00)时,线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中(注:锁记录中的标识字段称为Displaced Mark Word)。
2.将对象头中的MarkWord复制到栈桢中的锁记录中之后,虚拟机将尝试使用CAS将对象头中Mark Word替换为指向该线程虚拟机栈中锁记录的指针,此时如果没有线程占有锁或者没有线程竞争锁,则当前线程成功获取到锁,然后执行同步块中的代码。
3.如果在获取到锁的线程执行同步代码的过程中,另一个线程也完成了栈桢中锁记录的创建,并且已经将对象头中的MarkWord复制到了自己的锁记录中,然后尝试使用CAS将对象头中的MarkWord修改为指向自己的锁记录的指针,但是由于之前获取到锁的线程已经将对象头中的MarkWord修改过了(并且现在还在执行同步体中的代码,即仍然持有着锁),所以此时对象头中的MarkWord与当前线程锁记录中MarkWord的值不同,导致CAS操作失败,然后该线程就会不停地循环使用CAS操作试图将对象头中的MarkWord替换为自己锁记录中MarkWord的值,(当循环次数或循环时间达到上限时停止循环)如果在循环结束之前CAS操作成功,那么该线程就可以成功获取到锁,如果循环结束之后依然获取不到锁,则锁获取失败,对象头中的MarkWord会被修改为指向重量级锁的指针,然后这个获取锁失败的线程就会被挂起,阻塞了。
4.当持有锁的那个线程执行完同步体之后,使用CAS操作将对象头中的MarkWord还原为最初的状态时(将对象头中指向锁记录的指针替换为Displaced Mark Word ),发现MarkWord已被修改为指向重量级锁的指针,因此CAS操作失败,该线程会释放锁并唤起阻塞等待的线程,开始新一轮夺锁之争,而此时,轻量级锁已经膨胀为重量级锁,所有竞争失败的线程都会阻塞,而不是自旋。
锁 | 优点 | 缺点 | 试用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |