极客时间已完结课程限时免费阅读

24 | 字段访问相关优化

24 | 字段访问相关优化-极客时间

24 | 字段访问相关优化

讲述:郑雨迪

时长09:10大小3.68M

在上一篇文章中,我介绍了逃逸分析,也介绍了基于逃逸分析的优化方式锁消除、栈上分配以及标量替换等内容。
其中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对对象字段的访问,替换为对一个个局部变量的访问。
class Foo {
int a = 0;
}
static int bar(int x) {
Foo foo = new Foo();
foo.a = x;
return foo.a;
}
举个例子,上面这段代码中的bar方法,经过逃逸分析以及标量替换后,其优化结果如下所示。(确切地说,是指所生成的 IR 图与下述代码所生成的 IR 图类似。之后不再重复解释。)
static int bar(int x) {
int a = x;
return a;
}
由于 Sea-of-Nodes IR 的特性,局部变量不复存在,取而代之的是一个个值。在例子对应的 IR 图中,返回节点将直接返回所输入的参数。
经过标量替换的bar方法
下面我列举了bar方法经由 C2 即时编译生成的机器码(这里略去了指令地址的前 48 位)。
# {method} 'bar' '(I)I' in 'FieldAccessTest'
# parm0: rsi = int // 参数x
# [sp+0x20] (sp of caller)
0x06a0: sub rsp,0x18 // 创建方法栈桢
0x06a7: mov QWORD PTR [rsp+0x10],rbp // 无关指令
0x06ac: mov eax,esi // 将参数x存入返回值eax中
0x06ae: add rsp,0x10 // 弹出方法栈桢
0x06b2: pop rbp // 无关指令
0x06b3: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x06b7: test DWORD PTR [r10],eax // 安全点测试
0x06ba: ret
在 X86_64 的机器码中,每当使用 call 指令进入目标方法的方法体中时,我们需要在栈上为当前方法分配一块内存作为其栈桢。而在退出该方法时,我们需要弹出当前方法所使用的栈桢。
由于寄存器 rsp 维护着当前线程的栈顶指针,因此这些操作都是通过增减寄存器 rsp 来实现的,即上面这段机器码中偏移量为 0x06a0 以及 0x06ae 的指令。
在介绍安全点(safepoint)时我曾介绍过,HotSpot 虚拟机的即时编译器将在方法返回时插入安全点测试指令,即图中偏移量为 0x06b3 以及 0x06ba 的指令。其中真正的安全点测试是 0x06b7 指令。
如果虚拟机需要所有线程都到达安全点,那么该 test 指令所访问的内存地址所在的页将被标记为不可访问,而该指令也将触发 segfault,并借由 segfault 处理器进入安全点之中。通常,该指令会附带; {poll_return}这样子的注释,这里被我略去了。
在 X8_64 中,前几个传入参数会被放置于寄存器中,而返回值则需要存放在 rax 寄存器中。有时候你会看到返回值被存入 eax 寄存器中,这其实是同一个寄存器,只不过 rax 表示 64 位寄存器,而 eax 表示 32 位寄存器。具体可以参考 x86 calling conventions[1]。
当忽略掉创建、弹出方法栈桢,安全点测试以及其他无关指令之后,所剩下的方法体就只剩下偏移量为 0x06ac 的 mov 指令,以及 0x06ba 的 ret 指令。前者将所传入的 int 型参数 x 移至代表返回值的 eax 寄存器中,后者是退出当前方法并返回至调用者中。
虽然在部分情况下,逃逸分析以及基于逃逸分析的优化已经十分高效了,能够将代码优化到极其简单的地步,但是逃逸分析毕竟不是 Java 虚拟机的银色子弹。
在现实中,Java 程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即时编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。这时候,针对对象字段访问的优化也变得格外重要起来。
static int bar(Foo o, int x) {
o.a = x;
return o.a;
}
在上面这段代码中,对象o是传入参数,不属于逃逸分析的范围(Java 虚拟机中的逃逸分析针对的是新建对象)。该方法会将所传入的 int 型参数x的值存储至实例字段Foo.a中,然后再读取并返回同一字段的值。
这段代码将涉及两次内存访问操作:存储以及读取实例字段Foo.a。我们可以轻易地将其手工优化为直接读取并返回传入参数 x 的值。由于这段代码较为简单,因此它极大可能被编译为寄存器之间的移动指令(即将输入参数x的值移至寄存器 eax 中)。这与原本的内存访问指令相比,显然要高效得多。
static int bar(Foo o, int x) {
o.a = x;
return x;
}
那么即时编译器是否能够作出类似的自动优化呢?

字段读取优化

