深入理解 synchronized 的锁升级

2,573 阅读13分钟

前言

最近看到一道有关 synchronized 关键字的面试题:不同 JDK 版本对 synchronized 有何优化?这道面试题的目的是为了考察 JDK 1.6后对锁的优化(增加了自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁)。通过这道面试题让我重新复习了一下锁升级的内容,接下来我想通过这篇文章主要讲述 synchronized 的锁升级知识,有兴趣的读者们可以继续往下阅读。

在 JDK 1.6 之前,使用 synchronized 关键字需要依赖于底层操作系统的 Mutex Lock 实现,挂起线程和恢复线程都需要转入内核态来完成,也就是说阻塞或唤醒一个 Java 线程都需要系统去切换 CPU 状态,这种状态的切换需要消耗处理器时间。这也就是为什么 synchronized 属于重量级锁的原因,因为需要切换 CPU 状态导致效率低下,时间成本相对较高,特别是当同步代码的内容过于简单时,可能切换的时间还要比代码执行的时间长。

在 JDK 1.6 之后,引入了偏向锁与轻量锁来减小获取和释放锁所带来的性能消耗,也就是不再是一上来就需要切换 CPU 状态导致效率低下而是通过锁升级的方式逐步增大性能消耗,从而避免了一些无需使用重量级锁的情况的性能消耗问题。

下面将详细讲解一下什么是 synchronized 的锁升级以及锁升级的整个流程。


锁升级

在讲述锁升级之前,先了解一下为什么每一个对象都可以作为锁使用?为什么使用 synchronized 关键字实现同步时以对象作为临界资源(锁),对象又是如何充当不同的锁的?

对象是如何充当不同的锁的?

Java对象的内存布局如下:

对象内存布局.png

可以看到在 Java 虚拟机中,对象分为三块区域,其中的对象头又包含 Mark Word(标记字段)Class Pointer(类型指针) 两部分(其中的数组长度是针对数组来说的)。

其中 Mark Word 用于存储对象的哈希码(hashCode)、GC分代年龄、锁状态标记、线程持有的锁、偏向线程ID、偏向时间戳等信息,Mark Word 会根据对象的状态复用自身的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化的

通过上图可以看到对象通过更改对象头的 Mark Word 中的锁的状态来实现锁升级。

锁升级可以分为四种状态:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,锁会随着线程的竞争情况逐渐升级,但是锁升级是不可逆的,只能升级不能降级。下面将详细介绍每个锁的状态。


无锁

无锁其实就是不使用 synchronized 关键字,无锁的锁标志位为 01。

image.png

如何在程序中查看对象的锁状态呢?

可以使用一个工具 JOL 来查看底层的内存标志位变化

引入 maven 坐标:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

测试类演示如何查看对象内存标志位:

/**
 * 测试类
 * @author 单程车票
 */
public class MonitorTest {

