如果堆栈跟踪的长度为偶数,JIT 会在多次迭代后重新编译以快速抛出
JIT recompiles to do fast Throw after more iterations if stacktrace is of even length
下面的代码,
public class TestFastThrow {
public static void main(String[] args) {
int count = 0;
int exceptionStackTraceSize = 0;
Exception exception = null;
do {
try {
throwsNPE(1);
}
catch (Exception e) {
exception = e;
if (exception.getStackTrace().length != 0) {
exceptionStackTraceSize = exception.getStackTrace().length;
count++;
}
}
}
while (exception.getStackTrace().length != 0);
System.out.println("Iterations to fastThrow :" + count + ", StackTraceSize :" + exceptionStackTraceSize);
}
static void throwsNPE(int callStackLength) {
throwsNPE(callStackLength, 0);
}
static void throwsNPE(int callStackLength, int count) {
if (count == callStackLength) {
((Object) null).getClass();
}
else {
throwsNPE(callStackLength, count + 1);
}
}
}
多次运行后给出如下输出,
Iterations to fastThrow :5517, StackTraceSize :4
Iterations to fastThrow :2825, StackTraceSize :5
Iterations to fastThrow :471033, StackTraceSize :6
Iterations to fastThrow :1731, StackTraceSize :7
Iterations to fastThrow :157094, StackTraceSize :10
.
.
.
Iterations to fastThrow :64587, StackTraceSize :20
Iterations to fastThrow :578, StackTraceSize :29
虚拟机详细信息
Java HotSpot(TM) 64-Bit Server VM (11.0.5+10-LTS) for bsd-amd64 JRE (11.0.5+10-LTS)
-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:+PrintAssembly
令人惊讶的是,如果堆栈跟踪的长度是偶数,为什么 JIT 需要更多的迭代来优化?
我启用了 JIT 日志并通过 jitwatch 进行了分析,但没有看到任何有用的信息,只是 C1 和 C2 编译的时间线似乎在稍后发生,用于偶数大小的堆栈跟踪。
时间线是这样的,(看java.lang.Throwable.getStackTrace()
编译的时候)
| StackSize | 10 | 11 |
|---------------|-------|-------|
| Queued for C1 | 1.099 | 1.012 |
| C1 | 1.318 | 1.162 |
| Queued for C2 | 1.446 | 1.192 |
| C2 | 1.495 | 1.325 |
为什么会这样? JIT 使用什么启发式方法来快速抛出?
此效果是技巧 的结果。
让我解释一下简化的例子:
public class TestFastThrow {
public static void main(String[] args) {
for (int iteration = 0; ; iteration++) {
try {
throwsNPE(2);
} catch (Exception e) {
if (e.getStackTrace().length == 0) {
System.out.println("Iterations to fastThrow: " + iteration);
break;
}
}
}
}
static void throwsNPE(int depth) {
if (depth <= 1) {
((Object) null).getClass();
}
throwsNPE(depth - 1);
}
}
为简单起见,我将从编译中排除所有方法,除了 throwsNPE
。
-XX:CompileCommand=compileonly,TestFastThrow::throwsNPE -XX:+PrintCompilation
HotSpot 默认使用分层编译。这里 throwsNPE
首先在第 3 层编译(带分析的 C1)。在 C1 中进行分析使得以后可以通过 C2 重新编译该方法。
OmitStackTraceInFastThrow
优化仅在 C2 编译代码中有效。因此,C2 越早编译代码 - 循环完成之前通过的迭代越少。
C1 编译代码中的分析工作原理:计数器在每次方法调用和每个向后分支上递增(但是,throwsNPE
方法中没有向后分支)。当计数器达到某个可配置的阈值时,JVM 编译策略决定当前方法是否需要重新编译。
throwsNPE
是一种递归方法。 HotSpot 最多可以内联递归调用 -XX:MaxRecursiveInlineLevel
(默认值为 1)。
C1 编译代码回调 JVM 编译策略的频率,对于常规调用和内联调用是不同的。常规方法每 210 次调用 (-XX:Tier3InvokeNotifyFreqLog=10
) 通知 JVM,而内联方法通知 JVM 的次数要少得多:每 220调用 (-XX:Tier23InlineeNotifyFreqLog=20
).
对于偶数次递归调用,所有调用都遵循Tier23InlineeNotifyFreqLog
参数。当调用次数为奇数时,内联对最后一次剩余调用不起作用,最后一次调用遵循Tier3InvokeNotifyFreqLog
参数。
这意味着,当调用深度为偶数时,throwsNPE
只会在220次调用后重新编译,即在2[=70次之后=]19 次循环迭代。当您 运行 上述代码与 throwNPE(2)
:
时,这正是您所看到的
Iterations to fastThrow: 524536
524536 非常接近 219 = 524288
现在,如果你运行与-XX:Tier23InlineeNotifyFreqLog=15
相同的应用,迭代次数将接近214 = 16384.
Iterations to fastThrow: 16612
现在让我们更改代码以调用throwsNPE(1)
。无论 Tier23InlineeNotifyFreqLog
值如何,程序都会很快完成。那是因为现在有不同的选择规则。但是如果我用 -XX:Tier3InvokeNotifyFreqLog=20
重新 运行 程序,循环将不会早于 220 次迭代后完成:
Iterations to fastThrow: 1048994
总结
快速抛出优化仅适用于 C2 编译代码。由于一级内联 (-XX:MaxRecursiveInlineLevel
),C2 编译触发较早(在 2Tier3InvokeNotifyFreqLog 调用之后,如果递归调用的次数为奇数),或较晚(在 2 Tier23InlineeNotifyFreqLog 调用,如果所有递归调用都被内联覆盖)。
下面的代码,
public class TestFastThrow {
public static void main(String[] args) {
int count = 0;
int exceptionStackTraceSize = 0;
Exception exception = null;
do {
try {
throwsNPE(1);
}
catch (Exception e) {
exception = e;
if (exception.getStackTrace().length != 0) {
exceptionStackTraceSize = exception.getStackTrace().length;
count++;
}
}
}
while (exception.getStackTrace().length != 0);
System.out.println("Iterations to fastThrow :" + count + ", StackTraceSize :" + exceptionStackTraceSize);
}
static void throwsNPE(int callStackLength) {
throwsNPE(callStackLength, 0);
}
static void throwsNPE(int callStackLength, int count) {
if (count == callStackLength) {
((Object) null).getClass();
}
else {
throwsNPE(callStackLength, count + 1);
}
}
}
多次运行后给出如下输出,
Iterations to fastThrow :5517, StackTraceSize :4
Iterations to fastThrow :2825, StackTraceSize :5
Iterations to fastThrow :471033, StackTraceSize :6
Iterations to fastThrow :1731, StackTraceSize :7
Iterations to fastThrow :157094, StackTraceSize :10
.
.
.
Iterations to fastThrow :64587, StackTraceSize :20
Iterations to fastThrow :578, StackTraceSize :29
虚拟机详细信息
Java HotSpot(TM) 64-Bit Server VM (11.0.5+10-LTS) for bsd-amd64 JRE (11.0.5+10-LTS)
-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:+PrintAssembly
令人惊讶的是,如果堆栈跟踪的长度是偶数,为什么 JIT 需要更多的迭代来优化?
我启用了 JIT 日志并通过 jitwatch 进行了分析,但没有看到任何有用的信息,只是 C1 和 C2 编译的时间线似乎在稍后发生,用于偶数大小的堆栈跟踪。
时间线是这样的,(看java.lang.Throwable.getStackTrace()
编译的时候)
| StackSize | 10 | 11 |
|---------------|-------|-------|
| Queued for C1 | 1.099 | 1.012 |
| C1 | 1.318 | 1.162 |
| Queued for C2 | 1.446 | 1.192 |
| C2 | 1.495 | 1.325 |
为什么会这样? JIT 使用什么启发式方法来快速抛出?
此效果是技巧
让我解释一下简化的例子:
public class TestFastThrow {
public static void main(String[] args) {
for (int iteration = 0; ; iteration++) {
try {
throwsNPE(2);
} catch (Exception e) {
if (e.getStackTrace().length == 0) {
System.out.println("Iterations to fastThrow: " + iteration);
break;
}
}
}
}
static void throwsNPE(int depth) {
if (depth <= 1) {
((Object) null).getClass();
}
throwsNPE(depth - 1);
}
}
为简单起见,我将从编译中排除所有方法,除了 throwsNPE
。
-XX:CompileCommand=compileonly,TestFastThrow::throwsNPE -XX:+PrintCompilation
HotSpot 默认使用分层编译。这里
throwsNPE
首先在第 3 层编译(带分析的 C1)。在 C1 中进行分析使得以后可以通过 C2 重新编译该方法。OmitStackTraceInFastThrow
优化仅在 C2 编译代码中有效。因此,C2 越早编译代码 - 循环完成之前通过的迭代越少。C1 编译代码中的分析工作原理:计数器在每次方法调用和每个向后分支上递增(但是,
throwsNPE
方法中没有向后分支)。当计数器达到某个可配置的阈值时,JVM 编译策略决定当前方法是否需要重新编译。throwsNPE
是一种递归方法。 HotSpot 最多可以内联递归调用-XX:MaxRecursiveInlineLevel
(默认值为 1)。C1 编译代码回调 JVM 编译策略的频率,对于常规调用和内联调用是不同的。常规方法每 210 次调用 (
-XX:Tier3InvokeNotifyFreqLog=10
) 通知 JVM,而内联方法通知 JVM 的次数要少得多:每 220调用 (-XX:Tier23InlineeNotifyFreqLog=20
).对于偶数次递归调用,所有调用都遵循
Tier23InlineeNotifyFreqLog
参数。当调用次数为奇数时,内联对最后一次剩余调用不起作用,最后一次调用遵循Tier3InvokeNotifyFreqLog
参数。这意味着,当调用深度为偶数时,
时,这正是您所看到的throwsNPE
只会在220次调用后重新编译,即在2[=70次之后=]19 次循环迭代。当您 运行 上述代码与throwNPE(2)
:Iterations to fastThrow: 524536
524536 非常接近 219 = 524288
现在,如果你运行与
-XX:Tier23InlineeNotifyFreqLog=15
相同的应用,迭代次数将接近214 = 16384.Iterations to fastThrow: 16612
现在让我们更改代码以调用
throwsNPE(1)
。无论Tier23InlineeNotifyFreqLog
值如何,程序都会很快完成。那是因为现在有不同的选择规则。但是如果我用-XX:Tier3InvokeNotifyFreqLog=20
重新 运行 程序,循环将不会早于 220 次迭代后完成:Iterations to fastThrow: 1048994
总结
快速抛出优化仅适用于 C2 编译代码。由于一级内联 (-XX:MaxRecursiveInlineLevel
),C2 编译触发较早(在 2Tier3InvokeNotifyFreqLog 调用之后,如果递归调用的次数为奇数),或较晚(在 2 Tier23InlineeNotifyFreqLog 调用,如果所有递归调用都被内联覆盖)。