likes
comments
collection
share

深入剖析Java中的synchronized关键字

作者站长头像
站长
· 阅读数 11

synchronized介绍

通过深入理解Java内存模型:保障多线程程序的正确执行我们了解到synchronized关键字可以解决的是多个线程之间访问资源的同步性。synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 程序中,我们可以利用 synchronized 关键字来对程序进行加锁。它既可以用来声明一个 synchronized 代码块,也可以直接标记静态方法或者实例方法。

关键字在代码块上

代码如下:

public void methodA() {
    Object obj = new Object();
    synchronized (obj) {
        //
    }
}

编译结果(javap -v)

public void methodA();
descriptor: ()V
flags: ACC_PUBLIC
Code:
  stack=2, locals=4, args_size=1
     0: new           #3                  // class java/lang/Object
     3: dup
     4: invokespecial #1                  // Method java/lang/Object."<init>":()V
     7: astore_1
     8: aload_1
     9: dup
    10: astore_2
    11: monitorenter
    12: aload_2
    13: monitorexit
    14: goto          22
    17: astore_3
    18: aload_2
    19: monitorexit
    20: aload_3
    21: athrow
    22: return
  Exception table:
     from    to  target type
        12    14    17   any
        17    20    17   any

上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。 synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。

关键字在方法上

代码如下:

public synchronized void methodB() {  
    //  
    i++;  
}

当synchronized修饰同步方法时,编译器会在生成的字节码中添加一个额外的指令来获取和释放方法的监视器锁(monitor lock)。同时,编译器还会设置方法的ACC_SYNCHRONIZED标志。

public synchronized void methodB();
  descriptor: ()V
  flags: ACC\_PUBLIC, ACC\_SYNCHRONIZED
  Code:
    stack=3, locals=1, args\_size=1
        0: aload\_0
        1: dup
        2: getfield      #2                  // Field i:I
        5: iconst\_1
        6: iadd
        7: putfield      #2                  // Field i:I
        10: return
        LineNumberTable:
        line 15: 0
        line 16: 10

当JVM加载字节码文件并解析类的时候,会检查方法的访问标志。如果ACC_SYNCHRONIZED标志被设置,表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。

这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。

synchronized原理

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头、实例数据和对齐填充。在对象头中保存了锁标志位和指向 monitor 对象的起始地址,如下图所示,右侧就是对象对应的 Monitor 对象。当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。另外 Monitor 中还有两个队列,用来存放进入及等待获取锁的线程。

深入剖析Java中的synchronized关键字

为了提升性能,JDK1.6 引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的 Java 对象头实现了锁升级功能。当 Java 对象被 Synchronized 关键字修饰成为同步锁后,围绕这个锁的一系列升级操作都将和 Java 对象头有关。

对象头

Java中对象头由三个部分组成:Mark Word、Klass Pointer、Length。

  • Mark Word Mark WordMark Word记录了与对象和锁相关的信息,当这个对象作为锁对象来实现synchronized的同步操作时,锁标记和相关信息都是存储在Mark Word中的。

    64位系统Mark Word存储结构如下: 深入剖析Java中的synchronized关键字 从图中可以看到一个锁状态的字段,它包含五种状态分别是无锁、偏向锁、轻量级锁、重量级锁、GC标记。通过1bit来表达无锁和偏向锁,其中0表示无锁、1表示偏向锁。

  • Klass PointerKlass Pointer Klass PointerKlass Pointer表示指向类的指针,JVM通过这个指针来确定对象具体属于哪个类的实例。它的存储长度根据JVM的位数来决定,在32位的虚拟机中占4字节,在64位的虚拟机中占8字节,但是在JDK 1.8中,由于默认开启了指针压缩,所以压缩后在64位系统中只占4字节。

  • Length Length表示数组长度,只有构建对象数组时才会有数组长度属性。

锁升级过程

深入剖析Java中的synchronized关键字

当一个线程访问增加了synchronized关键字的代码块时,如果偏向锁是开启状态,则先尝试通过偏向锁来获得锁资源,这个过程仅仅通过CAS来完成。如果当前已经有其他线程获得了偏向锁,那么抢占锁资源的线程由于无法获得锁,所以会尝试升级到轻量级锁来进行锁资源抢占,轻量级锁就是通过多次CAS(也就是自旋锁)来完成的。如果这个线程通过多次自旋仍然无法获得锁资源,那么最终只能升级到重量级锁来实现线程的等待。

