17

从 Java 6 左右开始,Hotspot JVM 可以进行转义分析并在堆栈上分配非转义对象,而不是在垃圾收集堆上。这会加速生成的代码并减少垃圾收集器的压力。

Hotspot何时能够堆叠分配对象的规则是什么?换句话说,我什么时候可以依靠它来进行堆栈分配?

编辑:这个问题是重复的,但是(IMO)下面的答案比原始问题的答案更好。

4

1 回答 1

49

我做了一些实验,以查看 Hotspot 何时能够进行堆栈分配。事实证明,它的堆栈分配比您根据可用文档预期的要有限得多。Choi 引用的论文“Escape Analysis for Java”表明,一个只分配给局部变量的对象总是可以被堆栈分配。但事实并非如此。

所有这些都是当前 Hotspot 实现的实现细节,因此它们可能会在未来的版本中发生变化。这是指我的 OpenJDK 安装,它是 X86-64 的 1.8.0_121 版本。

基于大量实验的简短摘要似乎是:

Hotspot 可以堆栈分配对象实例,如果

  • 它的所有用途都是内联的
  • 它永远不会分配给任何静态或对象字段,只会分配给局部变量
  • 在程序的每一点,哪些局部变量包含对对象的引用必须是 JIT 时间可确定的,并且不依赖于任何不可预测的条件控制流。
  • 如果对象是一个数组,它的大小必须在 JIT 时间知道,并且索引到它必须使用 JIT 时间常量。

要知道这些条件何时成立,您需要对 Hotspot 的工作原理有相当多的了解。在特定情况下依靠 Hotspot 进行堆栈分配是有风险的,因为涉及到很多非本地因素。特别是要知道是否所有内容都内联可能很难预测。

实际上,如果您只是使用它们进行迭代,简单的迭代器通常是可分配堆栈的。对于复合对象,只有外部对象可以被堆栈分配,因此列表和其他集合总是导致堆分配。

如果你有 aHashMap<Integer,Something>并且你在 中使用它myHashMap.get(42),它42可能会在测试程序中进行堆栈分配,但它不会在完整的应用程序中,因为你可以确定在整个程序中 HashMaps 中会有两种以上的关键对象,因此键上的 hashCode 和 equals 方法不会内联。

除此之外,我没有看到任何普遍适用的规则,这将取决于代码的具体情况。

热点内部结构

首先要知道的是转义分析是在内联之后执行的。这意味着 Hotspot 的逃逸分析在这方面比 Choi 论文中的描述更强大,因为从方法返回但调用方方法本地的对象仍然可以堆栈分配。因此,如果你这样做,迭代器几乎总是可以被堆栈分配for(Foo item : myList) {...}(并且实现myList.iterator()很简单,它们通常是。)

Hotspot 仅在确定方法是“热的”时才编译方法的优化版本,因此未多次运行的代码根本不会得到优化,在这种情况下,没有任何堆栈分配或内联。但是对于那些你通常不关心的方法。

内联

内联决策基于 Hotspot 首先收集的分析数据。声明的类型并不重要,即使一个方法是虚拟的 Hotspot 也可以根据它在分析期间看到的对象的类型内联它。分支也有类似的情况(即 if 语句和其他控制流结构):如果在分析期间 Hotspot 从未看到某个分支被采用,它将基于从未采用该分支的假设来编译和优化代码。在这两种情况下,如果 Hotspot 不能证明它的假设总是正确的,它将在编译后的代码中插入称为“不常见陷阱”的检查,如果遇到这样的陷阱,Hotspot 将取消优化并可能重新优化新信息考虑在内。

Hotspot 将分析哪些对象类型作为接收者出现在哪些呼叫站点。如果 Hotspot 只看到一个类型或两个不同的类型出现在调用站点,它能够内联被调用的方法。如果只有一两个非常常见的类型,而其他类型的出现频率要低得多,Hotspot 也应该仍然能够内联常见类型的方法,包括检查它需要采用哪些代码。(不过,我不完全确定最后一种情况是否有一种或两种常见类型和更不常见的类型)。如果有两种以上的常见类型,Hotspot 根本不会内联调用,而是为间接调用生成机器代码。

