我做了一些实验,以便了解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的转义分析在这方面比Choi论文中的描述更强大,因为从方法返回但在调用方法本地的对象仍然可以进行堆栈分配.因此,如果您执行此操作,则迭代器几乎总是可以进行堆栈分配. for(Foo item:myList){…}(myList.iterator()的实现很简单,它们通常都是.)
Hotspot只有在确定方法“热”时才编译优化版本的方法,因此很多次运行的代码根本没有得到优化,在这种情况下,没有堆栈分配或内联.但对于那些你通常不在乎的方法.
内联
内联决策基于Hotspot首先收集的分析数据.声明的类型并不重要,即使方法是虚拟的,Hotspot也可以根据它在分析期间看到的对象的类型来内联它.类似的东西适用于分支(即if语句和其他控制流构造):如果在分析期间Hotspot从未看到某个分支被采用,它将基于从不采用分支的假设来编译和优化代码.在这两种情况下,如果Hotspot无法证明其假设始终为真,则会在已编译的代码中插入检查,称为“不常见的陷阱”,如果遇到此类陷阱,Hotspot将进行去优化并可能重新优化新信息考虑在内.
Hotspot将分析哪些对象类型作为呼叫站点的接收者.如果Hotspot只看到一个类型或在调用站点只发现两种不同的类型,则它能够内联调用的方法.如果只有一个或两个非常常见的类型,而其他类型的出现频率低得多,Hotspot还应该能够内联常见类型的方法,包括检查它需要采取哪些代码. (我不完全确定最后一种情况,有一两种常见类型和更多不常见的类型).如果有两种以上的常见类型,Hotspot根本不会内联调用,而是生成间接调用的机器代码.
这里的“类型”是指对象的确切类型.不考虑已实现的接口或共享超类.即使在调用站点出现不同的接收器类型,但它们都继承了方法的相同实现(例如,所有从Object继承hashCode的多个类),Hotspot仍将生成间接调用而不是内联调用. (所以i.m.o.在这种情况下,热点是非常愚蠢的.我希望未来版本能改进这一点.)
Hotspot也只会内联不太大的方法. “不太大”由-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函数并返回转换后的列表,Hotspot会将对func.call的调用视为单个程序范围的调用站点.您可以在程序中的多个位置调用此地图功能,在每个呼叫站点传递不同的功能(但对于一个呼叫站点则相同).在这种情况下,您可能希望Hotspot能够内联映射,然后调用func.call,因为在每次使用map时,只有一个func类型.如果是这样的话,Hotspot将能够非常紧密地优化循环.不幸的是,Hotspot对此并不够聪明.它只为func.call调用站点保留一个配置文件,将所有传递给它的func类型集中在一起.您可能会使用两个以上不同的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能够进行scalarize的变种运行速度明显快于不能运行的变种.我验证了生成的机器代码(instructions),以确保Hotspot没有进行任何意外的优化.如果热点能够标量替换分配,那么它还可以在循环上进行一些额外的优化,展开几次迭代,然后将这些迭代组合在一起.因此,在scalarized版本中,每个迭代器执行多个源代码级迭代的工作时,有效循环计数较低.因此速度差异不仅仅是由于分配和垃圾收集开销.
意见
我尝试了上述程序的一些变体.标量替换的一个条件是对象绝不能分配给对象(或静态)字段,并且可能也不会分配给数组.所以在代码中
Foo f = new Foo();
bar.field = f;
Foo对象不能被标量替换.即使条本身被标量替换,并且如果你再也不使用bar.field,这就成立了.因此,只能将对象分配给局部变量.
仅凭这一点还不够,Hotspot还必须能够在JIT时间静态地确定哪个对象实例将成为呼叫的目标.例如,使用以下foo实现以及test和remove字段会导致堆分配:
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似乎无法将对象移动到之前被标量替换的堆中(至少在没有去优化的情况下).标量替换是一种全有或全无的事情.因此在原始测试方法中,Scalarization的两个分配总是发生在堆上.
条件语句
一个重要的细节是Hotspot将根据其分析数据预测条件.如果从未执行条件赋值,Hotspot将根据该假设编译代码,然后可能能够进行标量替换.如果在稍后的时间点确实采取了条件,Hotspot将需要使用这个新假设重新编译代码.新代码不会进行标量替换,因为Hotspot无法再静态地确定以下调用的接收器实例.
例如,在这个测试变体中:
static long limit = 0;
static long test() {
long ctr = 0x5;
long i = limit;
limit += 0x10000;
for(; i
Scalarization s = new Scalarization();
ctr = s.foo(ctr);
if(i == 0xf9a0) s = new Scalarization();
ctr = s.foo(ctr);
}
return ctr;
}
条件赋权仅在程序的生命周期内执行一次.如果此分配发生得足够早,在Hotspot开始对测试方法进行完整分析之前,Hotspot从不会注意到所采用的条件并编译执行标量替换的代码.如果在采取条件时已经开始进行分析,则Hotspot将不会进行标量替换.使用0xf9a0的测试值,标量替换是否发生在我的计算机上是不确定的,因为完全在分析开始时可能会有所不同(例如,因为分析和优化的代码是在后台线程上编译的).因此,如果我运行上述变体,它有时会执行一些垃圾收集,有时则不会.
Hotspot的静态代码分析比C/C++和其他静态编译器可以做的更加有限,因此Hotspot在通过几个条件和其他控制结构来跟踪方法中的控制流以确定变量引用的实例时并不聪明即使它对程序员或更智能的编译器是静态可确定的.在许多情况下,分析信息将弥补这一点,但需要注意的是.
数组
如果在JIT时间知道它们的大小,则可以分配堆栈.但是,除非Hotspot还能在JIT时间静态地确定索引值,否则不支持索引到数组中.所以堆栈分配的数组是没用的.由于大多数程序不直接使用数组而是使用标准集合,因此这不是非常相关,因为嵌入对象(例如包含ArrayList中的数据的数组)由于其嵌入式而需要进行堆分配.我认为这种限制的原因是对局部变量不存在索引操作,因此这需要额外的代码生成功能来处理非常罕见的用例.