【译】JVM Anatomy Quark #25: 隐式空检查

原文地址:JVM Anatomy Quark #25: Implicit Null Checks

问题

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 中。

我们可以看一下它是如何工作的吗?

实践

请看这个巧妙而简单的 JMH 测试用例:

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 次整数加法。

具体来看,这个测试用例有几次很巧妙的地方:

  1. 这个测试参数化了 blowup,当 blowup = true 时在第三次迭代会暴露 null 对象给 test() 方法。
  2. 这个测试以不安全的方式使用循环。通过 LoopUnrollLimit 设置 Hotspot 不展开循环,这样可以消除这个问题。
  3. 这个测试一次又一次地访问同一个对象。聪明的优化器可以将 h 字段的加载提升到循环外面,然后进行积极地优化。通过将 h 声明为 volatile 可以消除这个问题:除非我们面对的是一个像上帝一样聪明的优化器,否则这足以打破提升优化。
  4. 这个测试使用编译器提示来打破 test 的内联。严格来说这不是该测试必需的,但是这是安全措施。原因如下:该测试依赖 test 的分析信息,更聪明的编译器可以使用 caller-callee profiles 来区分不同调用来源(setup() 或者测试用例自身的循环)的分析信息。

在最近的 8u232[1] 版本中测试结果如下:

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

所以我们需要寻找一些额外的 branches。注意测试的循环有100次迭代,所以每次迭代都有额外的分支?另外也多了 200 条额外的指令,感觉 "branch" 就是 x86_64 的 testjcc 指令。

基于以上的假设,我们通过 -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压缩引用解码,对 h.x 的访问,以及隐式空检查。我们没有发现对 h 进行空检查的额外指令。[2]

implicit exception: dispatches to 0x…​03e 这行是 VM 输出的一部分,表示 VM 知道 SEGV 异常来自空检查失败的指令。然后 JVM 信号处理程序执行它的请求并将控制转移到 0x…​03e,这里将会抛出异常。[3]

当然,如果在执行过程中经常遇到 null,那么每次都经过信号处理程序会很慢。对于当前的情况,我们可以说抛出异常也很慢,但是这里有两个逻辑问题。第一,即使异常有时候很慢,但是如果可以避免的话,那么没有理由让它更慢。第二,我们想要使用相同的机制处理用户编写的空检查,但是用户不会想要简单的 if (h == null) { …​ } else { …​ } 由于 h 的空检查而导致性能急剧下降。因此我们希望只有在 null 的频率比较低的情况下使用隐式空检查。

幸运的是,JVM 可以基于运行时 profile编译代码。也就是,当 JIT 编译器决定是否生成隐式空检查时,它可以查看分析信息,看看对象是否曾经为 null。此外,即使 JIT 编译器已经生成了隐式空检查,然后在关于 null 的优化假设违反后也可以重新编译代码。blowup = true 的情况通过在代码中赋值为 null 违反了优化假设。结果 JVM 重新编译代码为:[4]

            ...
 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
            ...

砰!现在生成的代码是显式空检查了![5]没有用户的干预,隐式空检查转化为了显式。

你在完整的测试日志中可以实时看到相关信息:

# 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 注意到变化进行重新编译。[6]这为空检查提供了基本平稳的性能模型。

其它琐事: Shenandoah GC

总的来说,这是一个非常有用的技术,除此之外还有其它使用场景。例如 Shenandoah GCload-reference-barrier 需要检查对象是否在 collection set 中。如果不在,屏障可以跳过,因为当前对象不需要移动。

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
      ...

"collection set" 比特是 region 的属性,所以存在一个全局的 "cset bitmap",用于识别哪个 region 在 collection set 中。为了识别对象是否在 collection set 中,将对象的地址整除 region 的大小,然后检查对应的 region bitmap。需要注意的是堆不必以零地址开始。所以整除结果并不是实际的 region 索引。相反,它给你的是带偏移的 region 索引:有一个偏移常量,这取决于实际的堆基址。实际实现中,我们可以使用偏移后的索引查看 cset bitmap!

这使我们在 region bitmap 中可以命中每个合法对象地址,除了 null,异常地址就访问到 bitmap 之外了。然而我们知道 null 将命中哪个地址,所以可以在那里分配并提交零页,然后这个检查可以假装 null 的答案是 0 或 "false"。这不需要使用单独的运行时检查来处理 null,也不是涉及任何信号处理机制。

结论

虚拟内存为处理内存访问提供了很多漂亮的技巧。隐式空检查利用了大部分空检查不会触发的事实,并在触发的时候让虚拟内存子系统通知我们。带有重新编译功能的托管运行时可以利用 profile 生成正确空检查代码,并且在空检查假设违反之后动态重新生成代码。最后,以上这些对用户来说或多或少是透明的,并且提供了显著的性能收益。


1. 我们使用 8u 版本 —— 而不是哪些新版本 JDK —— 的目的是展示这个优化不是很新 ;)

2. 在更复杂的情况中,简化的控制流和不使用显式空检查的空闲寄存器/标志可以提高代码质量。

3. 在这段代码中,它实际上进入了所谓的 ”uncommon trap“,之后我们会讨论这个主题。简单来说,这是向运行时发出通知,告诉它某个不会执行的分支被执行了,并要求 JVM 基于这些信息重新编译方法。

4. 虽然这个测试用例展示了动态重编译,但是如果我们在测试代码执行前赋值 null,更新初始的 profile,那么也会得到相同的效果。

5. 0x…​bfe: mov %r12d,0x10(%rsi) 是一个 low-level WTF.

6. -prof perfasm 过滤了预热阶段发生的事情,这就是我们没有看到反编译的原因。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,744评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,505评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,105评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,242评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,269评论 6 389
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,215评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,096评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,939评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,354评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,573评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,745评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,448评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,048评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,683评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,838评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,776评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,652评论 2 354

推荐阅读更多精彩内容