下图显示了对象头的布局和不同对象状态的表示: 深入剖析Java中的synchronized关键字

偏向锁的原理

偏向锁其实可以认为是在没有多线程竞争的情况下访问synchronized修饰的代码块的加锁场景,也就是在单线程执行的情况下。

实际上对程序开发来说,加锁是为了防范线程安全性的风险,但是是否有线程竞争并不由我们来控制,而是由应用场景来决定。假设这种情况存在,就没有必要使用重量级锁基于操作系统级别的Mutex Lock来实现锁的抢占,这样显然很耗费性能。

所以偏向锁的作用就是,线程在没有线程竞争的情况下去访问synchronized同步代码块时,会尝试先通过偏向锁来抢占访问资格,这个抢占过程是基于CAS来完成的,如果抢占锁成功,则直接修改对象头中的锁标记。其中,偏向锁标记为1,锁标记为01,以及存储当前获得锁的线程ID。而偏向的意思就是,如果线程X获得了偏向锁,那么当线程X后续再访问这个同步方法时,只需要判断对象头中的线程ID和线程X是否相等即可。如果相等,就不需要再次去抢占锁,直接获得访问资格即可,其实现原理如图所示:

深入剖析Java中的synchronized关键字

获取偏向锁的流程

下图代表获取偏向锁的粗粒度流程图,偏向锁是在没有线程竞争的情况下实现的一种锁,不能排除存在锁竞争的情况,所以偏向锁的获取有两种情况。

深入剖析Java中的synchronized关键字

  1. 没有锁竞争

    在没有锁竞争并且开启了偏向锁的情况下,当线程1访问synchronized(lock)修饰的代码块时:

    • 从当前线程的栈中找到一个空闲的BasicObjectLock(图中Lock Record),它是一个基础的锁对象,在后续的轻量级锁和重量级锁中都会用到,BasicObjectLock包含以下两个属性。
      • BasicLock,该属性中有一个字段markOop,用于保存指向lock锁对象的对象头数据。
      • oop,指向lock锁对象的指针。
    • 将BasicObjectLock中的oop指针指向当前的锁对象lock。
    • 获得当前锁对象lock的对象头,通过对象头来判断是否可偏向,也就是说锁标记为101,并且Thread Id为空。
      • 如果为可偏向状态,那么判断当前偏向的线程是不是线程1,如果偏向的是自己,则不需要再抢占锁,直接有资格运行同步代码块。
      • 如果为不可偏向状态,则需要通过轻量级锁来完成锁的抢占过程。
    • 如果对象锁lock偏向其他线程或者当前是匿名偏向状态(也就是没有偏向任何一个线程),则先构建一个匿名偏向的Mark Word,然后通过CAS方法,把一个匿名偏向的Mark Word修改为偏向线程1。如果当前锁对象lock已经偏向了其他线程,那么CAS一定会失败。
  2. 存在锁竞争

    假设线程1获得了偏向锁,此时线程2去执行synchronized(lock)同步代码块,如果访问到同一个对象锁则会触发锁竞争并触发偏向锁撤销,撤销流程如下。

    • 线程2调用撤销偏向锁方法,尝试撤销lock锁对象的偏向锁。
    • 撤销偏向锁需要到达全局安全点(SafePoint)才会执行,全局安全点就是当前线程运行到的这个位置,线程的状态可以被确定,堆对象的状态也是确定的,在这个位置JVM可以安全地进行GC、偏向锁撤销等动作。当到达全局安全点后,会暂停获得偏向锁的线程1。
    • 检查获得偏向锁的线程1的状态,这里存在两种状态。
      • 线程1已经执行完同步代码块或者处于非存活状态。在这种情况下,直接把偏向锁撤销恢复成无锁状态,然后线程2升级到轻量级锁,通过轻量级锁抢占锁资源。
      • 线程1还在执行同步代码块中的指令,也就是说没有退出同步代码块。在这种情况下,直接把锁对象lock升级成轻量级锁(由于这里是全局安全点,所以不需要通过CAS来实现),并且指向线程1,表示线程1持有轻量级锁,接着线程1继续执行同步代码块中的代码。

偏向锁的释放

