真正从底层理解 Synchronized 实现原理

实现synchronized的基础有两个:Java 对象头和 Monitor。
在虚拟机规范中定义了对象在内存中的布局,主要由以下 3 部分组成:

  • 对象头
  • 实例数据
  • 对齐填充

synchronized的实现就藏在对象头中。对象头中由两个比较重要的部分组成:

  • Mark Word:默认存储对象的 hashCode,分代年龄,锁类型,锁标志位等信息,是实现轻量级锁和偏向锁的关键

  • Class Metadata Address:类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的数据

下图是在 32 位机器上的 Mark Word 的组成示意图。在 Java6 之前,synchronized的实现是依靠重量级锁来实现的,锁标志位是10。在 Java6后,对synchronized进行了优化,增加了轻量级锁和偏向锁


我们先来说一下重量级锁:重量级锁中存放的是指向重量级锁的指针。在 Java 中,每个对象都存在着一个 Monitor 与之关联。当线程持有一个对象的 Monitor 后,Monitor便处于锁定状态。在 Hotspot 中,Monitor 是由`ObjectMonitor`来实现的。[点击这里查看源码](http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/b4fd7e078c54/src/share/vm/runtime/objectMonitor.hpp) ``` ObjectMonitor() { _header = NULL; _count = 0; _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; _previous_owner_tid = 0; } ``` 上面是`ObjectMonitor`类的初始化代码,可以看到有`_WaitSet`和`_EntryList`,对应于 Java 中的等待池和锁池 - `_owner`表示的是持有`objectMonitor`的线程。当多个线程同时访问一个对象的同步代码时,首先会进入到`_EntryList`里,当线程获取到对象的`Monitor`后,就把`Monitor`中的`owner`设置位当前线程,同时`Monitor`中的`_count`变量+1 - 若持有`Monitor`的线程执行完毕或者调用 `wait()`方法,则会释放持有的`Monitor`,`owner`会被设置为`NULL`,并将 count-1。当前线程会进入到`_WaitSet`,等待被唤醒 `Monitor`对象存在于每个对象的对象头中,`synchronized`便是通过持有`Monitor`实现锁的,这也是为什么 Java 中任意对象可以作为锁的原因。

下面是从字节码层面来分析synchronized关键字

public class SyncBlockAndMethod {
    public void syncTask() {
        //同步代码块
        synchronized (this) {
            System.out.println("Hello");
        }
    }

    //同步方法
    public void syncMethod() {
        System.out.println("Hello Again");

    }
}

在类中定义了同步代码块和同步方法,使用javac编译为字节码

javac SyncBlockAndMethod.java

然后使用javap查看编译生成的字节码

javap -verbose SyncBlockAndMethod

首先来看与synchronized修饰同步代码块有关的字节码

  public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Hello
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        ...
        ...
        ...

从字节码中可知,synchronized同步语句块的实现主要对应于monitorentermonitorexit指令。monitorenter是同步代码块的入口,表示在这里获取锁。monitorexit是同步代码块的出口,表示在这里释放锁。

细心的同学可以看到字节码中有一个monitorenter指令,却有两个monitorexit指令。第一个monitorexit指令是和monitorenter指令对应的。

而为了保证在同步代码块中抛出异常时依然能够释放锁,编译器会自动产生一个异常处理器,在同步代码块中抛出异常时,会在这个异常处理器里面释放锁,对应于字节码中的第二个monitorexit指令。

下面来看与synchronized修饰方法有关的字节码

  public synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, 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 Again
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 13: 0
        line 15: 8

上面没有看到monitorentermonitorexit指令,因为在synchronized修饰方法时,monitor的实现是隐式的,在方法的flags中添加了ACC_SYNCHRONIZED标志位,通过这种方式来实现synchronized方法。当一个线程进入synchronized方法时会获取monitor对象,在方法体中抛出异常或者执行完成时会释放monitor

什么是重入:

从互斥锁的设计上来说,当一个线程视图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态。但是当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入,是可以请求成功的。
举个例子:一个线程在获得锁进入synchronized方法时,在方法内部又调用了该对象另一个synchronized方法,是可以调用成功的。

