【死磕Java并发】-----深入分析synchronized的实现原理

时间:2021-09-11 00:44:07

记得刚刚開始学习Java的时候。一遇到多线程情况就是synchronized。相对于当时的我们来说synchronized是这么的奇妙而又强大,那个时候我们赋予它一个名字“同步”。也成为了我们解决多线程情况的百试不爽的良药。可是,随着我们学习的进行我们知道synchronized是一个重量级锁,相对于Lock。它会显得那么笨重,以至于我们觉得它不是那么的高效而慢慢摒弃它。

诚然,随着Javs SE 1.6对synchronized进行的各种优化后,synchronized并不会显得那么重了。以下尾随LZ一起来探索synchronized的实现机制、Java是怎样对它进行了优化、锁优化机制、锁的存储结构和升级过程;

实现原理

synchronized能够保证方法或者代码块在运行时,同一时刻仅仅有一个方法能够进入到临界区,同一时候它还能够保证共享变量的内存可见性

Java中每个对象都能够作为锁,这是synchronized实现同步的基础:

1. 普通同步方法。锁是当前实例对象

2. 静态同步方法,锁是当前类的class对象

3. 同步方法块,锁是括号中面的对象

当一个线程訪问同步代码块时。它首先是须要得到锁才干运行同步代码,当退出或者抛出异常时必须要释放锁,那么它是怎样来实现这个机制的呢?我们先看一段简单的代码:

public class SynchronizedTest {
public synchronized void test1(){ } public void test2(){
synchronized (this){ }
}
}

利用javap工具查看生成的class文件信息来分析Synchronize的实现

【死磕Java并发】-----深入分析synchronized的实现原理

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描写叙述" title="">

从上面能够看出,同步代码块是使用monitorenter和monitorexit指令实现的,同步方法(在这看不出来须要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。

同步代码块:monitorenter指令插入到同步代码块的開始位置,monitorexit指令插入到同步代码块的结束位置,JVM须要保证每个monitorenter都有一个monitorexit与之相相应。不论什么对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程运行到monitorenter指令时。将会尝试获取对象所相应的monitor全部权。即尝试获取对象的锁;

同步方法:synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令。在VM字节码层面并没有不论什么特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1。表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)

以下我们来继续分析。可是在深入之前我们须要了解两个重要的概念:Java对象头。Monitor。

Java对象头、monitor

Java对象头和monitor是实现synchronized的基础。以下就这两个概念来做具体介绍。

Java对象头

synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。当中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以以下将重点阐述

Mark Word。

Mark Word用于存储对象自身的运行时数据。如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节。也就是32bit),可是假设对象是数组类型,则须要三个机器码,由于JVM虚拟机能够通过Java对象的元数据信息确定Java对象的大小,可是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

【死磕Java并发】-----深入分析synchronized的实现原理

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描写叙述" title="">

对象头信息是与对象自身定义的数据无关的额外存储成本,可是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。变化状态例如以下(32位虚拟机):

【死磕Java并发】-----深入分析synchronized的实现原理

简介了Java对象头,我们以下再看Monitor。

Monitor

什么是Monitor?我们能够把它理解为一个同步工具,也能够描写叙述为一种同步机制,它通常被描写叙述为一个对象。

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

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

其结构例如以下:

【死磕Java并发】-----深入分析synchronized的实现原理

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描写叙述" title="">

Owner:初始时为NULL表示当前没有不论什么线程拥有该monitor record。当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL。

EntryQ:关联一个系统相互排斥锁(semaphore),堵塞全部试图锁住monitor record失败的线程。

RcThis:表示blocked或waiting在该monitor record上的全部线程的个数。

Nest:用来实现重入锁的计数。

HashCode:保存从对象头拷贝过来的HashCode值(可能还包括GC age)。

Candidate:用来避免不必要的堵塞或等待线程唤醒。由于每一次仅仅有一个线程能够成功拥有锁,假设每次前一个释放锁的线程唤醒全部正在堵塞或等待的线程,会引起不必要的上下文切换(从堵塞到就绪然后由于竞争锁失败又被堵塞)从而导致性能严重下降。

Candidate仅仅有两种可能的值0表示没有须要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

摘自:Java中synchronized的实现原理与应用)

我们知道synchronized是重量级锁,效率不怎么滴,同一时候这个观念也一直存在我们脑海里,只是在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了。那么JVM採用了那些优化手段呢?

锁优化

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来降低锁操作的开销。

锁主要存在四中状态。依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁能够升级不可降级,这样的策略是为了提高获得锁和释放锁的效率。

自旋锁

线程的堵塞和唤醒须要CPU从用户态转为核心态,频繁的堵塞和唤醒对CPU来说是一件负担非常重的工作,势必会给系统的并发性能带来非常大的压力。同一时候我们发如今很多应用上面,对象锁的锁状态仅仅会持续非常短一段时间,为了这一段非常短的时间频繁地堵塞和唤醒线程是非常不值得的。所以引入自旋锁。

何谓自旋锁?

所谓自旋锁。就是让该线程等待一段时间,不会被马上挂起,看持有锁的线程是否会非常快释放锁。怎么等待呢?运行一段无意义的循环就可以(自旋)。

