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