23 深入理解JVM逃逸分析:对象不一定都在堆上分配

图片[1]-23 深入理解JVM逃逸分析:对象不一定都在堆上分配-速优课

深入理解JVM逃逸分析:对象不一定都在堆上分配

本文导读

在Java中,对象都是在堆上分配的吗?很多开发者的答案是肯定的。但实际上,随着JVM即时编译技术的发展,这个答案已经不再绝对。

逃逸分析(Escape Analysis)是JVM中一项非常重要的优化技术,它可以让对象在栈上分配,甚至被”拆分”成标量直接放在寄存器中,从而大大减轻垃圾回收的压力。

本文将带你深入理解逃逸分析技术,你将学到:

  • 什么是逃逸分析,它的判断标准是什么
  • 基于逃逸分析的三大优化:锁消除、栈上分配、标量替换
  • 为什么HotSpot选择标量替换而不是栈上分配
  • 什么是部分逃逸分析,它解决了什么问题
  • 如何验证逃逸分析的效果

一、从foreach循环说起:对象真的都在堆上吗?

我们知道,Java中Iterable对象的foreach循环遍历是一个语法糖,Java编译器会将该语法糖编译为调用Iterable对象的iterator方法,并用所返回的Iterator对象的hasNext以及next方法,来完成遍历。

public void forEach(ArrayList<Object> list, Consumer<Object> f) {
    for (Object obj : list) {
        f.accept(obj);
    }
}

举个例子,上面的Java代码将使用foreach循环来遍历一个ArrayList对象,其等价的代码如下所示:

public void forEach(ArrayList<Object> list, Consumer<Object> f) {
    Iterator<Object> iter = list.iterator();
    while (iter.hasNext()) {
        Object obj = iter.next();
        f.accept(obj);
    }
}

我们来看一下ArrayList.iterator方法的实现,它会创建一个ArrayList$Itr实例:

public class ArrayList ... {
    public Iterator<E> iterator() {
        return new Itr();
    }
    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;
        ...
        public boolean hasNext() {
            return cursor != size;
        }
        @SuppressWarnings("unchecked")
        public E next() {
            checkForComodification();
            int i = cursor;
            if (i >= size)
                throw new NoSuchElementException();
            Object[] elementData = ArrayList.this.elementData;
            if (i >= elementData.length)
                throw new ConcurrentModificationException();
            cursor = i + 1;
            return (E) elementData[lastRet = i];
        }
        ...
        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }
}

因此,有同学认为我们应当避免在热点代码中使用foreach循环,并且直接使用基于ArrayList.size以及ArrayList.get的循环方式,以减少对Java堆的压力。

public void forEach(ArrayList<Object> list, Consumer<Object> f) {
    for (int i = 0; i < list.size(); i++) {
        f.accept(list.get(i));
    }
}

但实际上,Java虚拟机中的即时编译器可以将ArrayList.iterator方法中的实例创建操作给优化掉。不过,这需要方法内联以及逃逸分析的协作。

在前面几篇中我们已经深入学习了方法内联,今天我们就来介绍一下逃逸分析。


二、什么是逃逸分析

逃逸分析是”一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。

在Java虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据有两个:

  1. 对象是否被存入堆中(静态字段或者堆中对象的实例字段)
  2. 对象是否被传入未知代码中

前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。

关于后者,由于Java虚拟机的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此,我们可以认为方法调用的调用者以及参数是逃逸的。

通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些”未知代码”入口。

2.1 逃逸分析实例

我们回到文章开头的例子。理想情况下,即时编译器能够内联对ArrayList$Itr构造器的调用,对hasNext以及next方法的调用,以及当内联了Itr.next方法后,对checkForComodification方法的调用。

如果这些方法调用均能够被内联,那么结果将近似于下面这段伪代码:

public void forEach(ArrayList<Object> list, Consumer<Object> f) {
    Itr iter = new Itr; // 注意这里是new指令
    iter.cursor = 0;
    iter.lastRet = -1;
    iter.expectedModCount = list.modCount;
    while (iter.cursor < list.size) {
        if (list.modCount != iter.expectedModCount)
            throw new ConcurrentModificationException();
        int i = iter.cursor;
        if (i >= list.size)
            throw new NoSuchElementException();
        Object[] elementData = list.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        iter.cursor = i + 1;
        iter.lastRet = i;
        Object obj = elementData[i];
        f.accept(obj);
    }
}

可以看到,这段代码所新建的ArrayList$Itr实例既没有被存入任何字段之中,也没有作为任何方法调用的调用者或者参数。因此,逃逸分析将断定该实例不逃逸。


三、基于逃逸分析的优化

即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。

3.1 锁消除(Lock Elision)

我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。

实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于Java虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。

在介绍Java内存模型时,我们曾提到过synchronized (new Object()) {}会被完全优化掉。这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的happens-before规则。

synchronized (escapedObject) {}则不然。由于其他线程可能会对逃逸了的对象escapedObject进行加锁操作,从而构造了两个线程之间的happens-before关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。

不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。

3.2 栈上分配 vs 标量替换

我们知道,Java虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么Java虚拟机完全可以将其分配至栈上,并且在new语句所在的方法退出时,通过弹出当前方法的栈帧来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。

不过,由于实现起来需要更改大量假设了”对象只能堆分配”的代码,因此HotSpot虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。

什么是标量替换?

所谓的标量,就是仅能存储一个值的变量,比如Java代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是Java对象。

标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。举例来说,前面经过内联之后的forEach代码可以被转换为如下代码:

