死磕Synchronized底层实现--概论

关于synchronized的底层实现,网上有不少文章了。可是不少文章要么做者根本没看代码,仅仅是根据网上其余文章总结、照搬而成,不免有些错误;要么不少点都是一笔带过,对于为何这样实现没有一个说法,让像我这样的读者意犹未尽。java

本系列文章将对HotSpot的synchronized锁实现进行全面分析,内容包括偏向锁、轻量级锁、重量级锁的加锁、解锁、锁升级流程的原理及源码分析,但愿给在研究synchronized路上的同窗一些帮助。主要包括如下几篇文章:linux

死磕Synchronized底层实现--概论git

死磕Synchronized底层实现--偏向锁github

死磕Synchronized底层实现--轻量级锁数组

死磕Synchronized底层实现--重量级锁安全

更多文章见我的博客:github.com/farmerjohng…多线程

大概花费了两周的实现看代码(花费了这么久时间有些忏愧,主要是对C++、JVM底层机制、JVM调试以及汇编代码不太熟),将synchronized涉及到的代码基本都看了一遍,其中还包括在JVM中添加日志验证本身的猜测,总的来讲目前对synchronized这块有了一个比较全面清晰的认识,但水平有限,有些细节不免有些疏漏,还望请你们指正。oracle

本篇文章将对synchronized机制作个大体的介绍,包括用以承载锁状态的对象头、锁的几种形式、各类形式锁的加锁和解锁流程、何时会发生锁升级。须要注意的是本文旨在介绍背景和概念,在讲述一些流程的时候,只提到了主要case,对于实现细节、运行时的不一样分支都在后面的文章中详细分析函数

本人看的JVM版本是jdk8u,具体版本号以及代码能够在这里看到。oop

synchronized简介

Java中提供了两种实现同步的基础语义:synchronized方法和synchronized块, 咱们来看个demo:

public class SyncTest {
    public void syncBlock(){
        synchronized (this){
            System.out.println("hello block");
        }
    }
    public synchronized void syncMethod(){
        System.out.println("hello method");
    }
}
复制代码

当SyncTest.java被编译成class文件的时候,synchronized关键字和synchronized方法的字节码略有不一样,咱们能够用javap -v 命令查看class文件对应的JVM字节码信息,部分信息以下:

{
  public void syncBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter				 	  // monitorenter指令进入同步块
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String hello block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit						  // monitorexit指令退出同步块
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit						  // monitorexit指令退出同步块
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
 

  public synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED      //添加了ACC_SYNCHRONIZED标记
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
 
}

复制代码

从上面的中文注释处能够看到,对于synchronized关键字而言,javac在编译时,会生成对应的monitorentermonitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的缘由是:为了保证抛异常的状况下也能释放锁,因此javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试得到锁。

在JVM底层,对于这两种synchronized语义的实现大体相同,在后文中会选择一种进行详细分析。

由于本文旨在分析synchronized的实现原理,所以对于其使用的一些问题就不赘述了,不了解的朋友能够看看这篇文章

锁的几种形式

传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用mutex互斥锁,最底层实现依赖于futex,关于futex能够看我以前的文章,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized关键字但运行时并无多线程竞争,或两个线程接近于交替执行的状况,使用传统锁机制无疑效率是会比较低的。

在JDK 1.6以前,synchronized只有传统的锁机制,所以给开发者留下了synchronized关键字相比于其余同步机制性能很差的印象。

在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

在看这几种锁机制的实现前,咱们先来了解下对象头,它是实现多种锁机制的基础。

对象头

由于在Java中任意对象均可以用做锁,所以一定要有一个映射关系,存储该对象以及其对应的锁信息(好比当前哪一个线程持有锁,哪些线程在等待)。一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:须要对map作线程安全保障,不一样的synchronized之间会相互影响,性能差;另外当同步对象较多时,该map可能会占用比较多的内存。

因此最好的办法是将这个映射关系存储在对象头中,由于对象头自己也有一些hashcode、GC相关的数据,因此若是能将锁信息与这些信息共存在对象头中就行了。

在JVM中,对象在内存中除了自己的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。

类型指针是指向该对象所属类对象的指针,mark word用于存储对象的HashCode、GC分代年龄、锁状态等信息。在32位系统上mark word长度为32字节,64位系统上长度为64字节。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式以下:

image

能够看到锁信息也是存在于对象的mark word中的。当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。

重量级锁

重量级锁是咱们常说的传统意义上的锁,其利用操做系统底层的同步机制去实现Java中的线程同步。

重量级锁的状态下,对象的mark word为指向一个堆中monitor对象的指针。

一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

1517900250327

当一个线程尝试得到锁时,若是该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列尾部,而后暂停当前线程。当持有锁的线程释放锁前,会将cxq中的全部元素移动到EntryList中去,并唤醒EntryList的队首线程。

若是一个线程在同步块中调用了Object#wait方法,会将该线程对应的ObjectWaiter从EntryList移除并加入到WaitSet中,而后释放锁。当wait的线程被notify以后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。

以上只是对重量级锁流程的一个简述,其中涉及到的不少细节,好比ObjectMonitor对象从哪来?释放锁时是将cxq中的元素移动到EntryList的尾部仍是头部?notfiy时,是将ObjectWaiter移动到EntryList的尾部仍是头部?

关于具体的细节,会在重量级锁的文章中分析。

轻量级锁

JVM的开发者发如今不少状况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不一样的线程交替的执行同步块中的代码。这种状况下,用重量级锁是不必的。所以JVM引入了轻量级锁的概念。

线程在执行同步块以前,JVM会先在当前的线程的栈帧中建立一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record

img

加锁过程

1.在线程栈中建立一个Lock Record,将其obj(即上图的Object reference)字段指向锁对象。