在早期版本中,synchronized属于重量锁,Monitor依赖于底层操作系统的Mutex Lock实现
线程获得锁之后会切换,而线程之间的切换需要用用户态转换到和心态,开销较大
在 Java6 以后,在 JVM(Hospot) 层面对synchronized做了较大的优化,synchronized性能得到了很大的提升,包括如下:

  • Adaptive Spinning(自适应自旋锁)
  • Lock Eliminate(锁消除)
  • Lock Coarsening(锁粗化)
  • Lightweight Locking(轻量级锁)
  • Biased Locking(偏向锁)
    这些优化都是为了在线程之间更高效地共享数据以及解决竞争问题,从而提高程序的运行效率

自旋锁

在许多情况下,共享数据的锁定状态只会持续很短一段时间,为了这一点点的时间去切换线程并不值得,在多核 CPU 的情况下,完全可以让另一个没有获取到锁的线程通过执行忙循环等待锁的释放,而不是切换线程让出 CPU。这就是自旋锁,在 Java4 就引入了,只是默认是关闭的,到了 Java6 之后才变为默认开启自旋锁。
如果其他线程占有锁的时间非常短,那么自旋很快就能获取到锁,自旋锁的性能会很好;但是如果锁被其他线程长期占有,那么性能上的开销就会比较大,因为自旋就是循环,如果循环时间较长,那么就会白白消耗 CPU 的资源。因此在 自旋一定次数后仍然没有获取到锁,那么就使用传统的方式挂起并切换线程,可以使用preBlockSpin参数设置自旋次数。但是要确定不同场景下自旋次数是比较困难的,因此出现了自适应自旋锁

自适应自旋锁

在自适应自旋锁中,自旋的次数不再固定,而是由上一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。如果在一个锁对象上,刚刚有一个线程通过自旋成功获取到锁,并且持有锁的线程正在运行黄总,JVM 会认为通过自旋成功获取到锁的可能性很大,在下次其他线程获取锁时会增加自旋的次数。反之如果一个锁很少可以通过自旋成功获取,那么在之后获取锁时,将跳过自旋过程,避免浪费处理器资源。

锁消除

锁消除也是一种锁优化,在进行 JIT 编译时,JVM 对运行上下文进行扫描,去除不可能存在竞争的锁,可以节省一些没有意义的请求锁的时间,提升程序的性能。在下面的例子中,StringBuffer 是线程安全的,append() 方法带有synchronized关键字修饰,但是由于 sb 对象只会在add()方法内被调用,属于局部变量,不可能b被其他线程引用,因此 sb 对象属于不可能共享的资源,JVM 会自动消除 sb.append() 方法的锁

public class StringBufferWithoutSync {
    public void add(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        //StringBuffer 是线程安全的,append() 方法带有synchronized关键字修饰。
        //但是由于 sb 对象只会在add()方法内被调用,属于局部变量,不可能b被其他线程引用
        //因此 sb 对象属于不可能共享的资源,JVM 会自动消除 sb.append() 方法的锁
        sb.append(str1).append(str2);
    }

    public static void main(String[] args) {
        StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
        for (int i = 0; i < 100; i++) {
            withoutSync.add("a", "b");
        }
    }
}

锁粗化

通过扩大加锁的范围,避免反复加锁和释放锁

public class CoarseSync {
    public static String copyString100Times(String target) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            //由于 append 方法是 synchronized 的。每次 append 都会去申请锁,JVM 检测到在循环中的加锁和释放锁操作,
            // 比较耗时,就会将锁粗化到循环外部,只加一次锁,这样就提高了性能
            sb.append(target);
        }
        return sb.toString();
    }
}

在上面代码中,由于 append 方法是 synchronized 的。每次 append 都会去申请锁,JVM 检测到在循环中的加锁和释放锁操作,比较耗时,就会将锁粗化到循环外部,只加一次锁,这样就提高了性能。

synchronized 的四种状态

无锁、偏向锁、轻量级锁、重量级锁。
会随着竞争情况逐渐升级,锁膨胀的方向为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。

