JVM常用概念之安全点轮询
问题
在需要进行标记、压缩、清理时,JVM 是如何停止 Java 线程以进行STW的?
基础知识
假设您拥有像 JVM 这样的托管运行时,并且您需要偶尔停止 Java 线程以运行一些运行时代码。例如,您想要执行STW的 GC。您可以等待所有线程最终调用 JVM,例如,请求分配(通常是TLAB重新填充),或输入一些本地方法(转换为本地会捕获它),或执行其他操作。但这并不能保证会发生!如果线程当前正在某种忙循环中运行,从未执行任何特殊操作怎么办?
在大多数机器上,停止正在运行的线程其实很简单:您可以向它发送信号、强制处理器中断等,使其停止线程正在执行的操作并将控制权转移到其他地方。但是,Java 线程在任意点停止通常是不够的,特别是当您想要精确的垃圾收集时。在那里,您想知道寄存器和堆栈中有什么,以防这些值实际上是您需要处理的对象引用。或者,如果您想取消锁定,您需要获得有关线程状态和获取的锁定的精确信息。或者,如果您对方法进行反优化,您确实希望从安全位置执行此操作,而不会丢失已执行的代码部分和/或临时值。
因此,现代 JVM(如 Hotspot)实现了协作方案:线程在其生命周期的某些已知点(当其状态已知时)不时询问是否应将控制权移交给 VM。当所有线程都停止在这些已知点时,VM 即达到安全点。因此,检查安全点请求的代码片段称为安全点轮询。
实现需要满足有趣的权衡:安全点轮询几乎从不触发,因此当未触发时它们应该非常高效。
实验
源码
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class EmptyBench {
@Benchmark
public void emptyMethod() {
// This method is intentionally left blank.
}
}
您可能认为此基准测试测量的是空方法,但实际上它测量的是为基准测试服务的最小基础结构代码:计算迭代次数并等待迭代时间结束。幸运的是,这段代码相当快,因此可以借助-prof perfasm对其进行完整分析。执行结果如下:
3.60% ↗ ...a2: movzbl 0x94(%r8),%r10d ; load "isDone" field
0.63% │ ...aa: add $0x1,%rbp ; iterations++;
32.82% │ ...ae: test %eax,0x1765654c(%rip) ; global safepoint poll
58.14% │ ...b4: test %r10d,%r10d ; if !isDone, do the cycle again
╰ ...b7: je ...a2
上述结果说明,空方法被内联,其中的所有内容都消失了,只有基础设施保留了下来。
看到那个“全局安全点轮询”了吗?当需要安全点时,JVM 会启用“轮询页面”,因此任何读取该页面的尝试都会触发分段错误 (SEGV) 。当 SEGV 最终从这个安全点轮询触发时,控制权将首先传递给任何现有的 SEGV 处理程序,而 JVM 已经准备好了一个!例如,查看JVM_handle_linux_signal是如何做到这一点的。
所有这些技巧的目的是使安全点轮询尽可能便宜,因为它们需要在很多地方发生,而且它们几乎总是不会触发。因此,使用test %eax, (addr) :当未触发安全点轮询时,它没有效果。它还具有非常紧凑的编码,在 x86_64 上“仅” 6 个字节。轮询页面地址对于给定的 JVM 进程是固定的,因此该进程中 JIT 生成的代码可以使用RIP 相对寻址:它表示页面位于当前指令指针的给定偏移量处,从而无需花费宝贵的字节来编码绝对的 8 字节地址。
通常,还会有一个轮询页面一次处理所有线程,因此生成的代码不必区分当前正在运行的线程。但是,如果 VM 想要停止单个线程怎么办? JEP-312:“线程本地握手”回答了这个问题。它为 VM 提供了触发单个线程握手轮询的功能,目前实现方法是为每个线程分配单独的轮询页面,然后轮询指令从线程本地存储中读取该页面地址。
这纯粹是运行时的考虑,因此可以使用-XX:-ThreadLocalHandshakes禁用它,然后生成的代码将与 8u191 中的代码相同。这解释了为什么这个基准测试在 8 和 11 上的表现不同(让我们立即在-prof perfnorm下运行它):
Benchmark Mode Cnt Score Error Units
# 8u191
EmptyBench.test avgt 15 0.383 ± 0.007 ns/op
EmptyBench.test:CPI avgt 3 0.203 ± 0.014 #/op
EmptyBench.test:L1-dcache-load-misses avgt 3 ≈ 10⁻⁴ #/op
EmptyBench.test:L1-dcache-loads avgt 3 2.009 ± 0.291 #/op
EmptyBench.test:cycles avgt 3 1.021 ± 0.193 #/op
EmptyBench.test:instructions avgt 3 5.024 ± 0.229 #/op
# 11.0.1
EmptyBench.test avgt 15 0.590 ± 0.023 ns/op ; +0.2 ns
EmptyBench.test:CPI avgt 3 0.260 ± 0.173 #/op
EmptyBench.test:L1-dcache-loads avgt 3 3.015 ± 0.120 #/op ; +1 load
EmptyBench.test:L1-dcache-load-misses avgt 3 ≈ 10⁻⁴ #/op
EmptyBench.test:cycles avgt 3 1.570 ± 0.248 #/op ; +0.5 cycles
EmptyBench.test:instructions avgt 3 6.032 ± 0.197 #/op ; +1 instruction
# 11.0.1, -XX:-ThreadLocalHandshakes
EmptyBench.test avgt 15 0.385 ± 0.007 ns/op
EmptyBench.test:CPI avgt 3 0.205 ± 0.027 #/op
EmptyBench.test:L1-dcache-loads avgt 3 2.012 ± 0.122 #/op
EmptyBench.test:L1-dcache-load-misses avgt 3 ≈ 10⁻⁴ #/op
EmptyBench.test:cycles avgt 3 1.030 ± 0.079 #/op
EmptyBench.test:instructions avgt 3 5.031 ± 0.299 #/op
因此,线程本地握手会增加另一个 L1 命中负载,这大约需要半个周期。这也为我们估算安全点轮询本身的成本提供了一些依据:它本身就是 L1 命中负载,可能需要另外半个周期。
总结
安全点和握手轮询是托管运行时实现中一些有趣的细节。它们经常出现在生成的代码中的热路径上,有时会影响性能,尤其是在紧密循环中。然而,它们的存在对于运行时实现精确垃圾回收、锁定优化、去优化等重要功能是必要的。