2.直接经过CAS指令将Lock Record的地址存储在对象头的mark word中,若是对象处于无锁状态则修改为功,表明该线程得到了轻量级锁。若是失败,进入到步骤3。

3.若是是当前线程已经持有该锁了,表明这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的做用。而后结束。

4.走到这一步说明发生了竞争,须要膨胀为重量级锁。

解锁过程

1.遍历线程栈,找到全部obj字段等于当前锁对象的Lock Record

2.若是Lock RecordDisplaced Mark Word为null,表明这是一次重入,将obj设置为null后continue。

3.若是Lock RecordDisplaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。若是成功,则continue,不然膨胀为重量级锁。

偏向锁

Java是支持多线程的语言,所以在不少二方包、基础库中为了保证代码在多线程的状况下也能正常运行,也就是咱们常说的线程安全,都会加入如synchronized这样的同步语义。可是在应用在实际运行时,极可能只有一个线程会调用相关同步方法。好比下面这个demo:

import java.util.ArrayList;
import java.util.List;

public class SyncDemo1 {

    public static void main(String[] args) {
        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0; i < 100; i++) {
            syncDemo1.addString("test:" + i);
        }
    }

    private List<String> list = new ArrayList<>();

    public synchronized void addString(String s) {
        list.add(s);
    }

}
复制代码

在这个demo中为了保证对list操纵时线程安全,对addString方法加了synchronized的修饰,但实际使用时却只有一个线程调用到该方法,对于轻量级锁而言,每次调用addString时,加锁解锁都有一个CAS操做;对于重量级锁而言,加锁也会有一个或多个CAS操做(这里的’一个‘、’多个‘数量词只是针对该demo,并不适用于全部场景)。

在JDK1.6中为了提升一个对象在一段很长的时间内都只被一个线程用作锁对象场景下的性能,引入了偏向锁,在第一次得到锁时,会有一个CAS操做,以后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。咱们来看看偏向锁是如何作的。

对象建立

当JVM启用了偏向锁模式(1.6以上默认开启),当新建立一个对象的时候,若是该对象所属的class没有关闭偏向锁模式(何时会关闭一个class的偏向模式下文会说,默认全部class的偏向模式都是是开启的),那新建立对象的mark word将是可偏向状态,此时mark word中的thread id(参见上文偏向状态下的mark word格式)为0,表示未偏向任何线程,也叫作匿名偏向(anonymously biased)。

加锁过程

case 1:当该对象第一次被线程得到锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改为当前线程Id。若是成功,则表明得到了偏向锁,继续执行同步块中的代码。不然,将偏向锁撤销,升级为轻量级锁。

case 2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在经过一些额外的检查后(细节见后面的文章),会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,而后继续执行同步块的代码,由于操纵的是线程私有的栈,所以不须要用到CAS指令;因而可知偏向锁模式下,当被偏向的线程再次尝试得到锁时,仅仅进行几个简单的操做就能够了,在这种状况下,synchronized关键字带来的性能开销基本能够忽略。

case 3.当其余线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,通常来讲,会在safepoint中去查看偏向的线程是否还存活,若是存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;若是偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改成无锁状态(unlocked),以后再升级为轻量级锁。

因而可知,偏向锁升级的时机为:当锁已经发生偏向后,只要有另外一个线程尝试得到偏向锁,则该偏向锁就会升级成轻量级锁。固然这个说法不绝对,由于还有批量重偏向这一机制。

解锁过程

当有其余线程尝试得到锁时,是根据遍历偏向线程的lock record来肯定该线程是否还在执行同步块中的代码。所以偏向锁的解锁很简单,仅仅将栈中的最近一条lock recordobj字段设置为null。须要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

下图展现了锁状态的转换流程:

img

另外,偏向锁默认不是当即就启动的,在程序启动后,一般有几秒的延迟,能够经过命令 -XX:BiasedLockingStartupDelay=0来关闭延迟。

批量重偏向与撤销

从上文偏向锁的加锁解锁过程当中能够看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本能够忽略,可是当有其余线程尝试得到锁时,就须要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词咱们在GC中常常会提到,其表明了一个状态,在该状态下全部线程都是暂停的(大概这么个意思),详细能够看这篇文章。总之,偏向锁的撤销是有必定成本的,若是说运行时的场景自己存在多线程竞争的,那偏向锁的存在不只不能提升性能,并且会致使性能降低。所以,JVM中增长了一种批量重偏向/撤销的机制。

存在以下两种状况:(见官方论文第4小节):

1.一个线程建立了大量对象并执行了初始的同步操做,以后在另外一个线程中将这些对象做为锁进行以后的操做。这种case下,会致使大量的偏向锁撤销操做。

2.存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。

其作法是:以class为单位,为每一个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操做时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,所以会进行批量重偏向。每一个class对象会有一个对应的epoch字段,每一个处于偏向锁状态对象的mark word中也有该字段,其初始值为建立该对象时,class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中全部线程的栈,找到该class全部正处于加锁状态的偏向锁,将其epoch字段改成新值。下次得到锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其余线程,也不会执行撤销操做,而是直接经过CAS操做将其mark word的Thread Id 改为当前线程Id。

当达到重偏向阈值后,假设该class计数器继续增加,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,以后,对于该class的锁,直接走轻量级锁的逻辑。

End

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不一样线程交替持有锁、多线程竞争锁三种状况。当条件不知足时,锁会按偏向锁->轻量级锁->重量级锁 的顺序升级。JVM种的锁也是能降级的,只不过条件很苛刻,不在咱们讨论范围以内。该篇文章主要是对Java的synchronized作个基本介绍,后文会有更详细的分析。

相关文章
相关标签/搜索