Java并发编程:Synchronized 的优化机制

255 阅读8分钟

在上一篇文章中,我们学习了 Synchronized 的底层实现原理、JDK1.6 在偏向锁、轻量级锁上对 Synchronized 的优化,以及从偏向锁,到轻量级锁,再到重量级锁的升级过程。今天我们继续看看 JDK1.6 对 Synchronized 的还做了哪些其他优化,以及 Synchronized 的作用。

我们知道,Synchronized 锁在升级操作后,有很大可能会变成重量级锁,这种情况开销会很大。开销大的原因是因为 Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。下面我们介绍一下用户态和内核态。

用户态和内核态

用户态(User Mode)指的是当进程在执行用户自己的代码时,则称其处于用户运行态。而当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态,此时处理器处于特权级最高的内核代码中执行。

图片

区分了用户态和内核态后,程序在执行某个操作时会进行一系列的验证和检验,确认没问题之后才可以正常的操作资源,这样就不会担心一不小心就把系统搞崩溃的问题了,可以让程序更加安全地运行,但用户态和内核态这两种形态的切换,也会导致一定的性能开销。

自适应自旋锁

在有些场景下,对象锁的锁状态只会持续很短一段时间,短时间内频繁地阻塞和唤醒线程是非常不值得的。 正是为了解决这一问题,我们引入了自旋锁。

简单来说,自旋锁就是指通过自身循环,尝试获取锁的一种方式。自旋锁在 JDK 1.4.2 中引入时,默认为关闭,需要通过参数 -XX:+UseSpinning 开启。而到之后的 JDK1.6 中,自旋锁则默认为开启,同时自旋的默认次数为 10 次。我们可以通过参数-XX:PreBlockSpin 来调整自旋的默认次数。但是,如果通过参数-XX:preBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。

我们举一个实际的例子来说明:我将参数-XX:preBlockSpin 调整为 10,自旋 10 次后若还没有获取锁就退出。但在你刚刚退出的时候,可能有的线程就释放了锁,也就是说,在这种情况下,其实你多自旋一两次其实就可以获取锁。所以我们能看到, 即使可以调整参数,也还是不能彻底解决问题,于是 JDK1.6 引入自适应的自旋锁。

所谓自适应就意味着自旋的次数不再是固定的,而是由前一次在同一个锁上的自旋时间,以及锁的拥有者状态来决定的。

线程如果自旋成功了,那么下次自旋的次数会更加多。这是由于虚拟机会认为既然上次成功了,那么此次自旋也很有可能会再次成功,所以它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么以后在获取这个锁的时候,自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

下面这张图展示了一个线程 T1 尝试获取锁的操作,简单来说:如果获取锁失败,则将保持自旋;如果获取锁成功,就将跳出循环,开始业务逻辑。

图片

有了自适应自旋锁,在程序运行和性能监控信息的不断完善的情况下,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。对于 Synchronized 关键字来说,它的自旋锁更加地“智能”,并且 Synchronized 中的自旋锁也是自适应自旋锁。

自旋锁的优点在于它避免了一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以在一定程度上避免线程挂起和恢复时,所造成的性能开销。但是,如果长时间自旋还获取不到锁,那么也会造成一定的资源浪费,所以我们通常会给自旋设置一个固定的值来避免一直自旋带来的性能开销。

锁的优化机制

接下来,我们一起来看一下锁的优化机制,其中会着重介绍到锁膨胀、锁消除以及锁粗化的原理和实际操作。从中你能看到 Synchronized 在几种锁之间的状态变化、用来加速程序运行的锁消除操作,以及提升程序执行效率的锁粗化操作。

锁膨胀

锁膨胀是指 Synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,也就是我们上一节提到的锁升级。

图片

