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



