Java并发之深入分析synchronized的实现原理

本文转载自:http://www.open-open.com/lib/view/open1352431526366.html

                    https://blog.csdn.net/u012465296/article/details/53022317

稍微进行了整合

目前在Java中存在两种锁机制:synchronized和Lock,Lock接口及其实现类是JDK5增加的内容,synchronized是个重量级锁,是解决并发问题的一种最常用的方法,也是最简单的一种方法,Java SE1.6对其进行了优化。

synchronized作用主要有三个:

1)确保线程互斥的访问同步代码

2)保证共享变量的修改能够及时可见

3)有效解决重排序问题

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

1)普通同步方法,锁是当前实例对象

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

3)同步方法块,锁是括号里的对象

当一个线程访问同步代码块时,它需要得到锁才能执行同步代码,退出或抛异常时必须要释放锁,它是如何实现的呢?我们先来看一段简单的代码:

public class SynchronizedTest {
    public synchronized void test1(){
    }
    public void test2(){
        synchronized(this){
    }
    }
}
monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。


synchronized方法会被翻译成普通的方法调用和返回指令,如:invokevirtual和areturn指令,在VM的字节码层面没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags中的synchronized标志位置为1,并表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。

当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:

Contention List 所有请求锁的线程将被首先放置到该竞争队列
Entry List Contention List中那些有资格成为候选人的线程被移到Entry List中
Wait Set 那些调用wait方法被阻塞的线程被放置到Wait Set中
OnDeck 任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner 获得锁的线程成为Owner
!Owner 释放锁的线程

下图反映了状态转换关系


新请求锁的线程将首先被加入到ContentionList中,当某个拥有锁的线程调用unlock之后,如果发现EntryList为空,则从ContentionList中移动线程到EntryList,下面说明ContentionList和EntryList的实现方式:

1)ContentionList虚拟队列

ContentionList是由Node及其next指针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个后进先出(LIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构是个Lock-Free队列。

因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。


2)EntryList

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般是Head)为OnDeck线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权力交给OnDeck,OnDeck线程需要重新竞争锁,这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量。在Hotspot中把OnDeck的选择行为称作“竞争切换”。

OnDeck线程获得锁后变为Owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,如果在某个时刻被notify/notifyAll唤醒,则再次转移到EntryList。

介绍一下CAS(Compare-And-Swap)

是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,实现方式是基于硬件平台的汇编指令实现的,JVM只是封装了汇编调用。

CAS为什么会引起本地延迟?

下图为SMP(对称多处理器)架构


所有的CPU会共享一条系统总线(BUS),考此总线连接主存。每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构被称为“对称多处理器”。

Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总 线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致 性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。

而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

深入前需要了解两个重要概念:Java对象头和Monitor(是实现synchronized的基础

synchronized用的锁是存在Java对象头里的,Hotsport虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。Klass Pointer是对象指向它类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,下面将重点阐述:
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
下图是Java对象头的存储结构(32位虚拟机):
Owner
EntryQ
RcThis
Nest
HashCode
Candidate

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


从上面可以看出,同步代码块是使用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做为锁对象。


深入前需要了解两个重要概念:Java对象头和Monitor(是实现synchronized的基础)

Java对象头

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

Mark Word

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


下图是Java对象头的存储结构(32位虚拟机):

25Bit 4Bit 1Bit 2Bit
对象的hashCode 对象的分代年龄 是否偏向锁 锁标志位

synchronized使用的锁是存放在Java对象头里面,具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式,可能值如下所示:


Monitor

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

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

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

其结构如下:

Owner:初始时为null表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为null

EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。

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

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

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

Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每次只有一个线程能成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞),导致性能严重下降。它只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程要竞争锁。

锁的优化

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 test(){
     Vector<String> vector = new Vector<String>();
     for(int i=0;i<10;i++){
          vector.add(i+" ");    
     }
     System.out.println(vector);
}

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

锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步。

这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 

在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。 

那什么是锁粗化?

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:

vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,

会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

轻量级锁

这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状

态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的

是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的

情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。

获取锁步骤如下:

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操作来执行的。主要步骤如下:

轻量级锁保存在Displaced Mark Word中的数据;

2)用CAS操作将取出的数据替换到当前对象的Mark Word中,如果成功,说明释放锁成功,否则执行(3);

3)如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

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

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。

只需要检查是否为偏向锁、锁标识为以及ThreadID即可。

获取锁步骤如下:

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

2)若为可偏向状态,则测试线程id是否为当前线程id,如果是,执行(5),否则执行(3);

3)如果线程id不为当前线程id,则通过CAS操作竞争锁,竞争成功,将Mark Word的线程id替换为当前线程id,否则执行(4);

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

5)执行同步代码块。

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点。

偏向锁的释放步骤如下:

1)暂停拥有偏向锁的线程,判断锁对象是都还处于被锁定状态;

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

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


重量级锁

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

不同锁的比较

以上。

To be continued...

猜你喜欢

转载自blog.csdn.net/qq_34964197/article/details/80599670