答案是可以的。即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。
当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。
当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。
在前面的例子中,我们见识了缓存字段存储节点的情况。下面我们来看一下缓存字段读取节点的情况。
static int bar(Foo o, int x) {
int y = o.a + x;
return o.a + y;
}
在上面这段代码中,实例字段Foo.a将被读取两次。即时编译器会将第一次读取的值缓存起来,并且替换第二次字段读取操作,以节省一次内存访问。
static int bar(Foo o, int x) {
int t = o.a;
int y = t + x;
return t + y;
}
如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化。
static int bar(Foo o, int x) {
o.a = 1;
if (o.a >= 0)
return x;
else
return -x;
}
例如在上面这段代码中,实例字段Foo.a会被赋值为 1。接下来的 if 语句将判断同一实例字段是否不小于 0。经过字段读取优化之后,>=节点的两个输入参数分别为常数 1 和 0,因此可以直接替换为具体结果true。如此一来,else 分支将变成不可达代码,可以直接删除,其优化结果如下所示。
static int bar(Foo o, int x) {
o.a = 1;
return x;
}
我们再来看另一个例子。下面这段代码的bar方法中,实例字段a会被赋值为true,后面紧跟着一个以a为条件的 while 循环。
class Foo {
boolean a;
void bar() {
a = true;
while (a) {}
}
void whatever() { a = false; }
}
同样,即时编译器会将 while 循环中读取实例字段a的操作直接替换为常量true,即下面代码所示的死循环。
void bar() {
a = true;
while (true) {}
}
// 生成的机器码将陷入这一死循环中
0x066b: mov r11,QWORD PTR [r15+0x70] // 安全点测试
0x066f: test DWORD PTR [r11],eax // 安全点测试
0x0672: jmp 0x066b // while (true)
在介绍 Java 内存模型时,我们便知道可以通过 volatile 关键字标记实例字段a,以此强制对它的读取。
实际上,即时编译器将在 volatile 字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。
就我们的例子而言,尽管在 X86_64 平台上,volatile 字段读取操作前后的内存屏障是 no-op,在即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化,强制在循环中使用内存读取指令访问实例字段Foo.a的最新值。
0x00e0: movzx r11d,BYTE PTR [rbx+0xc] // 读取a
0x00e5: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x00e9: test DWORD PTR [r10],eax // 安全点测试
0x00ec: test r11d,r11d // while (a)
0x00ef: jne 0x00e0 // while (a)
同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。

字段存储优化

除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。
class Foo {
int a = 0;
void bar() {
a = 1;
a = 2;
}
}
举例来说,上面这段代码中的bar方法先后存储了两次Foo.a实例字段。由于第一次存储之后没有读取Foo.a的值,因此,即时编译器会将其看成冗余存储,并将之消除掉,生成如下代码:
void bar() {
a = 2;
}
实际上,即便是在这两个字段存储操作之间读取该字段,即时编译器还是有可能在字段读取优化的帮助下,将第一个存储操作当成冗余存储给消除掉。
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,那么即时编译器也不能将冗余的存储操作消除掉。
这种情况看似很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。

死代码消除

除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于 Sea-of-Nodes IR 的特性,死存储的优化无须额外代价。
int bar(int x, int y) {
int t = x*y;
t = x+y;
return t;
}
上面这段代码涉及两个存储局部变量操作。当即时编译器将其转换为 Sea-of-Nodes IR 之后,没有节点依赖于 t 的第一个值x*y。因此,该乘法运算将被消除,其结果如下所示:
int bar(int x, int y) {
return x+y;
}
死存储还有一种变体,即在部分程序路径上有冗余存储。
int bar(boolean f, int x, int y) {
int t = x*y;
if (f)
t = x+y;
return t;
}
举个例子,上面这段代码中,如果所传入的 boolean 类型的参数ftrue,那么在程序执行路径上将先后进行两次对局部变量t的存储。
同样,经过 Sea-of-Nodes IR 转换之后,返回节点所依赖的值是一个 phi 节点,将根据程序路径选择x+y或者x*y。也就是说,当ftrue的程序路径上的乘法运算会被消除,其结果如下所示:
int bar(boolean f, int x, int y) {
int t;
if (f)
t = x+y;
else
t = x*y;
return t;
}
另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支,我们之前已经多次接触过了。
在即时编译过程中,我们经常因为方法内联、常量传播以及基于 profile 的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
int bar(int x) {
if (false)
return x;
else
return -x;
}
举个例子,在上面的代码中,if 语句将一直跳转至 else 分支之中。因此,另一不可达分支可以直接消除掉,形成下面的代码:
int bar(int x) {
return -x;
}

总结与实践

今天我介绍了即时编译器关于字段访问的优化方式,以及死代码消除。
即时编译器将沿着控制流缓存字段存储、读取的值,并在接下来的字段读取操作时直接使用该缓存值。
这要求生成缓存值的访问以及使用缓存值的读取之间没有方法调用、内存屏障,或者其他可能存储该字段的节点。
即时编译器还会优化冗余的字段存储操作。如果一个字段的两次存储之间没有对该字段的读取操作、方法调用以及内存屏障,那么即时编译器可以将第一个冗余的存储操作给消除掉。
此外,我还介绍了死代码消除的两种形式。第一种是局部变量的死存储消除以及部分死存储消除。它们可以通过转换为 Sea-of-Nodes IR 来完成。第二种则是不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
今天的实践环节,请思考即时编译器会怎么优化下面代码中的除法操作?
int bar(int x, int y) {
int t = x/y;
t = x+y;
return t;
}
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 4