在偏向锁执行完synchronized同步代码块后,会触发偏向锁释放的流程,需要注意的是,偏向锁本质上并没有释放,因为当前锁对象lock仍然是偏向该线程的。释放的过程只是把Lock Record释放了,也就是说把Lock Record保存的锁对象的Mark Word设置为空。

偏向锁批量重偏向当一个锁对象lock只被同一个线程访问时,该锁对象的锁状态就是偏向锁,并且一直偏向该线程。当有任何一个线程来访问该锁对象lock时,不管之前获得偏向锁线程的状态是存活还是死亡,lock锁对象都会升级为轻量级锁,并且锁在升级之后是不可逆的。

假设一个线程t1针对大量的锁对象增加了偏向锁,之后线程t2来访问这些锁对象,在不考虑锁竞争的情况下,需要对之前所有偏向线程t1的锁对象进行偏向锁撤销和升级,这个过程比较耗时,而且虚拟机会认为这个锁不适合再偏向于原来的t1线程,于是当偏向锁撤销次数达到20次时,会触发批量重偏向,把所有的锁对象全部偏向线程t2。偏向锁撤销并批量重偏向的触发阈值可以通过XX:BiasedLockingBulkRebiasThreshold = 20来配置,默认是20。

在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生 stop the word 后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM 参数关闭偏向锁来调优系统性能:

-XX:-UseBiasedLocking //关闭偏向锁(默认打开)
或者
-XX:+UseHeavyMonitors  //设置重量级锁

轻量级锁的原理

在线程没有竞争时,使用偏向锁能够在不影响性能的前提下获得锁资源,但是同一时刻只允许一个线程获得锁资源,如果有多个线程来访问同步方法,于是就有了轻量级锁的设计。

所谓的轻量级锁,就是没有抢占到锁的线程,进行一定次数的重试(CAS)。比如线程第一次没抢到锁则重试几次,如果在重试的过程中抢占到了锁,那么这个线程就不需要阻塞,这种实现方式我们称为自旋锁,具体的实现流程如图所示。

深入剖析Java中的synchronized关键字

线程通过重试来抢占锁的方式是有代价的,因为线程如果不断自旋重试,那么CPU会一直处于运行状态。如果持有锁的线程占有锁的时间比较短,那么自旋等待的实现带来性能的提升会比较明显。反之,如果持有锁的线程占用锁资源的时间比较长,那么自旋的线程就会浪费CPU资源,所以线程重试抢占锁的次数必须要有一个限制。从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,根据前一次在同一个锁上的自旋次数及锁持有者的状态来决定的。如果在同一个锁对象上,通过自旋等待成功获得过锁,并且持有锁的线程正在运行中,那么JVM会认为此次自旋也有很大的机会获得锁,因此会将这个线程的自旋时间相对延长。反之,如果在一个锁对象中,通过自旋锁获得锁很少成功,那么JVM会缩短自旋次数。

在高负载、高并发的场景下,我们可以通过设置 JVM 参数来关闭自旋锁,优化系统性能:

-XX:-UseSpinning //参数关闭自旋锁优化(默认打开) 
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由jvm控制

如果偏向锁存在竞争或者偏向锁未开启,那么当线程访问synchronized(lock)同步代码块时就会采用轻量级锁来抢占锁资源,获得访问资格,轻量级锁的加锁如图所示:

深入剖析Java中的synchronized关键字

获取轻量级锁的流程

  • 在线程2进入同步代码块后,JVM会给当前线程分配一个Lock Record,也就是一个BasicObjectLock对象,在它的成员对象BasicLock中有一个成员属性markOop _displaced_header,这个属性专门用来保存锁对象lock的原始Mark Word。

  • 构建一个无锁状态的Mark Word(其实就是lock锁对象的Mark Word,但是锁状态是无锁),把这个无锁状态的Mark Word设置到Lock Record中的_displaced_header字段中,如图所示:

    深入剖析Java中的synchronized关键字

  • 通过CAS将lock锁对象的Mark Word替换为指向Lock Record的指针,如果替换成功,就会得到如图所示的结构,表示轻量级锁抢占成功,此时线程2可以执行同步代码块。 深入剖析Java中的synchronized关键字

  • 如果CAS失败,则说明当前lock锁对象不是无锁状态,会触发锁膨胀,升级到重量级锁。

