JVM常用概念之隐式空值检查
问题
Java 规范规定,当我们访问null对象字段时,会抛出NullPointerException 。这是否意味着JVM必须始终使用运行时检查来判断是否为空吗?
基础知识
JIT编译器可以知道对象不为null并省略运行时空检查。
static class Holder { int x; }
static final Holder H = new Holder();
int m() {
return H.x; // H is known to be not null at JIT compilation time
}
如果这种方法不起作用,例如当无法自动推断出空值时,编译器还可以使用数据流分析来删除在对对象进行第一次空值检查后进行的后续空值检查。例如:
int m(Holder h) {
int x1 = h.x; // null-check here
int x2 = h.x; // no need to null-check here again
return x1 + x2;
}
这些优化非常有用,但相当无趣,并且它们不能解决所有其他情况下对空检查的需求。
还有一种更聪明的方法可以做到这一点:让用户代码访问对象而不进行显式检查!大多数情况下,不会发生任何不好的事情,因为大多数对象访问都不会看到空对象。但是,当null访问确实发生时,我们仍然需要处理特殊情况。当这种情况发生时,JVM可以拦截生成的 SIGSEGV(“信号:分段错误”),查看该信号的返回地址,并找出在生成的代码中进行了该访问的位置。一旦弄清楚了这一点,它就可以知道在哪里调度控制来处理这种情况——在大多数情况下,抛出NullPointerException或分支到某个地方。
这种机制在 Hotspot 中被称为“隐式空检查” 。它最近以类似的名称添加到 LLVM 中,以满足相同的用例。
实验
源码
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(value = 3, jvmArgsAppend = {"-XX:LoopUnrollLimit=1"})
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class ImplicitNP {
@Param({"false", "true"})
boolean blowup;
volatile Holder h;
int itCnt;
@Setup
public void setup() {
h = null;
if (blowup && ++itCnt == 3) { // blow it up on 3-rd iteration
for (int c = 0; c < 10000; c++) {
try {
test();
} catch (NullPointerException npe) {
// swallow
}
}
System.out.print("Boom! ");
}
h = new Holder();
}
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
@Benchmark
public int test() {
int sum = 0;
for (int c = 0; c < 100; c++) {
sum += h.x;
}
return sum;
}
static class Holder {
int x;
}
}
从表面上看,这个基准测试很简单:它执行 100 次整数加法。
从方法论角度来看,这个基准测试在几个方面很巧妙:
- 它由blowup标志参数化,当blowup = true时,将在第 3 次迭代中将null对象暴露给test()方法,否则保持不变。
- 它以基准不安全的方式使用循环。通过要求 Hotspot 不要使用LoopUnrollLimit展开循环,可以缓解此问题。
- 它一遍又一遍地访问同一个对象。一个聪明的优化器能够将h的负载提升到循环之外,然后积极优化。通过将h声明为volatile可以缓解这种情况:除非我们面对的是像超乎我们想象聪明的优化器,否则这足以破坏提升。
- 它使用编译器提示来中断test的内联。严格来说,这不是此基准测试所必需的,但它是一种安全措施。理由如下:测试依赖于test的分析信息,而更智能的编译器可以使用调用者-被调用者配置文件将配置文件拆分为从setup()调用的版本和基准测试循环本身。
执行结果
基于JDK-8-8u232运行的结果如下:
Benchmark (blowup) Mode Cnt Score Error Units
ImplicitNP.test false avgt 15 40.417 ± 0.030 ns/op
ImplicitNP.test true avgt 15 63.187 ± 0.156 ns/op
依据上述运行结果可知,绝对分数的高低在这里并不重要,重要的是其中一种情况比另一种情况快得多。blowup = false情况在这里明显更快。如果我们深入研究原因,我们可能会首先借助-prof perfnorm来描述它,它可以显示两个测试的低级机器计数器:
Benchmark (blowup) Mode Cnt Score Error Units
ImplicitNP.test false avgt 15 40.484 ± 0.090 ns/op
ImplicitNP.test:L1-dcache-loads false avgt 3 206.606 ± 24.336 #/op
ImplicitNP.test:L1-dcache-stores false avgt 3 5.861 ± 0.426 #/op
ImplicitNP.test:branches false avgt 3 102.972 ± 13.679 #/op
ImplicitNP.test:cycles false avgt 3 141.252 ± 22.330 #/op
ImplicitNP.test:instructions false avgt 3 521.998 ± 87.292 #/op
ImplicitNP.test true avgt 15 63.254 ± 0.047 ns/op
ImplicitNP.test:L1-dcache-loads true avgt 3 206.154 ± 15.231 #/op
ImplicitNP.test:L1-dcache-stores true avgt 3 4.971 ± 0.677 #/op
ImplicitNP.test:branches true avgt 3 199.993 ± 20.805 #/op ; +100 branches
ImplicitNP.test:cycles true avgt 3 221.388 ± 13.126 #/op ; +80 cycles
ImplicitNP.test:instructions true avgt 3 714.439 ± 64.476 #/op ; +190 insns
因此,我们正在寻找一些多余的分支。请注意,我们的循环有 100 次迭代,因此每次迭代一定有多余的分支?此外,我们有大约 200 条多余的指令,这是有道理的,因为“分支”实际上是 x86_64 上的test和jcc 。
现在我们有了这个假设,让我们借助-prof perfasm来查看这两种情况下的实际热代码。高度编辑的代码片段如下。但是首先设置blowup = false。执行结果如下:
...
1.71% ↗ 0x...020: mov 0x10(%rsi),%r11d ; get field "h"
9.19% │ 0x...024: add 0xc(%r12,%r11,8),%eax ; sum += h.x
│ ; implicit exception:
│ ; dispatches to 0x...03e
59.60% │ 0x...029: inc %r10d ; increment "c" and loop
0.02% │ 0x...02c: cmp $0x64,%r10d
╰ 0x...030: jl 0x...d204020
4.57% 0x...032: add $0x10,%rsp
3.16% 0x...036: pop %rbp
3.37% 0x...037: test %eax,0x16a18fc3(%rip)
0x...03d: retq
0x...03e: mov $0xfffffff6,%esi
0x...043: callq 0x00007f8aed0453e0 ; <uncommon trap>
这里我们可以看到一个非常紧密的循环,位于0x…024处的指令结合了h的压缩引用解码、对hx的访问以及隐式的空值检查。我们无需使用任何其他指令来检查h是否为空。
implicit exception: dispatches to 0x…03e 行是 VM 输出的一部分,表示 VM 知道来自该指令的 SEGV 异常实际上未通过空值检查。然后,JVM 信号处理程序将执行其命令并将控制权分派给0x…03e ,然后后者将继续抛出异常。
当然,如果该路径上经常出现null -s,那么每次都通过信号处理程序会相当慢。对于我们当前的情况,我们可以说抛出异常仍然很麻烦,但它会遇到两个逻辑问题。首先,即使异常有时很慢,但如果可以避免,就没有理由让它们变得更慢。其次,我们希望使用相同的机制来处理用户编写的空检查,并且用户不希望他们的简单 if (h == null) { … } else { … } 分支运行速度因h的空值而急剧下降。因此,我们希望仅在实际null -s 的频率非常低时使用隐式空检查。
幸运的是,JVM 可以在知道运行时配置文件的情况下编译代码。也就是说,当 JIT 编译器决定是否发出隐式空值检查时,它可以查看配置文件并查看对象是否曾经为null 。此外,即使它确实发出了隐式空值检查,当违反了关于null频率的乐观假设时,它也可以稍后重新编译代码。blowup = true情况通过将null输入到我们的代码中而明确违反了该假设。结果,JVM 将整个内容重新编译为:
...
11.36% ↗ 0x...bd1: mov 0x10(%rsi),%r11d ; get field "h"
12.81% │ 0x...bd5: test %r11d,%r11d ; EXPLICIT NULL CHECK
0.02% ╭│ 0x...bd8: je 0x...bf4
17.23% ││ 0x...bda: add 0xc(%r12,%r11,8),%eax ; sum += h.x
25.07% ││ 0x...bdf: inc %r10d ; increment "c" and loop
8.70% ││ 0x...be2: cmp $0x64,%r10d
0.02% │╰ 0x...be6: jl 0x...bd1
3.31% │ 0x...be8: add $0x10,%rsp
2.49% │ 0x...bec: pop %rbp
2.72% │ 0x...bed: test %eax,0x160e640d(%rip)
│ 0x...bf3: retq
↘ 0x...bf4: movabs $0x7821044f8,%rsi ; <preallocated NullPointerException>
0x...bfe: mov %r12d,0x10(%rsi) ; WTF
0x...c02: add $0x10,%rsp
0x...c06: pop %rbp
0x...c07: jmpq 0x00007f887d1053a0 ; throw_exception
...
此时会发现,生成的代码中现在有了显式的空值检查! 隐式空值检查变成了显式空值检查,无需用户干预。
查看完整的基准测试日志时,您可以看到:
# JMH version: 1.22
# VM version: JDK 1.8.0_232, OpenJDK 64-Bit Server VM, 25.232-b09
# VM options: -XX:LoopUnrollLimit=1
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.openjdk.ImplicitNP.test
# Parameters: (blowup = true)
# Run progress: 50.00% complete, ETA 00:00:30
# Fork: 1 of 3
Warmup Iteration 1: 40.900 ns/op
Warmup Iteration 2: 40.698 ns/op
Warmup Iteration 3: Boom! 63.157 ns/op // <--- recompilation happened here
Warmup Iteration 4: 63.158 ns/op
Warmup Iteration 5: 63.130 ns/op
Iteration 1: 63.188 ns/op
Iteration 2: 63.208 ns/op
Iteration 3: 63.128 ns/op
Iteration 4: 63.137 ns/op
Iteration 5: 63.143 ns/op
通过前两次迭代一切都很好,然后第三次迭代将null暴露给代码,JVM 注意到了这一点并重新编译。这为我们提供了或多或少平坦的空值检查性能模型。
其他情况
Shenandoah GC
总体而言,隐式空值检查是一项非常有用的技术,因此它甚至在处理原始 Java 对堆的访问之外也使用。例如, Shenandoah GC的加载引用屏障需要检查对象是否在集合集中。如果不在,屏障可以走捷径,因为当前对象不会移动。
在 x86_64 代码中:
................. LRB fastpath............................
0x...067: testb $0x1,0x20(%r15)
╭ 0x...06c: jne 0x...086
..│.............. actual heap access .....................
│↗ 0x...06e: movl $0x2a,0xc(%r9)
││ ...
..││............. LRB mid path ...........................
..││............. checking in-cset .......................
↘│ 0x...086: mov %r9,%r10
│ 0x...089: shr $0x17,%r10 ; %r10 is biased region idx
│ 0x...08d: movabs $0x7f60d00919f0,%r8 ; %r8 is biased cset bitmap
│ 0x...097: cmpb $0x0,(%r8,%r10,1) ; <--- implicit check for null here!
╰ 0x...09c: je 0x...06e
...
“集合集”位是区域的属性,因此有一个全局“cset 位图”来告诉哪些区域在收集集中。为了确定对象是否在收集集中,代码将对象地址除以区域大小,然后对照区域位图进行检查。这里需要注意的是,堆不一定从零地址开始。因此,该除法不会为您提供实际的区域索引。相反,它会为您提供有偏差的区域索引:具有恒定偏移量的东西,取决于实际的堆基数。为了补偿它,我们可以在其有偏差的偏移量处访问 cset 位图本身!
这样,我们就可以命中每个合法对象地址的区域位图,除了null之外,因为 null 会访问位图之外的内容。但是我们知道null会命中哪个地址,因此我们可以在那里分配并提交零页,然后此检查可以假装null的答案是0或“false”。而且它无需使用单独的运行时检查来处理null ,也无需涉及任何信号处理机制。
总结
虚拟内存在处理内存访问时提供了一些巧妙的技巧。隐式空值检查充分利用了大多数空值检查实际上从未触发的事实,并在触发时让虚拟内存子系统通知我们。具有重新编译功能的托管运行时为我们提供了利用配置文件对检查规则做出正确猜测的方法,甚至在违反有关空值检查频率的假设时动态重塑代码。最终,整个过程对用户来说或多或少变得不可见,同时提供显着的性能优势。