这里的“类型”是指对象的确切类型。不考虑已实现的接口或共享超类。即使不同的接收器类型出现在一个调用站点,但它们都继承了一个方法的相同实现(例如,多个类都继承hashCodeObject),Hotspot 仍然会生成间接调用而不是内联调用。(所以 imo 热点在这种情况下是相当愚蠢的。我希望未来的版本能改进这一点。)

Hotspot 也只会内联不太大的方法。“不太大”由-XX:MaxInlineSize=nand-XX:FreqInlineSize=n选项决定。JVM 字节码大小低于 MaxInlineSize 的可内联方法始终是内联的,如果调用是“热的”,则 JVM 字节码大小低于 FreqInlineSize 的方法是内联的。更大的方法永远不会内联。默认情况下 MaxInlineSize 为 35,FreqInlineSize 取决于平台,但对我来说它是 325。因此,如果您希望内联方法,请确保您的方法不要太大。有时它可以帮助从一个大方法中分离出公共路径,以便它可以内联到它的调用者中。

剖析

关于剖析的一件重要事情是剖析站点基于 JVM 字节码,它本身并没有以任何方式内联。所以如果你有一个静态方法

static <T,U> List<U> map(List<T> list, Function<T,U> func) {
    List<U> result = new ArrayList();
    for(T item : list) { result.add(func.call(item)); }
    return result; 
}

将 SAMFunction可调用映射到列表并返回转换后的列表,Hotspot 会将调用func.call视为单个程序范围的调用站点。您可以map在程序中的多个位置调用此函数,在每个调用点传入不同的函数(但对一个调用点传递相同的函数)。在这种情况下,您可能希望 Hotspot 能够内联map,然后也可以调用,func.call因为在每次使用时map只有一个func类型。如果是这样,Hotspot 将能够非常紧密地优化循环。不幸的是,Hotspot 还不够聪明。它只为func.call调用站点保留一个配置文件,将func您传递给的所有类型集中在一起map一起。您可能会使用两个以上不同的实现func,因此 Hotspot 将无法内联对func.call. 链接以获取更多详细信息,原始链接似乎已消失。

(顺便说一句,在Kotlin中,等效循环可以完全内联,因为 Kotlin 编译器可以在字节码级别进行调用内联。因此对于某些用途,它可能比 Java 快得多。)

标量替换

另一个重要的事情是 Hotspot 实际上并没有实现对象的堆栈分配。相反,它实现了标量替换,这意味着一个对象被解构为它的组成字段,并且这些字段像普通的局部变量一样被堆栈分配。这意味着根本没有留下任何物体。仅当不需要创建指向堆栈分配对象的指针时,标量替换才有效。某些形式的堆栈分配,例如 C++ 或 Go 将能够在堆栈上分配完整的对象,然后将引用或指向它们的指针传递给被调用的函数,但在 Hotspot 中这不起作用。因此,如果需要将对象引用传递给非内联方法,即使该引用不会转义被调用的方法,Hotspot 也会始终堆分配这样的对象。

原则上,Hotspot 在这方面可能会更聪明,但现在并非如此。

测试程序

我使用以下程序和变体来查看 Hotspot 何时会进行标量替换。

// Minimal example for which the JVM does not scalarize the allocation. If field is final, or the second allocation is unconditional, it will.

class Scalarization {

        int field = 0xbd;
        long foo(long i) { return i * field; }


        public static void main(String[] args) {
                long result = 0;
                for(long i=0; i<100; i++) {
                        result += test();
                }
                System.out.println("Result: "+result);
        }


        static long test() {
                long ctr = 0x5;
                for(long i=0; i<0x10000; i++) {

                Scalarization s = new Scalarization();
                ctr = s.foo(ctr);
                if(i == 0) s = new Scalarization();
                ctr = s.foo(ctr);
                }
                return ctr;
        }
}

如果你编译并运行这个程序,javac Scalarization.java; java -verbose:gc Scalarization你可以看到标量替换是否对垃圾回收的数量起作用。如果标量替换有效,则我的系统上没有发生垃圾收集,如果标量替换不起作用,我会看到一些垃圾收集。

Hotspot 能够标量化的变体的运行速度明显快于不能标量化的版本。我验证了生成的机器代码(指令)以确保 Hotspot 没有进行任何意外优化。如果hotspot能够标量替换分配,那么它还可以对循环进行一些额外的优化,将其展开几次迭代,然后将这些迭代组合在一起。因此,在标量化版本中,有效循环计数较低,每个迭代执行多个源代码级迭代的工作。所以速度差异不仅仅是由于分配和垃圾收集的开销。