相对偏向锁来说,轻量级锁的原理比较简单,它只是通过CAS来修改锁对象中指向Lock Record的指针。从功能层面来说,偏向锁和轻量级锁最大的不同是:

  • 偏向锁只能保证偏向同一个线程,只要有线程获得过偏向锁,那么当其他线程去抢占锁时,只能通过轻量级锁来实现,除非触发了重新偏向(如果获得轻量级锁的线程在后续的20次访问中,发现每次访问锁的线程都是同一个,则会触发重新偏向,20次的定义属性为:XX:BiasedLockingBulkRebiasThreshold =20)
  • 轻量级锁可以灵活释放,也就是说,如果线程1抢占了轻量级锁,那么在锁用完并释放后,线程2可以继续通过轻量级锁来抢占锁资源。

轻量级锁的释放

偏向锁也有锁释放的逻辑,但是它只是释放Lock Record,原本的偏向关系仍然存在,所以并不是真正意义上的锁释放。而轻量级锁释放之后,其他线程可以继续使用轻量级锁来抢占锁资源,具体的实现流程如下。

  1. 把Lock Record中_displaced_header存储的lock锁对象的Mark Word替换到lock锁对象的Mark Word中,这个过程会采用CAS来完成。
  2. 如果CAS成功,则轻量级锁释放完成。
  3. 如果CAS失败,说明释放锁的时候发生了竞争,就会触发锁膨胀,完成锁膨胀之后,再调用重量级锁的释放锁方法,完成锁的释放过程。

重量级锁的原理分析

轻量级锁能够通过一定次数的重试让没有获得锁的线程有可能抢占到锁资源,但是轻量级锁只有在获得锁的线程持有锁的时间较短的情况下才能起到提升同步锁性能的效果。如果持有锁的线程占用锁资源的时间较长,那么不能让那些没有抢占到锁资源的线程不断自旋,否则会占用过多的CPU资源,这反而是一件得不偿失的事情。如果没抢占到锁资源的线程通过一定次数的自旋后,发现仍然没有获得锁,就只能阻塞等待了,所以最终会升级到重量级锁,通过系统层面的互斥量(Mutex)来抢占锁资源。重量级锁的实现原理如图所示:

深入剖析Java中的synchronized关键字

如果线程在运行synchronized(lock)同步代码块时,发现锁状态是轻量级锁并且有其他线程抢占了锁资源,那么该线程就会触发锁膨胀升级到重量级锁。因此,重量级锁是在存在线程竞争的场景中使用的锁类型。

获取重量级锁的流程

重量级锁的实现流程如图所示:

深入剖析Java中的synchronized关键字

重量级锁的实现是在ObjectMonitor中完成的,所以锁膨胀的意义就是构建一个ObjectMonitor,继续关注图中ObjectMonitor的实现部分,在ObjectMonitor中锁的实现过程如下:

  • 首先,判断当前线程是否是重入,如果是则增加重入次数。
  • 然后,通过自旋锁来实现锁的抢占(这个自旋锁就是前面我们提到的自适应自旋),这里使用CAS机制来判断ObjectMonitor中的_owner字段是否为空,如果为空就表示重量级锁已释放,当前线程可以获得锁,否则就进行自适应自旋重试。
  • 最后,如果通过自旋锁竞争锁失败,则会把当前线程构建成一个ObjectWaiter节点,插入_cxq队列的队首,再使用park方法阻塞当前线程。

重量级锁的释放

锁的释放是在synchronized同步代码块结束后触发的,释放的逻辑比较简单。

  • 把ObjectMonitor中持有锁的对象_owner置为null。
  • 从_cxq队列中唤醒一个处于锁阻塞的线程。
  • 被唤醒的线程会重新竞争重量级锁,需要注意的是,synchronized是非公平锁,因此被唤醒后不一定能够抢占到锁,如果没抢到,则继续等待。

总结

JVM 在 JDK1.6 中引入了分级锁机制来优化 Synchronized,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。减少锁竞争,是优化 Synchronized 同步锁的关键。我们应该尽量使 Synchronized 同步锁处于轻量级锁或偏向锁,这样才能提高 Synchronized 同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高 Synchronized 同步锁在自旋时获取锁资源的成功率,避免 Synchronized 同步锁升级为重量级锁。