Java 技术
#JVM#安全点#JIT#低延迟#HotSpot

JVM 线程局部握手:无全局安全点的线程协作与 JIT 去优化

本文解读 JDK 10 引入的线程局部握手机制,说明它如何在不触发全局安全点的前提下对单个线程执行 JIT 去优化、偏向锁撤销等操作,并通过与全局安全点的对比和诊断参数帮助读者理解其适用场景与局限。

从一次停顿说起

假设你负责一个低延迟交易服务,订单处理线程在高峰期以微秒级延迟运行。某天你发现,每隔一段时间,所有线程都会同时停顿几十毫秒,而这段时间里既没有发生 GC,也没有明显的锁竞争。打开安全点日志后,你看到 no vm operation 字样,才知道是 JVM 的定时安全点触发了全局停顿。更糟糕的是,一次简单的偏向锁撤销,本应只影响一个线程,却因为必须进入全局安全点,导致所有线程都被迫停下。

这类问题的根源在于,传统 JVM 的很多操作(如 JIT 去优化、偏向锁撤销、栈回溯)都依赖全局安全点(Global Safepoint):所有 Java 线程必须同时到达安全位置,才能执行操作。但很多操作其实只关心某一个线程,让所有线程一起停下并不必要。JDK 10 引入的线程局部握手(Thread-Local Handshake,JEP 312)正是为了解决这个问题:它允许 JVM 在不需要全局安全点的情况下,对单个线程执行回调,例如 JIT 去优化。

全局安全点:为什么需要,又为何昂贵

安全点(Safepoint)是 JVM 中一个特殊位置,线程执行到此处时,其运行状态(寄存器、栈、对象引用等)是完整且一致的,其他线程可以安全地检查或修改这些状态。JVM 在方法调用、循环回边、异常跳转等位置插入安全点检查。当需要执行 GC、类重定义、偏向锁撤销等操作时,VM 线程会请求所有线程进入安全点,这个过程称为全局安全点同步。

全局安全点的开销在于,它要求所有线程同时停下。如果某个线程正在执行一个长时间运行的循环,且该循环没有被插入安全点(例如 int 索引的可数循环),其他线程就必须等待它。得物技术的文章给出了一个典型例子:主线程调用 Thread.sleep(1000),但实际睡眠了 3 秒多,原因就是两个子线程在可数循环中无法进入安全点,导致主线程在安全点同步中被阻塞。

此外,全局安全点还会放大操作的影响范围。例如,偏向锁撤销只需要停止持有锁的那个线程,但在全局安全点机制下,所有线程都必须配合。对于低延迟应用,这种不必要的全局停顿是不可接受的。

线程局部握手的核心思想

线程局部握手(Thread-Local Handshake)提供了一种新的协作方式:JVM 可以指定一个回调,让它在某个线程(或一组线程)处于安全点安全状态时执行,而其他线程不受影响。这里的“安全点安全状态”是指线程当前的状态允许 JVM 安全地检查其栈和寄存器,而不需要线程完全停止。

与全局安全点不同,握手操作是异步的:目标线程在到达安全点安全状态后执行回调,执行完毕后立即继续运行,不需要等待其他线程。VM 线程负责协调整个握手过程,通过一个 VM 操作来防止全局安全点在握手期间发生。

JEP 312 指出,握手操作可以由线程自身执行,也可以由 VM 线程在保持线程阻塞的状态下执行。如果目标线程正在运行,JVM 可以只对这一个线程发起握手。初始实现中,同一时间最多只能有一个握手操作在途,但该操作可以涉及任意子集的线程。

轮询机制:从全局页到每线程指针

要理解线程局部握手的实现,需要先了解 JIT 编译代码中的安全点轮询。在传统全局安全点中,JIT 编译的代码会在方法返回和循环回边处插入一条 testl 指令,读取一个全局轮询页(Polling Page)。正常情况下该页是可读的,这条指令几乎无开销;当需要进入安全点时,VM 线程会将该页设为不可读,线程执行到该指令时触发段错误(SIGSEGV),JVM 的信号处理器捕获后,将线程引导至安全点阻塞。

线程局部握手修改了这种机制。JEP 312 描述,JVM 维护两个轮询页:一个始终被保护(不可读),另一个始终未保护(可读)。每个线程有一个指针,指向当前应该使用的轮询页。当需要让某个线程停顿时,VM 线程更新该线程的指针,使其指向被保护的页面。这样,只有目标线程的下一次轮询会触发信号,其他线程不受影响。

这种设计避免了全局安全点中“信号风暴”的问题:在全局安全点中,一旦轮询页被设为不可读,所有线程都会触发信号;而在线程局部握手中,只有目标线程会被捕获,其他线程的轮询页仍然可读,可以继续执行。

线程局部握手的执行流程

下面通过一个 JIT 去优化的场景,展示线程局部握手的完整流程。假设 C2 编译器发现某个方法的优化假设不成立(例如类层次结构变化导致去虚拟化失效),需要让正在执行该方法编译代码的线程去优化(即回退到解释执行)。

flowchart TD
    A[JIT 编译器检测到优化失效] --> B[VM 线程发起线程局部握手]
    B --> C[更新目标线程的轮询页指针]
    C --> D[目标线程执行轮询指令时触发 SIGSEGV]
    D --> E[JVM 信号处理器捕获并重定向到握手桩]
    E --> F[目标线程执行回调:执行去优化操作]
    F --> G[目标线程恢复执行,其他线程不受影响]

