Java多线程 synchronized底层原理

synchronized底层实现

本文转载 https://github.com/farmerjohngit/myblog/issues/12

synchronized简介

Java中提供了两种实现同步的基础语义:synchronized方法和synchronized块,

public class SynTest{
    public void syncBlock(){
        synchronized(this){
            System.out.println("hello block");
        }
    }
    public synchronized void syncMethod(){
        System.out.println("hello method");
    }
}

上边的代码展现了synchronized的基本用法

当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在编译时,会生成对应的monitorenter和monitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。

锁的几种形式

传统的锁依赖于系统的同步函数,在linux上使用mutex互斥锁,最底层实现依赖与futex,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized关键字运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。
在JDK1.6之前,synchronized只有传统的锁机制,因此给开发者留下了synchronized关键字相比于其他同步机制性能不好的印象。在JDK1.6引入了两种新型锁机制:偏向锁和轻量级锁,他们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
下面我们先了解下对象头,他是实现多种锁机制的基础。

对象头

因为在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(比如当前哪个线程持有锁,哪些线程在等待)。一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题,需要对map做线程安全保障,笔筒的synchronized之间会相互影响,性能差:另外当同步对象较多时,该map可能会占用比较多的内存。
所以可以讲这个映射关系存储在对象头中,因为对象头本身也有一些hashcode,GC相关的数据,所以如果能讲锁信息与这些信息共存在对象头中就好了。
在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。
其中类型指针是指向该对象所属类对象的指针,mark word用于存储对象和HashCode,GC分代年龄,锁状态等信息。在32为系统上mark word长度为32bit,64系统为64bit,为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32为系统上个状态的格式如下:

在这里插入图片描述

我们可以看到锁信息也是存在与对象的mark word中的。通过锁标志位来确定当前对象的锁类型,当对象状态为偏向锁时,mark word存储的时偏向的线程ID,当状态为轻量级锁时,mark word存储的时指向线程栈中的Lock Record的指针;当状态为重量级锁时,为堆中的monitor对象的指针。

重量级锁

重量级锁是传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。重量级锁的状态下,对象的mark word为指向一个堆中的monitor对象的指针。一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList,WaitSet,owner。其中cxq,EntryList,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

在这里插入图片描述

当一个线程尝试获得锁时,如果该锁已经被占用,则会讲该线程封装成一个ObjectWaiter对象插入到cxq的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将cxq中所有元素移动到EntryList中去,并唤醒EntryList的队首线程。
如果一个线程在同步块中调用了Object#wait方法,会将该线程对应的ObjectWaiter从EntryList移除后并加入待WaitSet中,然后释放锁。当wait线程被notify之后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。具体细节后续再说

轻量级锁

JVM开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替执行同步代码块中的代码。这种情况下,用重量级锁时没有必要的。因此JVM引入了轻量级锁的概念。
线程在执行同步块之前,JVM会在当前的线程的栈帧中创建一个Lock Record,其中包括一个用于存储对象头中的mark word以及一个指向对象的指针。下图右侧为LockRecord,左侧为对象头。

在这里插入图片描述

加锁过程

  1. 在线程池中创建一个Lock Record,将其obj(即上图的Object reference字段指向锁对象)。
  2. 直接通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁,如果失败,进入步骤3.
  3. 如果当前线程已经持有锁的,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用,然后结束。
  4. 走到这一步说明发生了竞争,需要膨胀为重量级锁

解锁过程

  1. 遍历所有线程栈,找到所有obj字段等于当前锁对象的LockRecord。
  2. 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue。
  3. 如果LockRecord的DisplacedMarkWord不为null,则利用CAS指令将对象头的mark down恢复称为无锁状态。如果成功,则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操作。
在JDK1.6中为了提高一个对象在一段很长的时间内都只能被一个线程用作锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程在获取锁,只会执行几个简单的命令,而不是开销较大的CAS指令。

对象的创建

当JVM启动了偏向锁模式后,当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式,那么新创建的对象的mark down将是可偏向状态,此时mark down中的thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

加锁过程

case1:当该对象第一次被线程获得锁的时候,发现时匿名偏向状态,则会使用CAS指令,将mark word中的thread id由0改成当前线程id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码,否则,将偏向锁撤销,升级为轻量级锁。
case2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后,会往当前线程中的栈中添加一条DisplacedMarkWord未空的LockRecord中,然后继续执行同步块的代码,因为操作的时线程私有的栈,因此不需要用到CAS指令,由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开箱基本可以忽略。
case3:当其他线程进入同步块时,发现已经由偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则讲锁升级未轻量级锁,原偏向的线程继续拥有锁,当前线程则走入待锁升级的逻辑中,如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态,之后在升级未轻量级锁
由此可见,偏向锁升级的时机未:当锁已发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级未轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。

解锁过程

当有其他线程尝试获得锁时,时根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将掌中最近一条lock record的obj字典设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

在这里插入图片描述

批量重偏向与撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等待safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁,safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有的线程都是暂停的,总之,偏向锁的撤销是由一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此JVM中增加了一种批量重偏向/撤销机制。

存在如下两种情况:

  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的锁,直接走轻量级锁的逻辑。