什么时候Hotspot可以在堆栈上分配对象?

时间:2017-03-24 14:48:13

标签: java jvm compiler-optimization jvm-hotspot stack-allocation

从Java 6的某个地方开始,Hotspot JVM可以进行转义分析并在堆栈上而不是在垃圾收集堆上分配非转义对象。这样可以加快生成的代码并降低垃圾收集器的压力。

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

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

1 个答案:

答案 0 :(得分:28)

我已经做了一些实验,以便了解Hotspot何时可以进行堆栈分配。事实证明,它的堆栈分配比基于available documentation的预期有限。 Choi" Escape Analysis for Java"表明只分配给局部变量的对象总是可以堆栈分配。但事实并非如此。

所有这些都是当前Hotspot实现的实现细节,因此它们可能会在将来的版本中进行更改。这是指我的OpenJDK安装,它是X86-64的版本1.8.0_121。

基于相当多的实验,简短的总结似乎是:

如果

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

要了解这些条件何时成功,您需要了解Hotspot的工作原理。由于涉及许多非本地因素,依赖于Hotspot在某种情况下确定堆栈分配可能是有风险的。特别是知道是否所有内容都很难预测。

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

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

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

Hotspot internals

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

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

内联

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

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

&#39;类型&#39;这里指的是对象的确切类型。不考虑已实现的接口或共享超类。即使在调用站点发生了不同的接收器类型,但它们都继承了方法的相同实现(例如,所有从hashCode继承Object的多个类),Hotspot仍将生成间接调用而不是内联。 (所以i.m.o.在这种情况下,热点是非常愚蠢的。我希望未来的版本可以改善这一点。)

Hotspot也只会内联不太大的方法。 &#39;不太大&#39;由-XX:MaxInlineSize=n-XX:FreqInlineSize=n选项决定。 JVM字节码大小低于MaxInlineSize的Inlinable方法总是内联的,如果呼叫是热的,那么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; 
}

将SAM Function可调用映射到列表并返回转换后的列表,Hotspot会将对func.call的调用视为单个程序范围的调用站点。您可以在程序中的多个位置调用此map函数,在每个调用站点传递不同的函数(但对于一个调用站点则相同)。在这种情况下,您可能希望Hotspot能够内联map,然后调用func.call,因为每次使用map时,只有一个func类型。如果是这样的话,Hotspot将能够非常紧密地优化循环。不幸的是,Hotspot对此并不够聪明。它只会为func.call调用网站保留一个配置文件,将您传递给func的所有map类型混为一谈。您可能会使用两个以上func的不同实现,因此Hotspot将无法内联对func.call的调用。 Link了解更多详情,而archived link原来似乎已消失。

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

观察

我尝试了上述程序的一些变体。标量替换的一个条件是对象绝不能分配给对象(或静态)字段,并且可能也不会分配给数组。所以在像

这样的代码中
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中的数据的数组)由于其嵌入式内容而需要进行堆分配。我认为这种限制的原因是对局部变量不存在索引操作,因此这需要额外的代码生成功能来处理非常罕见的用例。