在 JDK 1.6 之前,Synchronized 是重量级锁,在释放和获取锁时会从用户态转换成内核态,这个时候转换的效率是比较低的。JDK 从 1.6 开始,就引入了锁膨胀机制,Synchronized 的状态就多了无锁、偏向锁以及轻量级锁。这个时候在进行并发操作时,大部分的场景就不需要从用户态转换到内核态了,也因此 Synchronized 的性能得到了大幅度的提升。

锁消除

锁消除是指在某些情况下,JVM 虚拟机如果检测不到某段代码被共享或竞争的可能性,那么就会将这段代码所属的同步锁消除掉,从而达到提高程序性能的目的。

锁消除的依据是逃逸分析的数据支持,就像 StringBuffer 的 append() 方法,或 Vector 的 add() 方法,在很多情况下都是可以进行锁消除的。我们来看下面这个代码示例:

public String method() {
    StringBuffer sb = new StringBuffer();
    for (int i0; i < 10; i++) {
        sb.append("i:" + i);
    }
    return sb.toString();
}

以上代码经过编译之后的字节码如下:

图片

从上述字节码结果可以看出,之前我们写的线程安全的、加锁的 StringBuffer 对象,在生成字节码之后就被替换成了线程不安全的、不加锁的 StringBuilder 对象了。原因是 StringBuffer 的变量属于一个局部变量,不会从该方法中逃逸出去,此时我们就可以使用锁消除来加速程序的运行。

锁粗化

锁粗化指的是,将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。我们知道缩小锁的范围后,在锁竞争时,等待获取锁的线程可以更早地获取锁,提高程序的运行效率,这系列的操作,我们称之为锁“细化”。

锁细化的观点在大多数情况下都是成立的,但是一系列连续加锁和解锁的操作,也会导致不必要的性能开销,从而影响程序的执行效率,所以我们才需要进行锁粗化的操作。那么锁粗化是如何提高性能的呢?我们来看下面的例子:

public String test() {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 10; i++) {
        // 加锁操作
        // ...
        sb.append("i:" + i);
        // 伪代码:解锁操作
        // ...
    }
    return sb.toString();
}

这里我们不考虑编译器优化的情况。我们可以看到每次 for 循环都需要进行加锁和释放锁的操作,性能是很低的;但如果我们直接在 for 循环的外层加一把锁,那么对于同一个对象操作这段代码的性能就会提高很多,如下代码所示:

public String test() {
    StringBuilder sb = new StringBuilder();
    // 加锁操作
    // ...
    for (int i = 0; i < 10; i++) {
        sb.append("i:" + i);
    }
    // 解锁操作
    // ...
    return sb.toString();
}

锁粗化的作用:如果检测到同一个对象执行了连续的加锁和解锁的操作,则会将这一系列操作合并成一个更大的锁,从而提升程序的执行效率。其表现为,分散在不同地方的 Synchronized 语句块会根据代码逻辑自动合并。JVM 会根据 Synchronized 加锁解锁的总时间开销来自行决定合并 Synchronized 语句块的时机。

总结

今天我们首先介绍了一下用户态和内核态的内容。通常我们的应用代码都是用户态的,会和系统的内核交互,这样做的好处是保证系统权限的安全。之后,我们介绍了 JVM 底层对锁的几种优化方式,其中自旋可能是大家最为熟悉的操作。但要注意的是,在很多 CAS 的场景中需要我们手动地通过编码的方式完成自旋操作,而本文提到的自旋是 JVM 底层帮我们做的事情,这是大家要注意区分的地方。

从 JDK1.6 开始,JVM 帮我们实现的自旋,变得很智能,可以自动选择自旋的次数。此外 JVM 可以根据代码的实际运行情况自动地对锁进行升级,这是另一个体现 JVM 对锁实现智能控制的地方。锁膨胀是 Synchronized 的核心,Synchronized 通过偏向锁转化到轻量级锁再转化到重量级锁来完成膨胀过程;而锁的消除和粗化更像是一对相关的操作,消除是让不必要的 Synchronized 语句块消失,而粗化是让多个小的 Synchronized 合并成一个大的 Synchronized 语句块。