Error message here!

Hide Error message here!

忘记密码?

Error message here!

请输入正确邮箱

Hide Error message here!

密码丢失?请输入您的电子邮件地址。您将收到一个重设密码链接。

Error message here!

返回登录

Close

JVM 解剖公园(14): 常量

ImportNew 2019-07-30 13:54:50 阅读数:123 评论数:0 点赞数:0 收藏数:0

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

编译:ImportNew/唐尤华

shipilev.net/jvm/anatomy-quarks/14-constant-variables/


1. 写在前面


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


Aleksey Shipilёv,JVM 性能极客

推特 @shipilev

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


2. 问题


实例中 final 字段是不是常量?


3. 理论


假如你读过 Java 语言规范中关于 final 变量语义的描述,会发现一段诡异的描述:


>

 常量(Constant Variable)指使用常量表达式(§15.28)初始化的 final 变量,其类型为原始类型或 String。一个变量否为常量可能对类初始化(§12.4.1)、二进制兼容性(§13.1, §13.4.9) 和明确赋值(§16)产生影响。 

— Java 语言规范 4.12.4 

>


上述影响能够通过实验观察吗?


4. 实验


思考下面代码的输出是什么?


import java.lang.reflect.Field;
public class ConstantValues {
final int fieldInit = 42;
final int instanceInit;
final int constructor;
{
instanceInit = 42;
}
public ConstantValues() {
constructor = 42;
}
static void set(ConstantValues p, String field) throws Exception {
Field f = ConstantValues.class.getDeclaredField(field);
f.setAccessible(true);
f.setInt(p, 9000);
}
public static void main(String... args) throws Exception {
ConstantValues p = new ConstantValues();
set(p, "fieldInit");
set(p, "instanceInit");
set(p, "constructor");
System.out.println(p.fieldInit + " " + p.instanceInit + " " + p.constructor);
}
}


在我的电脑上运行,结果如下:

42 9000 9000


尽管试图覆盖 fieldInt 字段,但是没有看到被赋予新值。更令人困惑的是,其他两个变量的值似乎顺利地更新了。原因在于,其他两个是空白 final 字段,第一个字段是常量。上面示例生成的字节码:


$ javap -c -v -p ConstantValues.class
...
final int fieldInit;
descriptor: I
flags: ACC_FINAL
ConstantValue: int 42 <---- oh...
final int instanceInit;
descriptor: I
flags: ACC_FINAL
final int constructor;
descriptor: I
flags: ACC_FINAL
...
public static void main(java.lang.String...) throws java.lang.Exception;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
Code:
...
41: bipush 42 // <--- 哦, 这里 fieldInit 字段发生了内联
43: invokevirtual #18 // StringBuilder.append
46: ldc #19 // String " "
48: invokevirtual #20 // StringBuilder.append
51: aload_1
52: getfield #3 // Field instanceInit:I
55: invokevirtual #18 // StringBuilder.append
58: ldc #19 // String ""
60: invokevirtual #20 // StringBuilder.append
63: aload_1
64: getfield #4 // Field constructor:I
67: invokevirtual #18 // StringBuilder.append
70: invokevirtual #21 // StringBuilder.toString
73: invokevirtual #22 // System.out.println


难怪看不到 fieldInit 字段值更新:javac 对其进行了内联,JVM 不可能重写字节码反映待修改的内容。


字节码编译器会完成优化带来明显的性能提升:JIT 编译器可以直接使用常量,不需要进行复杂分析。但是和往常一样,这也是有代价的。比如对二进制兼容性影响,使用新值重新编译类会发生怎样的改变?可以参考Java 语言规范相关章节。对底层基准测试也会产生有趣的影响,比如为实例字段加上 final 修饰符是否真的会带来性能提升,像下面这样测试:


@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 FinalInitBench {
// 不能像在生产代码中那样稍后赋值,
// 在构造函数中初始化 final 字段
// 不用担心,可以直接为字段赋值
final int fx = 42; // 编译器会报告编译错误?这里赋值 42!
int x = 42;
@Benchmark
public int testFinal()
{
return fx;
}
@Benchmark
public int test()
{
return x;
}
}


使用 final 字段的初始化器自己完成初始化,可能带来意料之外的结果!使用 perfnorm 运行基准测试查看底层性能计数器,会得到以下结果:final 字段访问效果更好,并且产生的负载更少!<sup>(1)</sup>


