JVM 解剖公园(15): 即时常量

Posted ImportNew

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM 解剖公园(15): 即时常量相关的知识,希望对你有一定的参考价值。

(给ImportNew加星标,提高Java技能)

编译:ImportNew/唐尤华

shipilev.net/jvm/anatomy-quarks/15-just-in-time-constants/


1. 写在前面


“JVM 解剖公园”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。


Aleksey Shipilёv,JVM 性能极客

推特 @shipilev

问题、评论、建议发送到 aleksey@shipilev.net"">aleksey@shipilev.net


2. 问题


在程序中使用常量可以实现优化,那么 JVM 在背后究竟做了哪些工作?


3. 理论


基于常量优化是最理想的:运行时没有开销,所有工作在编译时完成。常量是什么?普通字段的值经常变化,它们不是常量。那么 final 字段呢?它们的值保持不变。但是,由于对象状态包含实例字段,final 字段值取决于对象的标识符:


class M {
final int x;
M(int x) { this.x = x; }
}
M m1 = new M(1337);
M m2 = new M(8080);
int work(M m) {
return m.x; // 编译后的值是 1337 还是 8080?
}


译注:原文 void work(M m) 会报告编译错误,改为 int work(M m)


如果不知道参数标识符,就无法确认 work 方法编译后的结果 <sup>(1)</sup>。唯一可以确定的是 static final 字段:加上了 final 标识,字段是不可变的;由于是类属性,而非对象持有,可以明确属性持有者的标识符。


(1) 基于流式调用也在此列,比如内联调用 work(new M(4242))


能不能在实验结果中观察到这点?


4. 实验


看看下面这个 JMH 基准测试:


@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 JustInTimeConstants {
static final long x_static_final = Long.getLong("divisor", 1000);
static long x_static = Long.getLong("divisor", 1000);
final long x_inst_final = Long.getLong("divisor", 1000);
long x_inst = Long.getLong("divisor", 1000);
@Benchmark public long _static_final() { return 1000 / x_static_final; }
@Benchmark public long _static() { return 1000 / x_static; }
@Benchmark public long _inst_final() { return 1000 / x_inst_final; }
@Benchmark public long _inst() { return 1000 / x_inst; }
}


上面的测试设计精巧,编译器可以利用除数为常数这一事实对除法进行优化。运行测试,可以看到以下结果:


Benchmark                          Mode  Cnt  Score   Error  Units
JustInTimeConstants._inst avgt 15 9.670 ± 0.014 ns/op
JustInTimeConstants._inst_final avgt 15 9.690 ± 0.036 ns/op
JustInTimeConstants._static avgt 15 9.705 ± 0.015 ns/op
JustInTimeConstants._static_final avgt 15 1.899 ± 0.001 ns/op


使用 -prof perfasm 对基准测试中热点循环进行研究,可以了解一些实现细节并理解为什么有些测试运行得更快。


_inst _inst_final 的运行结果并不令人惊讶:它们执行时会读取字段值作为除数。大部分运算都用来实际做整除:


# JustInTimeConstants._inst / _inst_final hottest loop
0.21% ↗ mov 0x40(%rsp),%r10
0.02% │ mov 0x18(%r10),%r10 ; 获取字段 x_inst / x_inst_final
| ...
0.13% │ idiv %r10 ; ldiv
76.59% 95.38% │ mov 0x38(%rsp),%rsi ; 准备处理字段值 (JMH 框架)
0.40% │ mov %rax,%rdx
0.10% │ callq CONSUME
| ...
1.51% │ test %r11d,%r11d ; 再次调用 @Benchmark
╰ je BACK


_static 的情况更有趣:它从static 字段实际存储的本地 class 镜像中读取 static 字段。static 字段是静态解析的,因此 JVM 运行时能够知道实际处理的是哪个类。虽然可以为镜像做内联指针,通过预先定义的偏移量访问字段,但是不知道实际值。可能有人在生成后的代码对其进行了修改。我们仍然执行相同的整数除法:


# JustInTimeConstants._static hottest loop
0.04% ↗ movabs $0x7826385f0,%r10 ; JustInTimeConstants.class 的本地镜像
0.02% │ mov 0x70(%r10),%r10 ; 获取 static x_static
| ...
0.02% │ idiv %r10 ;*ldiv
72.78% 95.51% | mov 0x38(%rsp),%rsi ; 准备处理字段值 (JMH 框架)
0.38% │ mov %rax,%rdx
0.04% 0.06% │ data16 xchg %ax,%ax
0.02% │ callq CONSUME
| ...
0.13% │ test %r11d,%r11d ; 再次调用 @Benchmark


_static_final 的情况最有趣:JIT 编译器能够确切知道正在处理的值,因此可以积极对其进行优化。这里可以看到,循环计算时重用了预先计算好的值 “1”,即 “1000 / 1000” <sup>(2)</sup>:


(2) 使用 int 相比 long 情况会生成 mov $0x1, %edx。但是我太懒了,这里就不再更新这种情况了。

# JustInTimeConstants._static_final hottest loop
1.36% 1.40% ↗ mov %r8,(%rsp)
7.73% 7.40% │ mov 0x8(%rsp),%rdx ; <--- 这里存储 "long" 常量 "1"
0.45% 0.51% │ mov 0x38(%rsp),%rsi ; 准备处理字段值 (JMH 框架)
3.59% 3.24% │ nop
1.44% 0.54% │ callq CONSUME
| ...
3.46% 2.37% │ test %r10d,%r10d ; 再次调用 @Benchmark
╰ je BACK


通过上述分析,从编译器角度解释了 static final 常量的性能表现。


5. 观察


注意:在上面的例子中,由于因为该字段使用运行时中的值初始化,因此像 javac 这样的字节码编译器并不知道 static final 字段的具体值。JIT 编译时,类已经成功地初始化,可以随时使用字段值。这是真正的“实时常数”。这样可以开发出高效且可在运行时调整的代码:整个过程可以看作基于预处理断言的一种替代<sup>(3)</sup>。使用 C++ 时通常会忘记这种技巧,因为它是提前编译的。除非具备一定的创造性,才可能让关键代码使用运行时选项<sup>(4)</sup>。


(3) 这种方法并不会完全奏效。无论 dead code 有多少,默认的内联试探法都会按照字节码长度方式计算方法大小。类似增量内联这样的方法能够对这种情况有所改善。


(4) 这种方式几乎不可避免地会引起模板或元编程混乱。我们都喜欢写代码,但讨厌调试。


这里最重要的部分是解析器与分层编译。类初始化代码通常只执行一次。但更重要的是类初始化过程中延迟加载部分,第一次访问字段时加载类并执行初始化,解释器或基线 JIT 编译器(例如 Hotspot 中的 C1)会开始工作。当 JIT 优化编译器(例如 C2)开始对相同函数进行优化时,重新编译的方法通常会进行完整的初始化,并且 static final 字段的值全部确认。


推荐阅读

(点击标题可跳转阅读)





看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

以上是关于JVM 解剖公园(15): 即时常量的主要内容,如果未能解决你的问题,请参考以下文章

JVM 解剖公园(18): 标量替换

JVM 解剖公园:JNI 临界区与 GC Locker

为什么 JVM x86 生成的机器代码有 XMM 寄存器?

在 JVM 中使用透明巨型页

JVM内存模型

JVM 方法区