    public static void main(String[] args) {
        Object obj = new Object();
        // 记录16进制hashcode方便后续查看
        System.out.println("16进制hashcode为:" + Integer.toHexString(obj.hashCode()));
        // 打印对象内存信息
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

测试类打印结果:

可以看到偏向锁位为0,锁标志位为01,此时锁状态为无锁态,因为代码中并没有加锁。

image.png

后续的锁也可以通过该工具查看,有兴趣的可以自己实践一下。


偏向锁

标记字段中偏向锁状态如下:

image.png

为什么要引入偏向锁?

其实在大多数实际应用运行过程中,锁不存在多线程竞争的,而是总被同一个线程持有,很少发生竞争。这样的话就会带来多次非必要的获取释放锁过程,从而带来非必要的性能开销。所以引入了偏向锁是为了解决只有一个线程执行同步代码时提高性能

偏向锁的作用:当一段同步代码一直被同一个线程多次访问时,由于只有一个线程,该线程后续访问时无需再次获取锁。

偏向锁的升级过程

当一个线程进入被 synchronized 关键字修饰的同步代码后,JVM 会使用 CAS 操作把当前线程 ID 记录到作为锁的对象的 Mark Word 中占用 54 bit 的 ThreadID 字段中,同时会修改偏向锁位置为 1,表示当前线程获得该锁。此时的锁对象由无锁状态变为偏向锁状态。

在当前线程再次访问该同步代码时,JVM 通过锁对象的对象头的 Mark Word 判断 ThreadID 字段是否与当前线程 ID 一致,一致则说明当前线程还持有该锁对象,可以直接进入同步代码(偏向锁不会在线程执行完同步代码后就释放,也就是线程不会主动释放偏向锁)。

可以看到通过这种方式无需切换 CPU 状态,也就是不用操作系统接入。偏向锁其实就是在没有其他线程的竞争下,一直偏向于同一线程,该线程可以一直访问同步代码,而无需重复加锁。所以使用偏向锁几乎没有额外的开销,性能极高。

偏向锁的撤销过程

前面也提到了线程不会主动释放偏向锁,偏向锁的释放时机在:只有当其他线程竞争该锁时,持有偏向锁的线程才会被撤销,释放该偏向锁。并且撤销需要等待全局安全点,也就是该时间点没有字节码正在执行。

同时根据当前持有偏向锁的线程是否执行完同步代码分为两种撤销情况:

  • 情况一:线程 A 正在执行同步代码(还没有执行完同步代码)。此时线程 B 抢占该锁,该偏向锁会被撤销并出现锁升级成轻量级锁,此时该轻量级锁由原持有偏向锁的线程 A 持有,继续执行其同步代码,而正在竞争的线程 B 会进入自旋等待获得该轻量级锁。
  • 情况二:线程 A 执行完同步代码(已经退出同步代码)。此时线程 B 抢占该锁,该偏向锁会被撤销并将 ThreadID 置空以及偏向锁位置 0,根据线程 A 是否再次竞争分为:
    • 如果线程 A 不再继续竞争,那么会将偏向锁重新偏向线程 B,即线程 B 持有该偏向锁;
    • 如果线程 A 继续竞争,那么会将锁升级到了轻量级锁,通过 CAS 自旋抢占锁;

轻量级锁

标记字段中轻量级锁状态如下:

image.png

为什么要引入轻量级锁?

轻量级锁是为了在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统的 Mutex Lock 带来的性能消耗,轻量级锁适用于线程交替执行同步代码的场景。所以轻量级锁是为了在线程近乎交替执行同步代码时提高性能

轻量级锁的升级和撤销过程

当线程 A 与 线程 B 同时抢占锁对象时,偏向锁会被撤销并将锁升级为轻量级锁,这个升级过程如下:

线程 A 在执行同步代码前,JVM 在线程的栈帧中创建用于存储锁记录的空间 Lock Record。当线程 A 抢占锁对象时,JVM 使用 CAS 操作将锁对象的对象头的 Mark Word 拷贝进线程 A 的锁记录 Lock Record 中(这个拷贝 Mark Word 的过程官方称为 Displaced Mark Word),并且将 Mark Word 中指向线程栈中 Lock Record 的指针指向线程 A 的锁空间

如果更新成功,则线程 A 持有该对象锁,并将对象锁的 Mark Word 的锁标志位更新为 00。即此时线程 A 持有轻量级锁会执行同步代码,而线程 B 会自旋等待获取该轻量级锁;如果更新失败,则说明该锁被线程 B 抢占。

轻量级锁撤销的两种情况:

  • 当有两个以上的线程同时竞争一个锁时,那么轻量级锁会被撤销并升级为重量级锁,不再通过自旋的方式等待获取锁而是直接阻塞线程;
  • 当持有轻量级锁的线程执行完同步代码时,同样会释放轻量级锁,会使用 CAS 操作将锁对象的 Mark Word 中指针指向的锁记录 Lock Record 重新替换回锁对象的 Mark Word

重量级锁

标记字段中重量级锁状态如下:

image.png

存在两个以上的线程竞争同一把锁线程竞争轻量级锁自旋多次仍然失败时,会导致锁升级为重量级锁。重量级锁会直接阻塞持有锁的线程以外的所有线程,防止 CPU 空转,减小 CPU 的开销。

通过将锁对象的 Mark Word 的锁标志位更新为 10,从而将锁升级为重量级锁。此时可以看到 Mark Word 中有一个指向互斥量的指针,这个指针其实指向的就是 Monitor 对象的起始地址,通过 Monitor 对象即可实现互斥访问同步代码,也就是通过阻塞唤醒的方式实现同步。


总要有总结

以上就是 synchronized 的锁升级内容了,JDK 1.6 之后采用锁升级的方式来优化 synchronized 同步锁,提高了程序的运行效率。接下来总结一下偏向锁、轻量级锁、重量级锁三者的优缺点以及使用场景。

锁类型优点缺点
偏向锁只有一个线程访问同步代码时,只在置换ThreadID时进行一次CAS操作,锁的开销低,性能接近于无锁状态。线程间存在竞争时,需要频繁暂停持有锁的线程并检查状态和撤销锁,反而带来额外的开销。
轻量级锁线程间存在交替竞争时,竞争的线程不需要阻塞,提高了响应速度。当大量线程存在竞争时,线程始终的抢占不到锁,会导致CPU空转消耗CPU性能。
重量级锁通过阻塞唤醒的方式实现同步,防止CPU空转,不会消耗CPU性能。线程阻塞导致响应时间变长,频繁切换CPU状态,导致性能消耗增大。

根据三类锁的优缺点可以知道使用场景:

  • 偏向锁:适用于单线程的情况,在不存在锁竞争的时候进入同步代码可以使用偏向锁。
  • 轻量级锁:适用于竞争较不激烈且同步代码执行时间较短的情况,存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁要更高效。
  • 重量级锁:适用于竞争激烈且同步代码执行时间较长的情况,此时使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。

锁优化

JDK 1.6之后除了引入偏向锁、轻量级锁等针对于 synchronized 的锁优化之外,还引入了自适应自旋锁、锁消除、锁粗化等锁优化策略,接下来简单补充一下剩下的这三种锁优化策略。

自适应自旋锁

JDK 1.6之前就已经引入了自旋锁的概念,为了避免阻塞和唤醒线程带来的时间开销,引入了自旋锁的概念,即没有抢占到锁的线程通过自旋的方式,不放弃 CPU 执行时间,等待持有锁的线程执行完后,继续抢占。对于锁占用时间很短的场景,采用自旋锁可以展现出很好的性能。

为什么有了自旋锁还要引入自适应自旋锁呢?

自旋锁的缺点在于面对锁占用时间长的场景,线程始终占用 CPU 的时间片,会导致浪费 CPU 资源的问题。因此自旋锁需要指定超过限定次数后仍然没有抢占到锁的线程,就应该使用传统方式挂起(阻塞)线程。JDK 1.6之前可以通过 -XX:PreBlockSpin=10 的方式配置自旋上限次数,默认是 10 次。

JDK 1.6之后引入了自适应自旋锁,也就无需再指定自旋上限次数,自适应自旋锁会通过前一次在同一个锁上的自旋时间及锁的持有者状态来决定。也就是如果在同一锁对象上,自旋等待在尝试几次后抢占到锁,那么 JVM 会认为该锁自旋抢占到锁的几率很大,会自动增大自旋的上限次数;如果自旋等待很少抢占到锁,那么 JVM 可能直接省略自旋过程,直接挂起线程。

所以也就是 JDK 1.6引入自适应自旋锁后,使得自旋上限次数变得更加准确,减少额外的 CPU 开销。

锁消除

锁消除是 JVM 在 JIT 编译期间,通过对运行上下文的扫描,消除不可能存在共享资源竞争的锁。通过锁消除的优化可以节省非必要的抢占锁时间。

锁消除的优化依靠于逃逸分析的数据支持,JVM 会分析对象作用域,判断对象是否会逃逸,即在堆上的某个对象不会逃逸出去被其他线程访问,则可以把它当作栈上的数据看待,认为该数据是线程私有的,这样就没必要同步加锁了,则进行锁消除。

锁粗化

锁粗化是把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作,所以需要扩大(粗化)加锁范围使得降低加锁解锁的频率。