JDK8中触发遗漏异常栈帧的方法执行次数如何计算?

How is the count of method executions that triggers the omitted exception stack frames calculated in JDK 8?

JDK版本:

java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, mixed mode)

如果我们编写如下代码:

import java.util.concurrent.ThreadLocalRandom;

public class Test {

    public static void main(String[] args) {
        int exceptionCount = 0;
        while (true) {
            try {
                Object object = null;
                if (ThreadLocalRandom.current().nextInt(2) == 0) {
                    object = new Object();
                }
                object.hashCode();
            } catch (Exception e) {
                System.err.println(++exceptionCount);
                e.printStackTrace();
            }
        }
    }

}

部分输出为:

21123
java.lang.NullPointerException
    at Test.main(Test.java:13)
21124
java.lang.NullPointerException
    at Test.main(Test.java:13)
21125
java.lang.NullPointerException
21126
java.lang.NullPointerException

即异常发生21124次后不再打印栈帧

我们对上面的代码做一个小改动,注意nextInt方法的参数:

import java.util.concurrent.ThreadLocalRandom;

public class Test {

    public static void main(String[] args) {
        int exceptionCount = 0;
        while (true) {
            try {
                Object object = null;
                if (ThreadLocalRandom.current().nextInt() == 0) {
                    object = new Object();
                }
                object.hashCode();
            } catch (Exception e) {
                System.err.println(++exceptionCount);
                e.printStackTrace();
            }
        }
    }

}

部分输出为:

115711
java.lang.NullPointerException
    at Test.main(Test.java:13)
115712
java.lang.NullPointerException
    at Test.main(Test.java:13)
115713
java.lang.NullPointerException
115714
java.lang.NullPointerException

即异常发生115712次后不再打印栈帧

现在想知道这个触发省略异常栈帧的方法的执行次数是怎么计算的?

参考:
Release Notes
jdk/graphKit.cpp at jdk8-b120 · openjdk/jdk · GitHub

为什么一个小的变化会造成如此大的差异确实很有趣,但是,答案一点也不简单。

首先,Java 方法可能会在解释器中 运行 或进行 JIT 编译。在 tiered compilation mode(默认)中,一个方法可能会以不同的优化级别编译多次。

OmitStackTraceInFastThrow 优化仅适用于 C2 编译代码(最高层)。当一个方法被解释或 C1 编译时,无论迭代次数如何,NPE 总是会有一个堆栈跟踪。所以问题归结为 C2 编译该方法的速度有多快。但是很难预测这个数字,因为 非常复杂,涉及很多因素。

让我们考虑一个简化的例子:

public class Test {

    public static void main(String[] args) {
        // Uncomment the following line to see the difference
        // args.hashCode();

        int exceptionCount = 0;
        while (true) {
            try {
                ((Object) null).hashCode();
            } catch (Exception e) {
                exceptionCount++;
                if (e.getStackTrace().length == 0) {
                    System.out.println(exceptionCount);
                    break;
                }
            }
        }
    }
}

它打印出一个接近 115000 的数字,就像在您的测试中一样。差异的原因是 JIT 编译 运行 在后台进行,即在编译方法时,它会在解释器或较低层继续执行。

为了消除这种差异,让我们运行禁用后台编译:-Xbatch
现在输出变得稳定:106496。为什么会这样?

  1. Test.main 在解释器中启动 运行ning。每 1024 次循环迭代(2Tier0BackedgeNotifyFreqLog = 210 = 1024)解释器调用 JIT 编译策略以查看是否需要编译方法。
  2. 一旦迭代次数超过 60000 (-XX:Tier3BackEdgeThreshold),该方法就会在第 3 层编译(C1 带分析)。给定 1024 步,这发生在 1024*59 = 60416 次迭代之后。
  3. 在第 3 层编译的方法调用 JIT 策略的频率比解释器低得多 - 更准确地说,在 8192 次迭代中调用一次 (2Tier3BackedgeNotifyFreqLog = 213).
  4. 经过 40000 次以上的循环迭代 (-XX:Tier4BackEdgeThreshold),该方法可以在第 4 层进行重新编译。在我们的例子中,这正好发生在 8192*5 = 40960 次迭代之后。
  5. 最初,C2 编译的方法不包含用于抛出的快速路径 NullPointerException。第一次调用 null 对象上的方法会导致不常见的陷阱,并且执行回退到解释器。
  6. 和(1)一样,解释器在1024次循环迭代后再次调用编译策略。
  7. 编译策略将方法识别为热方法并安装以前编译的 C2 版本,这会立即导致由于 NPE 而产生新的不常见陷阱。
  8. 然后重复步骤 (5)-(7) 4 次 (-XX:PerBytecodeTrapLimit)。超过限制后,在第 5 次尝试时,该方法最终使用 NPE 的快速路径重新编译。这个“快速”的 NPE 不再有堆栈跟踪。

上述生命周期中循环迭代的总次数为

60416 + 40960 + 5*1024 = 106496

令人惊讶的是,如果我取消注释 args.hashCode() 调用,迭代次数将下降到 40961。为什么?

即使该行看起来无关,但它会影响常量池解析。 args.hashCode()((Object) null).hashCode()在常量池中引用同一个CONSTANT_Methodref_info。第一次成功的方法调用解析了这个常量池条目。

如果 Methodref 未解析且接收者为 null,HotSpot JVM 在解析 invoke 指令期间立即抛出 NullPointerException

但是对于已解析的 Methodref,JVM 从代码中的不同位置抛出 NullPointerException。这另一条路径记录了 MethodData 结构中抛出异常的事实:

MethodData 是一个 JVM 结构,它包含用于 JIT 编译的方法的 运行 时间配置文件。配置文件的存在使 JVM 直接在第 4 层编译方法(因为如果解释器中已经收集了配置文件,则不需要在第 3 层收集配置文件)。

在这种情况下,当循环迭代次数达到40000次(-XX:Tier4BackEdgeThreshold)时,该方法被安排在第4层进行编译。但是由于反馈步骤为1024(2Tier0BackedgeNotifyFreqLog ), JIT编译前的实际迭代次数为40*1024 = 40960.

总结

回到你原来的例子。当ThreadLocalRandom.current().nextInt(2) == 0为真时,hashCode()成功解析并执行。随后的 NPE 从 invokevirtual 实现中抛出,作为副作用,JVM 为 main 方法创建了 MethodData。当循环迭代次数超过 40000 次(其中一半导致正常调用,一半导致抛出异常)时,该方法将由 C2 以 OmitStackTraceInFastThrow 优化进行编译。因此在优化之前有 20000 多个异常。

相反,ThreadLocalRandom.current().nextInt() == 0 几乎永远不会是真的。因此 hashCode() 调用永远不会成功:在解析常量池条目时抛出 NPE,并且该条目仍未解析。这就是为什么该方法首先由 C1 编译(在 60K 次迭代后),然后由 C2 重新编译(在 40K 次以上迭代后)。所以该方法之前总共抛出超过100K个异常OmitStackTraceInFastThrow 进行了优化。