自旋等待不能替代堵塞。先不说对处理器数量的要求(多核,貌似如今没有单核的处理器了),尽管它能够避免线程切换带来的开销,可是它占用了处理器的时间。假设持有锁的线程非常快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做不论什么有意义的工作。典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。

所以说。自旋等待的时间(自旋的次数)必须要有一个限度,假设自旋超过了定义的时间仍然没有获取到锁。则应该被挂起。

自旋锁在JDK 1.4.2中引入。默认关闭,可是能够使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同一时候自旋的默认次数为10次,能够通过參数-XX:PreBlockSpin来调整。

假设通过參数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。

假如我将參数调整为10,可是系统非常多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就能够获取锁),你是不是非常尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

适应自旋锁

JDK 1.6引入了更加聪明的自旋锁。即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

它怎么做呢?线程假设自旋成功了,那么下次自旋的次数会更加多,由于虚拟机觉得既然上次成功了,那么此次自旋也非常有可能会再次成功。那么它就会同意自旋等待持续的次数很多其它。反之,假设对于某个锁,非常少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会降低甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁。随着程序运行和性能监控信息的不断完好,虚拟机对程序锁的状况预測会越来越准确,虚拟机会变得越来越聪明。

锁消除

为了保证数据的完整性,我们在进行操作时须要对这部分操作进行同步控制。可是在有些情况下,JVM检測到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的根据是逃逸分析的数据支持。

假设不存在竞争。为什么还须要加锁呢?所以锁消除能够节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说须要使用数据流分析来确定,可是对于我们程序猿来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?可是有时候程序并非我们所想的那样?我们尽管没有显示使用锁,可是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等。这个时候会存在隐形的加锁操作。比方StringBuffer的append()方法。Vector的add()方法:

    public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
} System.out.println(vector);
}

在运行这段代码时。JVM能够明显检測到变量vector没有逃逸出方法vectorTest()之外,所以JVM能够大胆地将vector内部的加锁操作消除。

锁粗化

我们知道在使用同步锁的时候。须要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使须要同步的操作数量尽可能缩小。假设存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的,LZ也一直坚持着这个观点。可是假设一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。

锁粗话概念比較好理解,就是将多个连续的加锁、解锁操作连接在一起。扩展成一个范围更大的锁。如上面实例:vector每次add的时候都须要加锁操作,JVM检測到对同一个对象(vector)连续加锁、解锁操作。会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,降低传统的重量级锁使用操作系统相互排斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其过程例如以下:

获取锁

1. 推断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象眼下的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则运行步骤(3);

2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,假设成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),运行同步操作;假设失败则运行步骤(3);

3. 推断当前对象的Mark Word是否指向当前线程的栈帧,假设是则表示当前线程已经持有当前对象的锁,则直接运行同步代码块;否则仅仅能说明该锁对象已经被其它线程抢占了,这时轻量级锁须要膨胀为重量级锁,锁标志位变成10。后面等待的线程将会进入堵塞状态;

释放锁

轻量级锁的释放也是通过CAS操作来进行的,主要过程例如以下:

1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;

2. 用CAS操作将取出的数据替换当前对象的Mark Word中。假设成功,则说明释放锁成功。否则运行(3);

3. 假设CAS操作替换失败,说明有其它线程尝试获取该锁,则须要在释放锁的同一时候须要唤醒被挂起的线程。

对于轻量级锁,其性能提升的根据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”。假设打破这个根据则除了相互排斥的开销外。还有额外的CAS操作。因此在有多线程竞争的情况下。轻量级锁比重量级锁更慢;


下图是轻量级锁的获取和释放过程

【死磕Java并发】-----深入分析synchronized的实现原理

偏向锁

引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量降低不必要的轻量级锁运行路径。上面提到了轻量级锁的加锁解锁操作是须要依赖多次CAS原子指令的。那么偏向锁是怎样来降低不必要的CAS操作呢?我们能够查看Mark work的结构就明确了。仅仅须要检查是否为偏向锁、锁标识为以及ThreadID就可以,处理流程例如以下:

获取锁

1. 检測Mark Word是否为可偏向状态,即是否为偏向锁1。锁标识位为01;

1. 若为可偏向状态。则測试线程ID是否为当前线程ID,假设是,则运行步骤(5)。否则运行步骤(3)。

1. 假设线程ID不为当前线程ID。则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则运行线程(4);

4. 通过CAS竞争锁失败。证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被堵塞在安全点的线程继续往下运行同步代码块;

5. 运行同步代码块

释放锁

偏向锁的释放採用了一种仅仅有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,须要等待其它线程来竞争。偏向锁的撤销须要等待全局安全点(这个时间点是上没有正在运行的代码)。其过程例如以下:

1. 暂停拥有偏向锁的线程,推断锁对象石是否还处于被锁定状态;

2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;


下图是偏向锁的获取和释放流程

【死磕Java并发】-----深入分析synchronized的实现原理

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描写叙述" title="">

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,当中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换须要从用户态到内核态的切换。切换成本非常高。

參考资料

  1. 周志明:《深入理解Java虚拟机》
  2. 方腾飞:《Java并发编程的艺术》
  3. Java中synchronized的实现原理与应用)

欢迎扫一扫我的公众号关注 — 及时得到博客订阅哦!

–— Java成神之路: 488391811(一起走向Java成神) –—

【死磕Java并发】-----深入分析synchronized的实现原理