具体步骤如下:

  1. 发起握手:JIT 编译器或运行时组件请求 VM 线程执行一个握手操作,该操作包含一个回调,用于处理去优化。
  2. 更新轮询页指针:VM 线程将目标线程的轮询页指针指向受保护的页面。
  3. 触发信号:目标线程执行到下一个安全点轮询指令时,因为访问了受保护的页面,触发 SIGSEGV 信号。
  4. 信号处理:JVM 的信号处理器识别出这是握手轮询,将线程的指令指针重定向到握手桩代码。
  5. 执行回调:握手桩代码保存现场,调用回调函数。对于去优化,回调会使当前编译代码失效,并将线程的执行流切换到解释器。
  6. 恢复执行:回调完成后,线程继续执行,而其他线程从未感知到这次操作。

值得注意的是,线程局部握手的轮询机制与全局安全点类似,但只影响目标线程。JEP 312 提到,初始实现支持 x64 和 SPARC 架构,其他平台会回退到普通安全点。

线程局部握手的应用场景

线程局部握手最初是为了优化偏向锁撤销。在传统机制中,撤销偏向锁需要进入全局安全点,因为需要检查持有锁的线程是否存活,并修改对象头。使用线程局部握手后,可以只对持有锁的线程发起握手,其他线程无需停顿。

另一个重要应用是 JIT 去优化。当编译器做出的优化假设(如类层次结构稳定)被打破时,需要让正在执行旧编译代码的线程切换到解释器。在全局安全点机制下,这会导致所有线程停顿;而线程局部握手可以只暂停受影响的线程,从而减少停顿时间。

此外,服务性查询(如获取线程栈)也可以受益。传统上,获取所有线程的栈需要全局安全点,线程数多时延迟很高。使用线程局部握手,可以逐个请求线程提供栈信息,而不必一次性停止所有线程。

线程局部握手还支持一种称为“非对称 Dekker 同步”的技术,可以消除某些内存屏障。例如,G1 垃圾收集器的写屏障中的条件卡片标记代码,原本需要内存屏障来保证可见性,通过握手可以避免这些屏障,从而优化写屏障的性能。

与传统安全点的对比

下表总结了线程局部握手与全局安全点的关键差异:

维度全局安全点线程局部握手
影响范围所有 Java 线程必须停止仅目标线程执行回调,其他线程继续运行
触发方式全局轮询页设为不可读,所有线程触发信号更新每线程指针,仅目标线程触发信号
等待时间所有线程到达安全点后操作才开始每个线程独立执行回调,无需等待其他线程
适用场景GC、类重定义等需要全局一致性的操作偏向锁撤销、JIT 去优化、栈采样等单线程操作
实现复杂度相对简单,已成熟需要每线程指针,平台相关,初始仅支持 x64 和 SPARC
兼容性所有平台不支持时回退到全局安全点

从表中可以看出,线程局部握手并不是要取代全局安全点,而是为那些不需要全局一致性的操作提供更精细的协作方式。对于 GC 等操作,全局安全点仍然是必要的。

诊断与调优:如何观察线程局部握手

JEP 312 引入了产品级参数 -XX:ThreadLocalHandshakes,默认值为 true,允许用户在不支持的平台上或出于调试目的关闭该特性。此外,JVM 还提供了诊断参数 -XX:+PrintThreadLocalHandshake,用于打印线程局部握手的相关信息,帮助开发者了解握手的频率和耗时。

在实际生产环境中,你可以通过以下方式观察线程局部握手的影响:

  • 安全点日志:使用 -XX:+PrintSafepointStatistics 可以查看全局安全点的统计信息。如果启用了线程局部握手,某些操作(如偏向锁撤销)可能不再出现在全局安全点日志中,因为它们已经改为握手方式。
  • JFR 事件:Java Flight Recorder 可以记录安全点相关的信息,但线程局部握手的具体事件可能因版本而异。建议查阅对应 JDK 版本的 JFR 事件列表。
  • 性能对比:在相同负载下,分别启用和禁用 -XX:ThreadLocalHandshakes,观察延迟分布的变化。如果应用大量使用偏向锁或频繁触发 JIT 去优化,启用后应能看到停顿减少。

需要注意的是,线程局部握手并非万能。如果操作本身需要全局一致性(如 GC 根枚举),仍然会触发全局安全点。此外,如果目标线程长时间不执行轮询指令(例如在可数循环中),握手可能延迟,但不会影响其他线程。

局限性与未来展望

线程局部握手的初始实现有一些限制:同一时间只能有一个握手操作在途,这限制了并发握手的场景;支持平台有限,其他平台回退到全局安全点。此外,握手操作本身也有成本,包括更新指针、信号处理等,对于高频小操作可能得不偿失。

JEP 312 是 ZGC 等低延迟垃圾收集器的基础之一,ZGC 利用线程局部握手来减少停顿。随着 JDK 的发展,线程局部握手在更多平台(如 AArch64)上得到支持,并扩展到更多应用场景。

对于开发者而言,理解线程局部握手有助于诊断低延迟应用中的停顿问题。当安全点日志显示频繁的全局安全点,且这些操作只涉及单个线程时,可以考虑升级到支持线程局部握手的 JDK 版本,并确保相关参数已启用。

总之,线程局部握手是 JVM 在低延迟方向上的重要机制,它通过精细化的线程协作,减少了不必要的全局停顿,为 JIT 去优化、偏向锁撤销等操作提供了更高效的方式。

资料来源

  1. JEP 312: Thread-Local Handshakes
  2. 深入浅出解析JVM中的Safepoint | 得物技术 - 腾讯云
  3. 揭秘Java世界中safepoint机制之线程进入safepoint机制解析一