在特定情况下还会进行锁降级,当 JVM 运行到安全点(Safe Point)时,会检查是否有空闲的Monitor,并尝试将其进行降级。

无锁就对应上面的锁消除,重量锁就是Monitor,下面重点说一下偏向锁和轻量级锁。

偏向锁的出现是为了减少同一线程获取锁的代价。
在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得。为了减少同一线程获取锁的代价,引入了偏向锁。

偏向锁的核心思想是:如果一个线程获得了锁,那么锁进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当该线程再次清秋锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的锁标记位,以及当前线程 ID 等于 Mark Word 的 ThreadID即可,这样就省去了大量有关锁申请的操作。
当一个线程访问同步块并获取到锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程再进入和推出同步块时,不需要进行 CAS 操作来加锁和解锁,从而提高了程序的性能。
对于没有锁竞争的场合,偏向锁有很好的优化效果。但是对于锁竞争比较激烈的多线程场合,偏向锁就失去作用了,这时偏向锁就会升级为轻量级锁。

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁竞争的时候,偏向锁就会升级为轻量级锁。
轻量级锁适用的场景是线程交替执行同步块的情况,若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

  1. 在线程进入同步代码块时,如果同步对象锁状态为无锁状态(锁标志位为"01"状态),虚拟机首先在当当前线程的栈帧中建立一个名为琐记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word,这时线程堆栈与对象头的状态如图所示:

  1. 把对象头中的 Mark Word 拷贝到栈帧的琐记录中
  2. 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word,如果更次呢成功,则执行步骤 4,否则执行步骤 5
  3. 如果步骤 3 中的更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为"00",表示该对象处于轻量锁状态。这时候线程栈帧与对象头的状态如图所示:

5. 如果步骤 3 的更新操作失败了,虚拟机会首先检查对象的 Mark Word 的是否指向当前线程的栈帧,如果时就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行。否则说明多个线程在竞争锁,轻量级锁膨胀为重量级锁,锁标志的状态变为"10",Mark Word 中存储的就变为指向重量级锁的指针,当前线程便尝试使用自旋来获取锁,而后面等待锁的线程要进入阻塞状态。

下面再来讲讲轻量级锁解锁的过程。

解锁的过程:

  1. 通过 CAS 操作尝试把线程栈帧中复制的Displaced Mark Word 替换到对象当前的 Mark Word 中
  2. 如果替换成功,整个同步过程就完成了
  3. 如果替换失败,说明由其他线程尝试获取该锁(此时锁已经膨胀为重量级锁),那就要在释放锁的同时,唤醒被挂起的线程
    这里需要说明一下为什么把栈帧中的 Displaced Mark Word 成功替换到对象的 Mark Word 中,就是解锁成功了?这里需要从锁的内存语义来理解

锁的内存语义

  • 当线程释放锁时,Java 内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时,Java 内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

也就是说线程 A 释放一个锁,实际上是线程 A 向接下来将要获取这个锁的某个线程发出消息,这个消息就是线程 A 对共享变量所做的修改。
线程 B 获取某个锁,实际上是线程 B 接收之前占有这个锁的线程发出的消息,这个消息就是在释放锁之前对共享变量所做的修改。

优点 缺点 使用场景
偏向锁 加锁和解锁不需要 CAS 操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差别 如果线程之间存在锁竞争,会带来额外的锁撤销的消耗 只有一个线程访问同步代码块或者同步方法的场景
轻量级锁 竞争的线程不会阻塞,提高了相应速度 如果线程长时间抢占不到锁,自旋会消耗 CPU 性能 线程交替执行方法同步块或者同步方法的场景
重量级锁 线程竞争不使用自旋,不会消耗 CPU 线程阻塞,响应时间缓慢,在多线程下,频繁地获取和释放锁,会带来巨大的性能消耗 追求吞吐量,同步代码块或者同步方法执行时间较长的场景


如果你觉得这篇文章对你有帮助,不妨点个赞,让我有更多动力写出好文章。


我的文章会首发在公众号上,欢迎扫码关注我的公众号张贤同学


posted @ 2020-08-12 14:54  张贤同学  阅读(919)  评论(0编辑  收藏  举报