Java虚拟机是怎么实现synchronized的?
在Java虚拟机(JVM)中,synchronized关键字是实现线程同步的重要机制。它既可以用来声明一个synchronized代码块,也可以直接标记静态方法或者实例方法。
在阅读本文前,可以先看一下我之前写的深入synchronized这篇文章。
Java 编程语言的主要优势之一是其对多线程程序的内置支持。为了同步对共享对象的访问,在多个线程之间共享的对象可以被锁定。Java 提供了原语来指定临界代码区域,这些区域作用于共享对象,并且一次只能由一个线程执行。进入该区域的第一个线程会锁定共享对象。当第二个线程试图进入同一区域时,它必须等待,直到第一个线程再次解锁该对象。
在 Java HotSpot 虚拟机中,每个对象前面都有一个类指针和一个头字。头字存储标识哈希码以及用于分代垃圾回收的年龄和标记位,同时也用于实现轻量级锁方案。下图展示了头字的布局以及不同对象状态的表示。图的右侧说明了标准的锁定过程。只要对象处于解锁状态,最后两位的值为 01。当一个方法在对象上进行同步时,头字和指向该对象的指针会存储在当前栈帧内的一个锁记录中。然后,虚拟机尝试通过比较并交换操作在对象的头字中安装一个指向锁记录的指针。如果成功,当前线程随后就拥有了该锁。由于锁记录总是按字边界对齐,头字的最后两位则变为 00,表明该对象已被锁定。
如果比较并交换操作失败,是因为该对象之前已被锁定,虚拟机首先会测试头字是否指向当前线程的方法栈。在这种情况下,该线程已经拥有对象的锁,可以安全地继续执行。对于这种递归锁定的对象,锁记录会初始化为 0,而不是对象的头字。只有当两个不同的线程同时在同一个对象上进行同步时,轻量级锁才必须膨胀为重量级监视器,用于管理等待的线程。
轻量级锁比膨胀锁的开销小得多,但它们的性能会受到影响,因为在多处理器机器上,每个比较并交换操作都必须原子地执行,尽管大多数对象只由一个特定线程锁定和解锁。在 Java 6 中,通过一种所谓的无存储偏向锁技术 [罗素 06] 解决了这个缺点,该技术使用了与 [川千谷 02] 类似的概念。只有首次获取锁时才执行原子比较并交换操作,将锁定线程的 ID 安装到头字中。此时,该对象就被称为偏向于该线程。同一线程未来对该对象的锁定和解锁不需要任何原子操作或对头字的更新。甚至栈上的锁记录也保持未初始化状态,因为对于偏向对象永远不会检查它。
当一个线程在偏向于另一个线程的对象上进行同步时,必须通过使该对象看起来像是以常规方式被锁定来撤销偏向。会遍历偏向所有者的栈,根据轻量级锁方案调整与该对象关联的锁记录,并将指向其中最旧记录的指针安装在对象的头字中。此操作必须暂停所有线程。当访问对象的标识哈希码时,也会撤销偏向,因为哈希码位与线程 ID 是共享的。
明确设计为在多个线程之间共享的对象,例如生产者 / 消费者队列,不适合使用偏向锁。因此,如果某个类的实例过去频繁发生偏向撤销,那么该类的偏向锁将被禁用。这称为批量撤销。如果在偏向锁已被禁用的类的实例上调用锁定代码,它将执行标准的轻量级锁操作。该类新分配的实例将被标记为不可偏向。
一种类似的机制,称为批量重新偏向,用于优化这样的情况:一个类的对象由不同线程锁定和解锁,但从不并发进行。它会使一个类的所有实例的偏向无效,但不会禁用偏向锁。类中的一个纪元值用作时间戳,指示偏向的有效性。在对象分配时,该值会复制到头字中。然后,批量重新偏向可以有效地实现为相应类中纪元值的递增。下次该类的实例要被锁定时,代码会检测到头字中的值不同,并将对象重新偏向于当前线程。
synchronized的基本概念
synchronized关键字通过对象头中的锁标志位和Monitor来实现线程同步。当一个线程进入synchronized代码块时,它会获取对象的锁,并在退出代码块时释放锁。在JVM中,synchronized的实现与对象头、Monitor、以及操作系统的互斥锁(mutex)密切相关。
字节码层面对synchronized的实现
当声明synchronized代码块时,编译而成的字节码将包含monitorenter和monitorexit指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是synchronized关键字括号里的引用),作为所要加锁解锁的锁对象。
public void foo(Object lock) {
synchronized (lock) {
lock.hashCode();
}
}
编译后的字节码如下:
public void foo(java.lang.Object);
Code:
0: aload_1
1: dup
2: astore_2
3: monitorenter
4: aload_1
5: invokevirtual java/lang/Object.hashCode:()I
8: pop
9: aload_2
10: monitorexit
11: goto 19
14: astore_3
15: aload_2
16: monitorexit
17: aload_3
18: athrow
19: return
Exception table:
from to target type
4 11 14 any
14 17 14 any
在字节码中,monitorenter指令用于获取锁,而monitorexit指令用于释放锁。为了确保在正常执行路径和异常执行路径上都能正确释放锁,通常会有多条monitorexit指令。当用synchronized标记方法时,字节码中方法的访问标记会包含ACC_SYNCHRONIZED,表示在进入该方法时需要进行monitorenter操作,退出时需要进行monitorexit操作。
重量级锁
重量级锁是JVM中最基础的锁实现。在这种状态下,JVM会阻塞加锁失败的线程,并在目标锁被释放时唤醒这些线程。线程的阻塞和唤醒依赖于操作系统的互斥锁(如POSIX系统中的pthread_mutex),这些操作涉及系统调用,需要从用户态切换到内核态,开销较大。
为了减少线程阻塞和唤醒的开销,JVM引入了自旋锁。自旋锁允许线程在等待锁时主动循环(空转)并轮询锁是否被释放,而不是立即进入阻塞状态。如果锁很快被释放,自旋锁可以避免线程频繁地进行阻塞和唤醒操作。JVM中的自旋锁是自适应的,会根据历史的自旋等待成功率动态调整自旋的时间。
轻量级锁
轻量级锁是JVM针对无锁竞争场景的优化。它基于 Compare-And-Swap(CAS)原子操作实现,避免了线程阻塞和唤醒的开销。轻量级锁的实现依赖于对象头中的标记字段(mark word)。
当线程尝试获取轻量级锁时,会执行以下步骤:
-
检查锁对象的标记字段是否为无锁状态(01),如果是,则通过CAS操作将标记字段替换为当前线程的锁记录地址。如果替换成功,线程获得锁。
-
如果标记字段不是无锁状态,可能是其他线程持有锁或者锁已经被膨胀为重量级锁。如果锁记录中的线程地址与当前线程匹配,说明当前线程已经持有该锁,允许重入。否则,锁会被膨胀为重量级锁,当前线程进入阻塞状态。
轻量级锁的释放过程同样依赖CAS操作,将标记字段恢复为无锁状态。如果释放成功,锁被释放;如果失败,说明锁已被膨胀为重量级锁,进入重量级锁的释放流程。
偏向锁
偏向锁是JVM对锁进一步优化的结果,适用于锁只被同一个线程多次获取的场景。偏向锁的原理是在线程第一次获取锁时,将线程ID记录在对象头的标记字段中,并将锁标志位设置为偏向锁(101)。此后,该线程再次获取锁时,无需进行CAS操作,直接验证线程ID是否匹配即可。
偏向锁的撤销发生在其他线程尝试获取已被偏向的锁时。撤销偏向锁需要将锁膨胀为重量级锁,并唤醒被阻塞的线程。为了优化偏向锁的撤销过程,JVM引入了epoch机制。每个类维护一个epoch值,表示偏向锁的“代数”。当频繁撤销偏向锁时,JVM会增加类的epoch值,使得后续的偏向锁设置需要匹配新的epoch值。如果某个类的偏向锁撤销次数超过一定阈值,JVM会认为该类不适合使用偏向锁,从而禁用偏向锁。
锁的升级与降级
JVM中的锁可以从偏向锁升级为轻量级锁,再升级为重量级锁,但锁的降级(如从重量级锁降级为轻量级锁)在Java 8及之前版本中并不直接支持。
锁的升级过程如下:
-
偏向锁:如果锁对象始终由同一个线程获取,JVM会使用偏向锁,减少同步开销。
-
轻量级锁:当其他线程尝试获取锁时,偏向锁会被撤销,升级为轻量级锁。轻量级锁使用CAS操作来确保线程安全。
-
重量级锁:如果轻量级锁竞争激烈,JVM会将锁膨胀为重量级锁,利用操作系统的互斥锁来确保线程同步。
锁的升级是单向的,一旦锁被升级为重量级锁,就不会再降级为轻量级锁或偏向锁。这种设计是为了简化锁的管理逻辑,避免频繁的锁状态转换带来的开销。
synchronized的性能优化
(一)锁消除
锁消除是JVM的一种优化技术,它通过逃逸分析确定synchronized操作的锁对象不会被多个线程共享,从而安全地移除synchronized操作。例如,如果一个synchronized代码块中的对象始终在一个线程内使用,JVM可以消除对该代码块的加锁和解锁操作,提高性能。
(二)锁粗化
锁粗化是JVM的另一种优化技术,它将多个连续的synchronized操作合并为一个更大的synchronized操作。这可以减少锁的获取和释放次数,降低同步开销。例如,如果一个循环中多次调用同一把锁的synchronized方法,JVM可能会将整个循环作为一个synchronized块来处理。
(三)适应性自旋
适应性自旋根据线程过去获取锁的等待时间动态调整自旋的次数。如果线程在过去成功通过自旋获取了锁,JVM会增加当前线程的自旋次数;反之则减少自旋次数。这种机制可以有效平衡自旋和阻塞的开销。
实践与案例分析
(一)验证Object.hashCode()对偏向锁的影响
根据网上的一些讨论,调用Object的hashCode()方法可能会关闭对象的偏向锁。我们可以通过实验来验证这一点。实验代码如下:
public class SynchronizedTest {
static Lock lock = new Lock();
static int counter = 0;
public static void foo() {
synchronized (lock) {
counter++;
}
}
public static void main(String[] args) throws InterruptedException {
// lock.hashCode(); // Step 2
// System.identityHashCode(lock); // Step 4
for (int i = 0; i < 1_000_000; i++) {
foo();
}
}
static class Lock {
// @Override public int hashCode() { return 0; } // Step 3
}
}
运行参数:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintBiasedLockingStatistics -XX:TieredStopAtLevel=1
通过对比开启和关闭偏向锁时的输出结果,观察调用hashCode()方法是否会影响偏向锁的使用。实验结果可能会因JVM版本和具体实现而有所不同,但可以为我们理解偏向锁的行为提供参考。
(二)偏向锁的适用场景与性能测试
为了测试偏向锁在单线程场景下的性能优势,可以编写一个简单的测试程序:
public class BiasedLockingTest {
private final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
BiasedLockingTest test = new BiasedLockingTest();
// 开启偏向锁
System.setProperty("java.lang.Thread", "true");
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
test.synchedMethod();
}
long end = System.currentTimeMillis();
System.out.println("Biased Locking Time: " + (end - start) + " ms");
// 关闭偏向锁
System.setProperty("java.lang.Thread", "false");
start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
test.synchedMethod();
}
end = System.currentTimeMillis();
System.out.println("No Biased Locking Time: " + (end - start) + " ms");
}
private void synchedMethod() {
synchronized (lock) {
// 空同步块
}
}
}
运行结果可能会显示,偏向锁在单线程场景下确实能带来性能提升,因为减少了同步操作的开销。
总结
JVM通过对象头、Monitor、以及操作系统的互斥锁等机制实现了synchronized关键字的功能。从重量级锁到轻量级锁,再到偏向锁,JVM不断优化同步操作的性能,以适应不同的应用场景。开发者在使用synchronized时,应根据实际的线程竞争情况选择合适的同步策略,同时了解JVM的锁优化机制,以编写出高效、线程安全的代码。