24 JVM字段访问优化深度解析:减少内存访问的艺术

图片[1]-24 JVM字段访问优化深度解析:减少内存访问的艺术-速优课

本文导读

内存访问是程序性能的重要瓶颈之一。在Java中,每次字段访问都意味着一次内存读写操作。但你知道吗?JVM的即时编译器会对字段访问进行各种优化,尽可能地减少实际的内存访问次数。

在上一篇文章中,我们介绍了逃逸分析以及基于逃逸分析的优化方式——锁消除、栈上分配以及标量替换。其中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对对象字段的访问,替换为对一个个局部变量的访问。

但逃逸分析并不是万能的。当对象逃逸时,我们又该如何优化字段访问呢?本文将带你深入理解JVM中的字段访问优化技术,你将学到:

  • 字段读取优化的原理和实现
  • 字段存储优化如何消除冗余写入
  • 死代码消除的两种形式
  • volatile关键字对字段优化的影响
  • 为什么while循环中的volatile变量不会被优化掉

一、标量替换的回顾与局限

在开始讲字段访问优化之前,我们先快速回顾一下标量替换。

class Foo {
    int a = 0;
}
​
static int bar(int x) {
    Foo foo = new Foo();
    foo.a = x;
    return foo.a;
}

上面这段代码中的bar方法,经过逃逸分析以及标量替换后,其优化结果如下所示:

static int bar(int x) {
    int a = x;
    return a;
}

由于Sea-of-Nodes IR的特性,局部变量不复存在,取而代之的是一个个值。在例子对应的IR图中,返回节点将直接返回所输入的参数。

图片[2]-24 JVM字段访问优化深度解析:减少内存访问的艺术-速优课

下面是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来实现的。

在介绍安全点(safepoint)时我们曾提到,HotSpot虚拟机的即时编译器将在方法返回时插入安全点测试指令。其中真正的安全点测试是test指令。如果虚拟机需要所有线程都到达安全点,那么该test指令所访问的内存地址所在的页将被标记为不可访问,而该指令也将触发segfault,并借由segfault处理器进入安全点之中。

在X86_64中,前几个传入参数会被放置于寄存器中,而返回值则需要存放在rax寄存器中。有时候你会看到返回值被存入eax寄存器中,这其实是同一个寄存器,只不过rax表示64位寄存器,而eax表示32位寄存器。

当忽略掉创建、弹出方法栈帧,安全点测试以及其他无关指令之后,所剩下的方法体就只剩下偏移量为0x06ac的mov指令,以及0x06ba的ret指令。前者将所传入的int型参数x移至代表返回值的eax寄存器中,后者是退出当前方法并返回至调用者中。

1.1 逃逸分析的局限性

虽然在部分情况下,逃逸分析以及基于逃逸分析的优化已经十分高效了,能够将代码优化到极其简单的地步,但是逃逸分析毕竟不是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;
}

那么即时编译器是否能够作出类似的自动优化呢?答案是肯定的。下面我们就来详细讲解字段读取优化和字段存储优化。


二、字段读取优化:缓存是万能的吗?

即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。

优化规则如下:

  • 当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值
  • 当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值
  • 当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值

2.1 缓存字段存储值

在前面的例子中,我们见识了缓存字段存储节点的情况:先写入一个值,紧接着就读取同一个字段,这时候可以直接使用刚才写入的值,避免一次内存读取。

2.2 缓存字段读取值

下面我们来看一下缓存字段读取节点的情况:

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

2.3 触发更多优化

如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化。

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

2.4 volatile的作用:阻止字段优化

我们再来看另一个例子。下面这段代码的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)

这也是为什么如果while循环的条件变量没有被标记为volatile的话,其他线程修改了该条件变量,while循环却不会退出的原因——因为JIT优化掉了字段读取操作。

在介绍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)

同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。


三、字段存储优化:消除冗余写入

除了字段读取优化之外,即时编译器还将消除冗余的存储节点。

优化规则很简单:如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。

3.1 简单的冗余存储消除

class Foo {
    int a = 0;
    void bar() {
        a = 1;
        a = 2;
    }
}