public void forEach(ArrayList<Object> list, Consumer<Object> f) {
    // Itr iter = new Itr; // 经过标量替换后该分配无意义,可以被优化掉
    int cursor = 0;     // 标量替换
    int lastRet = -1;   // 标量替换
    int expectedModCount = list.modCount; // 标量替换
    while (cursor < list.size) {
        if (list.modCount != expectedModCount)
            throw new ConcurrentModificationException();
        int i = cursor;
        if (i >= list.size)
            throw new NoSuchElementException();
        Object[] elementData = list.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        cursor = i + 1;
        lastRet = i;
        Object obj = elementData[i];
        f.accept(obj);
    }
}

可以看到,原本需要在内存中连续分布的对象,现已被拆散为一个个单独的字段cursorlastRet,以及expectedModCount。这些字段既可以存储在栈上,也可以直接存储在寄存器中。而该对象的对象头信息则直接消失了,不再被保存至内存之中。

由于该对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。


四、部分逃逸分析:更智能的优化策略

C2的逃逸分析与控制流无关,相对来说比较简单。Graal则引入了一个与控制流有关的逃逸分析,名为部分逃逸分析(Partial Escape Analysis)。它解决了所新建的实例仅在部分程序路径中逃逸的情况。

4.1 部分逃逸分析的场景

举个例子,在下面这段代码中,新建实例只会在进入if-then分支时逃逸。(对hashCode方法的调用是一个HotSpot intrinsic,将被替换为一个无法内联的本地方法调用。)

public static void bar(boolean cond) {
    Object foo = new Object();
    if (cond) {
        foo.hashCode();
    }
}
// 可以手工优化为:
public static void bar(boolean cond) {
    if (cond) {
        Object foo = new Object();
        foo.hashCode();
    }
}

假设if语句的条件成立的可能性只有1%,那么在99%的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。

4.2 部分逃逸分析的原理

部分逃逸分析将根据控制流信息,判断出新建对象仅在部分分支中逃逸,并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作,不再出现在只执行if-else分支的程序路径之中。

具体来说,部分逃逸分析的工作流程如下:

  1. 分析对象在各个程序路径上的逃逸状态
  2. 找出对象真正逃逸的分支
  3. 将对象的创建操作延迟到逃逸分支中
  4. 对于非逃逸分支,直接使用标量替换

综上,与C2所使用的逃逸分析相比,Graal所使用的部分逃逸分析能够优化更多的情况,不过它的编译时间也更长一些。这是典型的编译时间和生成代码质量之间的权衡。


总结与思考

本文深入讲解了JVM中的逃逸分析技术及其带来的优化效果,让我们来总结一下核心要点:

1. 什么是逃逸分析 逃逸分析是一种静态分析技术,用于判断新建的对象是否会逃逸出当前方法。判断依据有两个:

  • 对象是否被存入堆中(静态字段或堆中对象的实例字段)
  • 对象是否被传入未知代码中(未被内联的方法调用)

2. 基于逃逸分析的三大优化

  • 锁消除:如果锁对象不逃逸,可以消除加锁解锁操作
  • 栈上分配:将不逃逸的对象分配在栈上,方法退出时自动回收(HotSpot未采用)
  • 标量替换:将对象拆分成多个标量,直接在栈上或寄存器中存储(HotSpot采用)

3. 为什么选择标量替换 HotSpot没有采用栈上分配,而是使用标量替换,主要原因是:

  • 栈上分配需要更改大量假设”对象只能堆分配”的代码,实现成本高
  • 标量替换对内存连续性没有要求
  • 标量甚至可以直接在寄存器中维护,效率更高

4. 部分逃逸分析 Graal编译器引入的部分逃逸分析,可以处理对象仅在部分程序路径中逃逸的情况。它将对象的创建操作延迟到逃逸分支中,从而在非逃逸分支避免对象分配。

实践建议

  • 不必过分担心foreach循环中的对象分配,JVM的逃逸分析通常能优化掉
  • 编写小方法有助于内联,而内联是逃逸分析的前提
  • 可以通过-XX:-DoEscapeAnalysis关闭逃逸分析,对比观察GC情况来验证其效果
  • Graal编译器的部分逃逸分析在特定场景下能带来更好的性能

你可以运行下面这段代码来验证逃逸分析的效果,观察开启和关闭逃逸分析时GC情况的差异:

// Run with
// java -XX:+PrintGC -XX:+DoEscapeAnalysis EscapeTest
import java.util.ArrayList;
import java.util.function.Consumer;
​
public class EscapeTest {
​
    public static void forEach(ArrayList<Object> list, Consumer<Object> f) {
        for (Object obj : list) {
            f.accept(obj);
        }
    }
​
    public static void main(String[] args) {
        ArrayList<Object> list = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            list.add(i);
        }
        for (int i = 0; i < 400_000_000; i++) {
            forEach(list, obj -> {});
        }
    }
}

如果你使用Graal编译器,还可以体验一下部分逃逸分析的效果:

// Run with
// java -Xlog:gc Foo
// java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -Xlog:gc Foo
public class Foo {
    long placeHolder0;
    long placeHolder1;
    long placeHolder2;
    long placeHolder3;
    long placeHolder4;
    long placeHolder5;
    long placeHolder6;
    long placeHolder7;
    long placeHolder8;
    long placeHolder9;
    long placeHoldera;
    long placeHolderb;
    long placeHolderc;
    long placeHolderd;
    long placeHoldere;
    long placeHolderf;
    public static void bar(boolean condition) {
        Foo foo = new Foo();
        if (condition) {
            foo.hashCode();
        }
    }
    public static void main(String[] args) {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            bar(i % 100 == 0);
        }
    }
}
© 版权声明
THE END
喜欢就支持一下吧
点赞11
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

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