Java里mode能不能做标识符_JVM 解剖公园(15): 即时常量

cgxi5N.jpg

(给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 方法编译后的结果 (1)。唯一可以确定的是 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” (2)

(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 ;

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 编译时,类已经成功地初始化,可以随时使用字段值。这是真正的“实时常数”。这样可以开发出高效且可在运行时调整的代码:整个过程可以看作基于预处理断言的一种替代(3)。使用 C++ 时通常会忘记这种技巧,因为它是提前编译的。除非具备一定的创造性,才可能让关键代码使用运行时选项(4)

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

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

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

推荐阅读

(点击标题可跳转阅读)

JVM 解剖公园(14): 常量

JVM 解剖公园(13): 代际屏障

JVM 解剖公园(12): 本地内存跟踪

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

关注「ImportNew」,提升Java技能

好文章,我在看❤️

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值