举例来说,上面这段代码中的bar方法先后存储了两次Foo.a实例字段。由于第一次存储之后没有读取Foo.a的值,因此,即时编译器会将其看成冗余存储,并将之消除掉,生成如下代码:

void bar() {
    a = 2;
}

3.2 读取优化+存储优化的组合效果

实际上,即便是在这两个字段存储操作之间读取该字段,即时编译器还是有可能在字段读取优化的帮助下,将第一个存储操作当成冗余存储给消除掉。

class Foo {
    int a = 0;
    void bar() {
        a = 1;
        int t = a;
        a = t + 2;
    }
}

上面这段代码的优化过程如下:

第一步:字段读取优化 由于第二次读取a时,缓存的值还是1(刚刚写入的),所以可以直接替换为常量1:

class Foo {
    int a = 0;
    void bar() {
        a = 1;
        int t = 1;
        a = t + 2;
    }
}

第二步:常量折叠 t + 2可以直接计算为3:

class Foo {
    int a = 0;
    void bar() {
        a = 1;
        a = 3;
    }
}

第三步:冗余存储消除 两次存储之间没有读取(第一次的读取已经被优化掉了),所以第一次存储可以消除:

class Foo {
    int a = 0;
    void bar() {
        a = 3;
    }
}

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

这种情况看似很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。


四、死代码消除:不只是字段优化

除了字段存储优化之外,局部变量的死存储(Dead Store)同样也涉及了冗余存储。这是死代码消除(Dead Code Elimination)的一种。不过,由于Sea-of-Nodes IR的特性,死存储的优化无须额外代价。

4.1 局部变量死存储消除

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

4.2 部分死存储消除

死存储还有一种变体,即在部分程序路径上有冗余存储。

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

4.3 不可达分支消除

另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支,我们之前已经多次接触过了。

在即时编译过程中,我们经常因为方法内联、常量传播以及基于profile的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。

int bar(int x) {
    if (false)
        return x;
    else
        return -x;
}

举个例子,在上面的代码中,if语句将一直跳转至else分支之中。因此,另一不可达分支可以直接消除掉,形成下面的代码:

int bar(int x) {
    return -x;
}

总结与思考

本文深入讲解了JVM中字段访问相关的优化以及死代码消除技术,让我们来总结一下核心要点:

1. 字段读取优化 即时编译器将沿着控制流缓存字段存储、读取的值,并在接下来的字段读取操作时直接使用该缓存值,从而减少内存访问次数。

缓存失效的情况:

  • 遇到方法调用节点(方法调用会执行未知代码)
  • 遇到内存屏障节点(其他线程可能异步更新了字段)
  • 遇到对同一字段的存储节点(更新缓存值)

2. 字段存储优化 如果一个字段的两次存储之间没有对该字段的读取操作、方法调用以及内存屏障,那么即时编译器可以将第一个冗余的存储操作给消除掉。

3. volatile的作用 volatile关键字会在字段访问前后插入内存屏障节点,这些屏障节点会阻止字段读取优化和字段存储优化,确保每次访问都真实地读写内存。

4. 死代码消除

  • 死存储消除:没有被使用的变量赋值会被消除,这在Sea-of-Nodes IR中是自然而然的
  • 部分死存储消除:只有部分路径上的冗余存储会被消除
  • 不可达分支消除:永远不会执行的分支会被删除,精简代码

实践建议

  • 不必过分担心多次读取同一字段的性能问题,JIT通常会帮你优化掉
  • 但也要注意,方法调用会中断字段缓存优化,因为编译器不知道方法里做了什么
  • 合理使用volatile关键字,它会阻止优化,但能保证内存可见性
  • 编写清晰的代码比手动优化更重要,JIT编译器会帮你做大部分优化

最后,留给大家一个思考题:即时编译器会怎么优化下面代码中的除法操作?

int bar(int x, int y) {
    int t = x/y;
    t = x+y;
    return t;
}

提示:考虑一下除法运算可能抛出的异常(ArithmeticException: / by zero),这会对死代码消除产生什么影响呢?

© 版权声明
THE END
喜欢就支持一下吧
点赞12
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!