提建议

上一篇
23 | 逃逸分析
下一篇
25 | 循环优化
 写留言

精选留言(18)

  • Void_seT
    2018-09-14
    因为x/y会有除0异常,这部分代码是否会被优化掉,不太确定,望老师指点。

    作者回复: 对的,因为有除0异常所以编译器没法优化掉这个除法

    16
  • 小文同学
    2018-09-20
    老师,请问一下,读这个专栏有点像涨视野的感觉,暂时来说对我看代码提供了新的灵感,但目前只能听,没法提问,这种感觉是是因为我底子不够么?还是我实践得比较少?

    作者回复: 按照自己的节奏学习就好啦,等以后遇到问题了也可以回来提问的。

    10
  • 誓言的梦
    2018-12-19
    有除零的异常 是通过什么手段/机制知道 还是写死的除法不能优化 或者回不回是通过 检测当输入除数为0就不优化 输入不为0时才优化
    6
  • test
    2020-02-23
    判断输入如果y为0则抛出异常,否则直接返回x+y
    共 2 条评论
    3
  • Scott
    2018-09-14
    "其中真正的安全点测试是 0x06ba 指令"应该是0x06b7指令

    作者回复: 多谢指出!已修改!

    共 2 条评论
    3
  • 剑八
    2020-06-14
    总结下来: 编绎器对于读取及存储指令的优化 对于读取,如果涉及一个堆对象同一个字段的多次读取,且不涉及该字段的写操作则会优化成方法执行中的缓存。只在第一次读取该对象字段时会从堆中取。会将该对象字段值缓存在局部变量表中。后面读取从这个地方取。这就涉及到一个多线程的可见性问题。如果想要去除可以设置为volatile或加lock,syncronized锁。 对于写操作,如果一个方法体对于一个变量的写是会覆盖的,则只会保留最后一个写操作。 还有一个优化则可以对于不可到达的分支代码进行消除,本质上都是减少不执行的代码,减少编绎成机器码后存储到code cache中的大小。
    展开
    3
  • 永烁星光
    2018-09-14
    Return x+y ;

    作者回复: 课后实践中的除法无法优化,因为可能存在除零异常。即时编译器需要判断除数是否为0

    3
  • 李二木
    2018-09-14
    除法操作直接消除,返回return x+y; ?

    作者回复: 消除不了,有位同学答出来了,有除零异常

    2
  • 随心而至
    2019-10-29
    JVM, 将字节码转成机器码,想深入了解挺难的,不过掌握其中的核心部分应该就够用了,毕竟咱不是JVM工程师,各有分工。
    2
  • Joker
    2019-08-16
    越走到后面就是天书啊,一篇文章至少三遍
    共 1 条评论
    1
  • xingoo
    2019-05-20
    字段缓存 存储优化 死代码消除:冗余代码,不可达代码
    2
  • 永烁星光
    2018-09-17
    希望老师推荐一本 JVM 书籍,(除了《深入理解java 虚拟机》,因为我发现最新出版也是2013年的)
    共 1 条评论
    1
  • Scott
    2018-09-14
    这一篇洋洋洒洒,其实覆盖了更多后端优化的算法,是否可以罗列一下对应算法名称供参考?

    作者回复: 我记得就叫read elimination和write elimination。第三节的是dead store elimination和partial redundancy elimination。 Graal的相关代码在PEReadEliminationClosure以及ReadEliminationClosure中。可以自行阅读。

    2
  • 李二木
    2018-09-14
    感觉写代码的能力水平跟编译器优化工作量还是有点关系的。

    作者回复: 一般我们写代码都会在保持代码可读性的同时,尽量减少编译器工作量

    2
  • 有米
    2020-04-14
    看起来就是在做代码优化,其实这些优化也是程序员应该去做的,而不是留给编译器去做。第一是为了代码质量,第二你不确定编译器会不会按你的想法去优化。
  • colin
    2019-05-29
    越了解,发现自己懂得越少
  • 『LHCY』
    2019-01-14
    不知道现在发还会不会回复。 while(a.flag){ System.out.println(); } 这种方式,在flag没有被volatile多线程方式修改flag=false会退出循环,是因为打印函数中的锁阻止了字段优化吗
    展开
  • o
    2018-10-05
    大佬,可否专门开一章讲讲对象的属性会在那些时候触发更新主存/用户内存?分别是单线程不同方法,多线程不同方法、多线程相同方法。是否和方法体大小也有关系?例如就是在该篇文章中,如果while的代码块足够简单就会形成死循环,但是如果加了输出语句就不会(之前测试volatile关键字的时候遇到过,晚点贴一下主要代码🙏)