观察

我尝试了上述程序的多种变体。标量替换的一个条件是永远不能将对象分配给对象(或静态)字段,并且可能也不能分配给数组。所以在代码中

Foo f = new Foo();
bar.field = f;

Foo对象不能被标量替换。即使它bar本身被标量替换,并且如果您不再使用bar.field. 所以一个对象只能分配给局部变量。

仅此还不够,Hotspot 还必须能够在 JIT 时间静态确定哪个对象实例将成为调用的目标。例如,使用和删除的以下实现footest导致field堆分配:

long foo(long i) { return i * 0xbb; }

static long test() {
    long ctr = 0x5;
    for(long i=0; i<0x10000; i++) {
        Scalarization s = new Scalarization();
        ctr = s.foo(ctr);
        if(i == 50) s = new Scalarization();
        ctr = s.foo(ctr);
    }
    return ctr;
}

如果您随后删除第二个分配的条件,则不会再发生堆分配:

static long test() {
    long ctr = 0x5;
    for(long i=0; i<0x10000; i++) {
        Scalarization s = new Scalarization();
        ctr = s.foo(ctr);
        s = new Scalarization();
        ctr = s.foo(ctr);
    }
    return ctr;
}

在这种情况下,Hotspot 可以静态确定哪个实例是每次调用的目标s.foo

另一方面,即使第二个分配给s的子类Scalarization具有完全不同的实现,只要分配是无条件的,Hotspot 仍然会缩放分配。

Hotspot 似乎无法将对象移动到先前被标量替换的堆中(至少在没有去优化的情况下不能)。标量替换是一个全有或全无的事情。所以在原始test方法中,两个分配Scalarization总是发生在堆上。

条件句

一个重要的细节是 Hotspot 将根据其分析数据预测条件。如果从不执行条件赋值,Hotspot 将在该假设下编译代码,然后可能能够进行标量替换。如果在稍后的时间点确实采用了条件,Hotspot 将需要使用这个新假设重新编译代码。新代码不会进行标量替换,因为 Hotspot 无法再静态确定后续调用的接收者实例。

例如在这个变体中test

static long limit = 0;

static long test() {
    long ctr = 0x5;
    long i = limit;
    limit += 0x10000;
    for(; i<limit; i++) { // In this form if scalarization happens is nondeterministic: if the condition is hit before profiling starts scalarization happens, else not.

        Scalarization s = new Scalarization();
        ctr = s.foo(ctr);
        if(i == 0xf9a0) s = new Scalarization();
        ctr = s.foo(ctr);
    }
    return ctr;
}

条件赋值只在程序的生命周期内执行一次。如果此分配发生得足够早,在 Hotspot 开始对该方法进行完整分析之前test,Hotspot 永远不会注意到正在采用的条件并编译执行标量替换的代码。如果在使用条件时分析已经开始,Hotspot 将不会进行标量替换。使用 的测试值0xf9a0,是否发生标量替换在我的计算机上是不确定的,因为分析开始的确切时间可能会有所不同(例如,因为分析和优化代码是在后台线程上编译的)。因此,如果我运行上述变体,它有时会进行一些垃圾收集,有时则不会。

Hotspot 的静态代码分析比 C/C++ 和其他静态编译器所能做的要有限得多,因此 Hotspot 在通过几个条件和其他控制结构来跟踪方法中的控制流来确定变量引用的实例方面并不聪明,即使它对于程序员或更智能的编译器来说是静态可确定的。在许多情况下,分析信息将弥补这一点,但这是需要注意的。

数组

如果数组的大小在 JIT 时已知,则可以堆栈分配数组。但是,除非 Hotspot 也可以在 JIT 时间静态确定索引值,否则不支持对数组进行索引。所以堆栈分配的数组是非常没用的。由于大多数程序不直接使用数组而是使用标准集合,因此这不是很相关,因为嵌入对象(例如包含 ArrayList 中数据的数组)由于它们的嵌入性而已经需要进行堆分配。我想这种限制的原因是不存在对局部变量的索引操作,因此对于非常罕见的用例,这将需要额外的代码生成功能。

于 2017-03-24T14:48:12.667 回答