如果堆栈跟踪的长度为偶数,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
  1. HotSpot 默认使用分层编译。这里 throwsNPE 首先在第 3 层编译(带分析的 C1)。在 C1 中进行分析使得以后可以通过 C2 重新编译该方法。

  2. OmitStackTraceInFastThrow 优化仅在 C2 编译代码中有效。因此,C2 越早编译代码 - 循环完成之前通过的迭代越少。

  3. C1 编译代码中的分析工作原理:计数器在每次方法调用和每个向后分支上递增(但是,throwsNPE 方法中没有向后分支)。当计数器达到某个可配置的阈值时,JVM 编译策略决定当前方法是否需要重新编译。

  4. throwsNPE是一种递归方法。 HotSpot 最多可以内联递归调用 -XX:MaxRecursiveInlineLevel(默认值为 1)。

  5. C1 编译代码回调 JVM 编译策略的频率,对于常规调用和内联调用是不同的。常规方法每 210 次调用 (-XX:Tier3InvokeNotifyFreqLog=10) 通知 JVM,而内联方法通知 JVM 的次数要少得多:每 220调用 (-XX:Tier23InlineeNotifyFreqLog=20).

  6. 对于偶数次递归调用,所有调用都遵循Tier23InlineeNotifyFreqLog参数。当调用次数为奇数时,内联对最后一次剩余调用不起作用,最后一次调用遵循Tier3InvokeNotifyFreqLog参数。

  7. 这意味着,当调用深度为偶数时,throwsNPE只会在220次调用后重新编译,即在2[=70次之后=]19 次循环迭代。当您 运行 上述代码与 throwNPE(2):

    时,这正是您所看到的
    Iterations to fastThrow: 524536
    

    524536 非常接近 219 = 524288

    现在,如果你运行与-XX:Tier23InlineeNotifyFreqLog=15相同的应用,迭代次数将接近214 = 16384.

    Iterations to fastThrow: 16612
    
  8. 现在让我们更改代码以调用throwsNPE(1)。无论 Tier23InlineeNotifyFreqLog 值如何,程序都会很快完成。那是因为现在有不同的选择规则。但是如果我用 -XX:Tier3InvokeNotifyFreqLog=20 重新 运行 程序,循环将不会早于 220 次迭代后完成:

    Iterations to fastThrow: 1048994
    

总结

快速抛出优化仅适用于 C2 编译代码。由于一级内联 (-XX:MaxRecursiveInlineLevel),C2 编译触发较早(在 2Tier3InvokeNotifyFreqLog 调用之后,如果递归调用的次数为奇数),或较晚(在 2 Tier23InlineeNotifyFreqLog 调用,如果所有递归调用都被内联覆盖)。