JVM进阶 -- 浅谈字段访问优化

概念

在实际中,Java程序中的对象或许本身就是逃逸的,或许因为方法内联不够彻底而被即时编译器当成是逃逸的,这两种情况都将
导致即时编译器无法进行标量替换,这时,针对对象字段访问的优化显得更为重要。

1
2
3
4
static int bar(Foo o, int x) {
o.a = x;
return o.a;
}
  1. 对象o是传入参数,不属于逃逸分析的范围(JVM中的逃逸分析针对的是新建对象
  2. 该方法会将所传入的int型参数x的值存储至实例字段Foo.a中,然后再读取并返回同一字段的值
  3. 这段代码涉及两次内存访问操作:存储和读取实例字段Foo.a
  4. 代码可以手工优化成如下
1
2
3
4
static int bar(Foo o, int x) {
o.a = x;
return x;
}

即时编译器也能作出类似的自动优化

字段读取优化

  1. 即时编译器会优化实例字段静态字段的访问,以减少总的内存访问次数
  2. 即时编译器将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值
    • 当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么将读取节点替换为该缓存值
    • 当即时编译器遇到对同一字段的存储节点时,会更新所缓存的值
      • 当即时编译器遇到可能更新字段的节点时,它会采取保守的策略,舍弃所有的缓存值
      • 方法调用节点:在即时编译器看来,方法调用会执行未知代码
      • 内存屏障节点:其他线程可能异步更新了字段

样例1

1
2
3
4
static int bar(Foo o, int x) {
int y = o.a + x;
return o.a + y;
}

实例字段Foo.a被读取两次,即时编译器会将第一次读取的值缓存起来,并且替换第二次的字段读取操作,以节省一次内存访问

1
2
3
4
5
static int bar(Foo o, int x) {
int t = o.a;
int y = t + x;
return t + y;
}

样例2

1
2
3
4
5
6
7
static int bar(Foo o, int x) {
o.a = 1;
if (o.a >= 0)
return x;
else
return -x;
}

字段读取节点被替换成一个常量,进一步触发更多的优化

1
2
3
4
static int bar(Foo o, int x) {
o.a = 1;
return x;
}

样例3

1
2
3
4
5
6
7
8
class Foo {
boolean a;
void bar() {
a = true;
while (a) {}
}
void whatever() { a = false; }
}

即时编译器会将while循环中读取实例字段a的操作直接替换为常量true

1
2
3
4
5
6
7
8
void bar() {
a = true;
while (true) {}
}
// 生成的机器码将陷入这一死循环中
0x066b: mov r11,QWORD PTR [r15+0x70] // 安全点测试
0x066f: test DWORD PTR [r11],eax // 安全点测试
0x0672: jmp 0x066b // while (true)
  1. 可以通过volatile关键字标记实例字段a,以强制对a的读取
  2. 实际上,即时编译器将在volatile字段访问前后插入内存屏障节点
    • 这些内存屏障节点阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上
    • 在X86_64平台上,volatile字段读取前后的内存屏障都是no-op
      • 即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化
      • 强制在循环中使用内存读取指令访问实例字段Foo.a的最新值
  3. 同理,加解锁操作同样也会阻止即时编译器的字段读取优化

字段存储优化

如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容读取,那么即时编译器将消除第一个字段存储

样例1

1
2
3
4
5
6
7
class Foo {
int a = 0;
void bar() {
a = 1;
a = 2;
}
}

即时编译器将消除bar方法的冗余存储

1
2
3
void bar() {
a = 2;
}

样例2

即便在某个字段的两个存储操作之间读取该字段,即时编译器也可能在字段读取优化的帮助下,将第一个存储操作当作冗余存储
场景:例如两个存储操作之间隔着许多代码,又或者因为方法内联的原因,将两个存储操作纳入到同一编译单元里(如构造器中字段的初始化以及随后的更新)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Foo {
int a = 0;
void bar() {
a = 1;
int t = a;
a = t + 2;
}
}
// 优化为
class Foo {
int a = 0;
void bar() {
a = 1;
int t = 1;
a = t + 2;
}
}
// 进一步优化为
class Foo {
int a = 0;
void bar() {
a = 3;
}
}

如果所存储的字段被标记为volatile,那么即时编译器也不能消除冗余存储

死代码消除

样例1

1
2
3
4
5
int bar(int x, int y) {
int t = x*y;
t = x+y;
return t;
}

没有节点依赖于t的第一个值x*y,因此该乘法运算将被消除

1
2
3
int bar(int x, int y) {
return x+y;
}

样例2

1
2
3
4
5
6
int bar(boolean f, int x, int y) {
int t = x*y;
if (f)
t = x+y;
return t;
}

部分程序路径上有冗余存储(f=true),该路径上的乘法运算将会被消除

1
2
3
4
5
6
7
8
int bar(boolean f, int x, int y) {
int t;
if (f)
t = x+y;
else
t = x*y;
return t;
}

样例3

1
2
3
4
5
6
int bar(int x) {
if (false)
return x;
else
return -x;
}

不可达分支指的是任何程序路径都不可达到的分支,即时编译器将消除不可达分支

1
2
3
int bar(int x) {
return -x;
}

参考资料

深入拆解Java虚拟机

0%