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