什么时候Hotspot可以在堆栈上分配对象?
从Java 6的某个地方开始,Hotspot JVM可以进行转义分析,并在堆栈上而不是在垃圾收集堆上分配非转义对象。 这导致生成的代码加速并减少垃圾收集器的压力。
Hotspot何时能够堆叠分配对象的规则是什么? 换句话说,我什么时候可以依靠它来进行堆栈分配?
编辑 :这个问题是重复的,但是(IMO)下面的答案比原始问题提供的答案更好。
我做了一些实验,以便了解Hotspot何时可以进行堆栈分配。 事实certificate,它的堆栈分配比基于可用文档的预期要有限。 Choi“Escape Analysis for Java”引用的论文表明,只能分配给局部变量的对象总是可以堆栈分配。 但事实并非如此。
所有这些都是当前Hotspot实现的实现细节,因此它们可能会在将来的版本中进行更改。 这是指我的OpenJDK安装,它是适用于X86-64的版本1.8.0_121。
基于相当多的实验,简短的总结似乎是:
如果热点可以堆栈分配对象实例
- 它的所有用途都是内联的
- 它永远不会分配给任何静态或对象字段,只分配给局部变量
- 在程序的每个点,哪些局部变量包含对象的引用必须是JIT时间可确定的,并且不依赖于任何不可预测的条件控制流。
- 如果对象是数组,则其大小必须在JIT时知道,并且索引必须使用JIT时间常量。
要知道这些条件何时成立,您需要了解Hotspot的工作原理。 由于涉及许多非本地因素,依赖Hotspot确定在某种情况下进行堆栈分配可能存在风险。 特别是知道是否所有内容都很难预测。
实际上,如果你只是使用它们进行迭代,那么简单的迭代器通常可以是栈可分配的。 对于复合对象,只能对外层对象进行堆栈分配,因此列表和其他集合总是会导致堆分配。
如果你有一个HashMap
并且你在myHashMap.get(42)
使用它, 42
可能会在测试程序中堆栈分配,但它不会在一个完整的应用程序中,因为你可以确定会有更多在整个程序中HashMaps中的两种类型的键对象,因此键上的hashCode和equals方法不会内联。
除此之外,我没有看到任何普遍适用的规则,它将取决于代码的细节。
热点内部
第一个重要的事情是在内联后执行转义分析。 这意味着Hotspot的转义分析在这方面比Choi论文中的描述更强大,因为从方法返回但在调用方法本地的对象仍然可以进行堆栈分配。 因为这样的迭代器几乎总是可以堆栈分配,例如你for(Foo item : myList) {...}
(并且myList.iterator()
的实现很简单,它们通常是。)
Hotspot只有在确定方法“热”时才编译优化版本的方法,因此很多次运行的代码根本没有得到优化,在这种情况下,没有堆栈分配或内联。 但对于那些你通常不在乎的方法。
内联
内联决策基于Hotspot首先收集的分析数据。 声明的类型并不重要,即使方法是虚拟的,Hotspot也可以根据它在分析期间看到的对象类型来内联它。 类似的东西适用于分支(即if语句和其他控制流构造):如果在分析期间Hotspot从未看到某个分支被采用,它将基于从不采用分支的假设来编译和优化代码。 在这两种情况下,如果Hotspot无法certificate其假设始终为真,它将在已编译的代码中插入检查,称为“不常见的陷阱”,如果遇到此类陷阱,Hotspot将进行去优化并可能重新优化新信息考虑在内。
Hotspot将分析哪些对象类型作为呼叫站点的接收者。 如果Hotspot在调用站点只看到一种类型或只有两种不同的类型,则它能够内联被调用的方法。 如果只有一个或两个非常常见的类型,并且其他类型的出现频率低得多,Hotspot还应该能够内联常见类型的方法,包括检查它需要采取哪些代码。 (我不完全确定最后一种情况,有一两种常见类型和更多不常见的类型)。 如果有两种以上的常见类型,Hotspot根本不会内联调用,而是生成间接调用的机器代码。
这里的“类型”是指对象的确切类型。 不考虑已实现的接口或共享超类。 即使在调用站点出现不同的接收器类型,但它们都inheritance了方法的相同实现(例如,所有从Object
inheritancehashCode
多个类),Hotspot仍将生成间接调用而不是内联调用。 (因此,在这种情况下,imo热点非常愚蠢。我希望未来的版本可以改进这一点。)
Hotspot也只会内联不太大的方法。 “不太大”由-XX:MaxInlineSize=n
确定-XX:MaxInlineSize=n
和-XX:FreqInlineSize=n
选项。 JVM字节码大小低于MaxInlineSize的Inlinable方法总是内联的,如果调用“热”,则内联JVM字节码大小低于FreqInlineSize的方法。 更大的方法永远不会内联。 默认情况下,MaxInlineSize是35,而FreqInlineSize是平台相关的,但对我来说它是325.所以如果你想让它们内联,请确保你的方法不是太大。 它有时可以帮助从大方法中分离出公共路径,以便可以将其内联到其调用者中。
剖析
关于性能分析的一个重要事项是,性能分析站点基于JVM字节码,它本身不以任何方式内联。 所以如果你有一个静态的方法
static List map(List list, Function func) { List 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
类型集中在一起。 您可能会使用两个以上不同的func
实现,因此Hotspot将无法内联对func.call
的调用。 链接以获取更多详细信息,并将原始链接显示为原始链接 。
(另外,在Kotlin中 ,等效循环可以完全内联,因为Kotlin编译器可以在字节码级别进行内联调用。因此,对于某些用途,它可能比Java快得多。)
标量替换
另一个重要的事情是Hotspot实际上并没有实现对象的堆栈分配。 相反,它实现了标量替换 ,这意味着对象被解构为其组成字段,并且这些字段是像普通局部变量一样分配的。 这意味着根本没有任何物体。 标量替换仅在从不需要创建指向堆栈分配对象的指针时才有效。 在例如C ++或Go中,某些forms的堆栈分配将能够在堆栈上分配完整对象,然后将引用或指针传递给它们到被调用函数,但在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
编译并运行此程序javac Scalarization.java; java -verbose:gc Scalarization
javac Scalarization.java; java -verbose:gc Scalarization
你可以看到标量替换是否由垃圾收集的数量起作用。 如果标量替换工作,我的系统上没有发生垃圾收集,如果标量替换不起作用,我会看到一些垃圾收集。
Hotspot能够进行scalarize的变体运行速度明显快于没有变化的版本。 我validation了生成的机器代码( 说明 ),以确保Hotspot没有进行任何意外的优化。 如果热点能够标量替换分配,那么它还可以在循环上进行一些额外的优化,展开几次迭代然后将这些迭代组合在一起。 因此,在scalarized版本中,每个迭代器执行多个源代码级迭代的工作时,有效循环计数较低。 因此速度差异不仅仅是由于分配和垃圾收集开销。
意见
我尝试了上述程序的一些变体。 标量替换的一个条件是永远不能将对象分配给对象(或静态)字段,并且可能也不会分配给数组。 所以在代码中
Foo f = new Foo(); bar.field = foo;
Foo
对象不能被标量替换。 即使bar
本身被标量替换,并且如果你再也不使用bar.field
。 因此,只能将对象分配给局部变量。
仅凭这一点还不够,Hotspot还必须能够在JIT时间静态地确定哪个对象实例将成为呼叫的目标。 例如,使用foo
的以下实现以及test
和删除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仍然会对分配进行scalarize。
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
条件赋权仅在程序的生命周期内执行一次。 如果此分配发生得足够早,在Hotspot开始对test
方法进行完整分析之前,Hotspot从不会注意到所采用的条件,并编译执行标量替换的代码。 如果在采取条件时已经开始进行性能分析,Hotspot将不会进行标量替换。 使用0xf9a0
的测试值,标量替换是否发生在我的计算机上是不确定的,因为完全在分析开始时可能会有所不同(例如,因为分析和优化的代码是在后台线程上编译的)。 因此,如果我运行上面的变体,它有时会做一些垃圾收集,有时则不会。
Hotspot的静态代码分析比C / C ++和其他静态编译器可以做的更加有限,因此Hotspot在通过几个条件和其他控制结构来跟踪方法中的控制流以确定变量引用的实例时并不聪明即使它对于程序员或更智能的编译器是静态可确定的。 在许多情况下,分析信息将弥补这一点,但需要注意的是。
数组
如果在JIT时间知道它们的大小,则可以分配堆栈。 但是,除非Hotspot还能在JIT时间静态地确定索引值,否则不支持索引到数组中。 所以堆栈分配的数组是没用的。 由于大多数程序不直接使用数组而是使用标准集合,因此这不是非常相关,因为嵌入对象(例如包含ArrayList中的数据的数组)由于其嵌入式而需要进行堆分配。 我认为这种限制的原因是对局部变量不存在索引操作,因此这需要额外的代码生成function以用于非常罕见的用例。