(1) 实际上同时也减少了一组 load-store 操作,这是寄存器分配优化后的结果。


Benchmark                                  Mode  Cnt   Score    Error  Units
FinalInitBench.test avgt 9 1.920 ± 0.002 ns/op
FinalInitBench.test:CPI avgt 3 0.291 ± 0.039 #/op
FinalInitBench.test:L1-dcache-loads avgt 3 11.136 ± 1.447 #/op
FinalInitBench.test:L1-dcache-stores avgt 3 3.042 ± 0.327 #/op
FinalInitBench.test:cycles avgt 3 7.316 ± 1.272 #/op
FinalInitBench.test:instructions avgt 3 25.178 ± 2.242 #/op
FinalInitBench.testFinal avgt 9 1.901 ± 0.001 ns/op
FinalInitBench.testFinal:CPI avgt 3 0.285 ± 0.004 #/op
FinalInitBench.testFinal:L1-dcache-loads avgt 3 9.077 ± 0.085 #/op <--- !
FinalInitBench.testFinal:L1-dcache-stores avgt 3 4.077 ± 0.752 #/op
FinalInitBench.testFinal:cycles avgt 3 7.142 ± 0.071 #/op
FinalInitBench.testFinal:instructions avgt 3 25.102 ± 0.422 #/op


这是因为在生成的代码中根本没有加载字段,这里所做的只是使用字节码传入的内联常量:


# test
...
1.02% 1.02% mov 0x10(%r10),%edx ; <--- 获取字段 x
2.50% 1.79% nop
1.79% 1.60% callq CONSUME
...
# testFinal
...
8.25% 8.21% mov $0x2a,%edx ; <--- 只是使用内联常量 "42"
1.79% 0.56% nop
1.35% 1.19% callq CONSUME
...


虽然直接赋值没有问题,但是对于空白的 final 字段结果会有所不同,后者更接近于实际编码中的用法。示例如下:

@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 FinalInitCnstrBench {
final int fx;
int x;
public FinalInitCnstrBench() {
this.fx = 42;
this.x = 42;
}
@Benchmark
public int testFinal()
{
return fx;
}
@Benchmark
public int test()
{
return x;
}
}


上面的代码产生结果更合理,两个测试的性能不相上下:<sup>(2)</sup>


(2)这才是 JIT 正常的工作方式,在下一篇中会讨论 JIT 常量中可能遇到的陷阱。


Benchmark                                            Mode  Cnt   Score    Error  Units
FinalInitCnstrBench.test avgt 9 1.922 ± 0.003 ns/op
FinalInitCnstrBench.test:CPI avgt 3 0.289 ± 0.049 #/op
FinalInitCnstrBench.test:L1-dcache-loads avgt 3 11.171 ± 1.429 #/op
FinalInitCnstrBench.test:L1-dcache-stores avgt 3 3.042 ± 0.031 #/op
FinalInitCnstrBench.test:cycles avgt 3 7.301 ± 0.445 #/op
FinalInitCnstrBench.test:instructions avgt 3 25.235 ± 1.732 #/op
FinalInitCnstrBench.testFinal avgt 9 1.919 ± 0.002 ns/op
FinalInitCnstrBench.testFinal:CPI avgt 3 0.287 ± 0.014 #/op
FinalInitCnstrBench.testFinal:L1-dcache-loads avgt 3 11.170 ± 1.104 #/op
FinalInitCnstrBench.testFinal:L1-dcache-stores avgt 3 3.039 ± 0.864 #/op
FinalInitCnstrBench.testFinal:cycles avgt 3 7.278 ± 0.394 #/op
FinalInitCnstrBench.testFinal:instructions avgt 3 25.314 ± 0.588 #/op


5. 观察


Java 中的常数传播过程相当复杂,并且存在一些有趣的临界情况。字节码编译器对常量(Constant Variable)的处理就是其中一种。更多时候,底层基准测试会出现问题,而实际的生产代码中在构造函数初始化字段不会有问题。JMH 的 perfasm 和 perfnorm 分析器这里非常适合捕捉、量化这些临界情况。


推荐阅读

(点击标题可跳转阅读)

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

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

JVM 解剖公园(11): 移动 GC 与局部性


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

关注「ImportNew」,提升Java技能

好文章,我在看

版权声明
本文为[ImportNew]所创,转载请带上原文链接,感谢
https://mp.weixin.qq.com/s/Tr-Zw6CrlKqu5jqcrTFWgg