为什么导致 StackOverflowError 的递归方法的调用次数在程序运行之间有所不同?
Why does the count of calls of a recursive method causing a StackOverflowError vary between program runs?
用于演示目的的简单 class:
public class Main {
private static int counter = 0;
public static void main(String[] args) {
try {
f();
} catch (WhosebugError e) {
System.out.println(counter);
}
}
private static void f() {
counter++;
f();
}
}
上面的程序我执行了5次,结果是:
22025
22117
15234
21993
21430
为什么每次的结果都不一样?
我尝试设置最大堆栈大小(例如 -Xss256k
)。然后结果更加一致,但每次都不相等。
Java版本:
java version "1.8.0_72"
Java(TM) SE Runtime Environment (build 1.8.0_72-b15)
Java HotSpot(TM) 64-Bit Server VM (build 25.72-b15, mixed mode)
编辑
当 JIT 被禁用时 (-Djava.compiler=NONE
) 我总是得到相同的数字 (11907
)。
这是有道理的,因为 JIT 优化可能会影响堆栈帧的大小,并且 JIT 完成的工作肯定在执行之间有所不同。
尽管如此,我认为如果通过参考一些关于该主题的文档来证实这一理论将是有益的 and/or JIT 在这个导致帧大小变化的特定示例中所做工作的具体示例。
Java 堆栈的确切功能未记录,但这完全取决于分配给该线程的内存。
只需尝试将 Thread 构造函数与 stacksize 一起使用,看看它是否保持不变。我没有试过,所以请分享结果。
首先,以下没有研究。我没有 "deep dived" OpenJDK 源代码来验证以下任何内容,而且我无权访问任何内部知识。
我试图通过 运行 你在我机器上的测试来验证你的结果:
$ java -version
openjdk version "1.8.0_71"
OpenJDK Runtime Environment (build 1.8.0_71-b15)
OpenJDK 64-Bit Server VM (build 25.71-b15, mixed mode)
我得到 "count" 在 ~250 范围内变化。 (没有你看到的那么多)
首先介绍一下背景。典型 Java 实现中的线程堆栈是在线程启动之前分配的连续内存区域,并且永远不会增长或移动。当 JVM 尝试创建堆栈帧以进行方法调用并且该帧超出内存区域的限制时,就会发生堆栈溢出。测试 可以 通过显式测试 SP 来完成,但我的理解是它通常是使用内存页面设置的巧妙技巧来实现的。
分配堆栈区域时,JVM 进行系统调用以告知 OS 将堆栈区域末尾的 "red zone" 页面标记为只读或不可访问。当线程进行溢出堆栈的调用时,它会访问 "red zone" 中的内存,这会触发内存错误。 OS 通过 "signal" 告诉 JVM,JVM 的信号处理程序将其映射到 WhosebugError
,即线程堆栈上的 "thrown"。
所以这里有几个可能对可变性的解释:
基于硬件的内存保护粒度是页边界。因此,如果使用 malloc
分配了线程堆栈,则该区域的开始不会进行页面对齐。因此,从堆栈帧的开始到 "red zone" 的第一个字(>is< 页面对齐)的距离将是可变的。
"main" 堆栈可能是特殊的,因为该区域 可能会 在 JVM 引导时使用。这可能会导致一些 "stuff" 在调用 main
之前留在堆栈中。 (这个没有说服力……我也不服气。)
话虽如此,您所看到的 "large" 可变性令人费解。页面大小太小,无法解释 ~7000 的计数差异。
更新
When JIT is disabled (-Djava.compiler=NONE) I always get the same number (11907).
有趣。除其他外,这可能会导致堆栈限制检查以不同方式完成。
This makes sense as JIT optimizations are probably affecting the size of stack frames and the work done by JIT definitely has to vary between the executions.
有道理。在 JIT 编译 f()
方法之后,堆栈帧的大小可能会有所不同。假设 f()
是 JIT 在某个时候编译的,你的堆栈将混合有 "old" 和 "new" 帧。如果 JIT 编译发生在不同的点,那么比率就会不同......因此当你达到限制时 count
会不同。
Nevertheless, I think it would be beneficial if this theory is confirmed with references to some documentation about the topic and/or concrete examples of work done by JIT in this specific example that leads to frame size changes.
这种可能性很小,恐怕......除非你准备付钱请人为你做几天研究。
1) 不存在此类 (public) 参考文档,AFAIK。至少,除了深入研究源代码之外,我一直没能找到这种东西的权威来源。
2) 查看 JIT 编译代码并不能告诉您在代码被 JIT 编译之前字节码解释器是如何处理的。所以您将无法看到帧大小是否已 更改。
观察到的差异是由后台 JIT 编译引起的。
过程是这样的:
- 方法
f()
在解释器中开始执行。
- 经过多次调用(大约 250 次)后,该方法将被安排编译。
- 编译器线程与应用程序线程并行工作。同时该方法继续在解释器中执行。
- 编译器线程完成编译后,方法入口点将被替换,因此下一次调用
f()
将调用方法的编译版本。
应用程序线程和 JIT 编译器线程之间基本上存在竞争。在方法的编译版本准备就绪之前,解释器可能会执行不同数量的调用。最后是解释和编译帧的混合。
难怪编译的框架布局与解释的不同。编译帧通常较小;他们不需要将所有执行上下文存储在堆栈上(方法引用、常量池引用、分析器数据、所有参数、表达式变量等)
此外, 有更多的比赛可能性(自 JDK 8 起默认)。可以有 3 种类型的帧的组合:解释器、C1 和 C2(见下文)。
让我们做一些有趣的实验来支持这个理论。
纯解释模式。没有 JIT 编译。
没有比赛 => 稳定的结果。
$ java -Xint Main
11895
11895
11895
禁用后台编译。 JIT 开启,但与应用程序线程同步。
再次没有比赛,但由于编译帧,现在调用次数更高。
$ java -XX:-BackgroundCompilation Main
23462
23462
23462
在执行之前用C1编译一切。与之前的情况不同,堆栈中没有解释的帧,因此数字会更高一些。
$ java -Xcomp -XX:TieredStopAtLevel=1 Main
23720
23720
23720
现在在执行之前用C2编译所有东西。这将产生具有最小框架的最优化代码。调用次数将是最高的。
$ java -Xcomp -XX:-TieredCompilation Main
59300
59300
59300
由于默认堆栈大小为 1M,这应该意味着帧现在只有 16 字节长。是吗?
$ java -Xcomp -XX:-TieredCompilation -XX:CompileCommand=print,Main.f Main
0x00000000025ab460: mov %eax,-0x6000(%rsp) ; Whosebug check
0x00000000025ab467: push %rbp ; frame link
0x00000000025ab468: sub [=14=]x10,%rsp
0x00000000025ab46c: movabs [=14=]xd7726ef0,%r10 ; r10 = Main.class
0x00000000025ab476: addl [=14=]x2,0x68(%r10) ; Main.counter += 2
0x00000000025ab47b: callq 0x00000000023c6620 ; invokestatic f()
0x00000000025ab480: add [=14=]x10,%rsp
0x00000000025ab484: pop %rbp ; pop frame
0x00000000025ab485: test %eax,-0x23bb48b(%rip) ; safepoint poll
0x00000000025ab48b: retq
其实这里的frame是32字节,只是JIT内联了一层递归
最后,让我们看一下混合堆栈跟踪。为了得到它,我们将在 WhosebugError 上使 JVM 崩溃(调试版本中可用的选项)。
$ java -XX:AbortVMOnException=java.lang.WhosebugError Main
故障转储 hs_err_pid.log
包含详细的堆栈跟踪,我们可以在其中找到底部的已解释帧,中间的 C1 帧,最后是顶部的 C2 帧。
Java frames: (J=compiled Java code, j=interpreted, Vv=VM code)
J 164 C2 Main.f()V (12 bytes) @ 0x00007f21251a5958 [0x00007f21251a5900+0x0000000000000058]
J 164 C2 Main.f()V (12 bytes) @ 0x00007f21251a5920 [0x00007f21251a5900+0x0000000000000020]
// ... repeated 19787 times ...
J 164 C2 Main.f()V (12 bytes) @ 0x00007f21251a5920 [0x00007f21251a5900+0x0000000000000020]
J 163 C1 Main.f()V (12 bytes) @ 0x00007f211dca50ec [0x00007f211dca5040+0x00000000000000ac]
J 163 C1 Main.f()V (12 bytes) @ 0x00007f211dca50ec [0x00007f211dca5040+0x00000000000000ac]
// ... repeated 1866 times ...
J 163 C1 Main.f()V (12 bytes) @ 0x00007f211dca50ec [0x00007f211dca5040+0x00000000000000ac]
j Main.f()V+8
j Main.f()V+8
// ... repeated 1839 times ...
j Main.f()V+8
j Main.main([Ljava/lang/String;)V+0
v ~StubRoutines::call_stub
用于演示目的的简单 class:
public class Main {
private static int counter = 0;
public static void main(String[] args) {
try {
f();
} catch (WhosebugError e) {
System.out.println(counter);
}
}
private static void f() {
counter++;
f();
}
}
上面的程序我执行了5次,结果是:
22025
22117
15234
21993
21430
为什么每次的结果都不一样?
我尝试设置最大堆栈大小(例如 -Xss256k
)。然后结果更加一致,但每次都不相等。
Java版本:
java version "1.8.0_72"
Java(TM) SE Runtime Environment (build 1.8.0_72-b15)
Java HotSpot(TM) 64-Bit Server VM (build 25.72-b15, mixed mode)
编辑
当 JIT 被禁用时 (-Djava.compiler=NONE
) 我总是得到相同的数字 (11907
)。
这是有道理的,因为 JIT 优化可能会影响堆栈帧的大小,并且 JIT 完成的工作肯定在执行之间有所不同。
尽管如此,我认为如果通过参考一些关于该主题的文档来证实这一理论将是有益的 and/or JIT 在这个导致帧大小变化的特定示例中所做工作的具体示例。
Java 堆栈的确切功能未记录,但这完全取决于分配给该线程的内存。
只需尝试将 Thread 构造函数与 stacksize 一起使用,看看它是否保持不变。我没有试过,所以请分享结果。
首先,以下没有研究。我没有 "deep dived" OpenJDK 源代码来验证以下任何内容,而且我无权访问任何内部知识。
我试图通过 运行 你在我机器上的测试来验证你的结果:
$ java -version
openjdk version "1.8.0_71"
OpenJDK Runtime Environment (build 1.8.0_71-b15)
OpenJDK 64-Bit Server VM (build 25.71-b15, mixed mode)
我得到 "count" 在 ~250 范围内变化。 (没有你看到的那么多)
首先介绍一下背景。典型 Java 实现中的线程堆栈是在线程启动之前分配的连续内存区域,并且永远不会增长或移动。当 JVM 尝试创建堆栈帧以进行方法调用并且该帧超出内存区域的限制时,就会发生堆栈溢出。测试 可以 通过显式测试 SP 来完成,但我的理解是它通常是使用内存页面设置的巧妙技巧来实现的。
分配堆栈区域时,JVM 进行系统调用以告知 OS 将堆栈区域末尾的 "red zone" 页面标记为只读或不可访问。当线程进行溢出堆栈的调用时,它会访问 "red zone" 中的内存,这会触发内存错误。 OS 通过 "signal" 告诉 JVM,JVM 的信号处理程序将其映射到 WhosebugError
,即线程堆栈上的 "thrown"。
所以这里有几个可能对可变性的解释:
基于硬件的内存保护粒度是页边界。因此,如果使用
malloc
分配了线程堆栈,则该区域的开始不会进行页面对齐。因此,从堆栈帧的开始到 "red zone" 的第一个字(>is< 页面对齐)的距离将是可变的。"main" 堆栈可能是特殊的,因为该区域 可能会 在 JVM 引导时使用。这可能会导致一些 "stuff" 在调用
main
之前留在堆栈中。 (这个没有说服力……我也不服气。)
话虽如此,您所看到的 "large" 可变性令人费解。页面大小太小,无法解释 ~7000 的计数差异。
更新
When JIT is disabled (-Djava.compiler=NONE) I always get the same number (11907).
有趣。除其他外,这可能会导致堆栈限制检查以不同方式完成。
This makes sense as JIT optimizations are probably affecting the size of stack frames and the work done by JIT definitely has to vary between the executions.
有道理。在 JIT 编译 f()
方法之后,堆栈帧的大小可能会有所不同。假设 f()
是 JIT 在某个时候编译的,你的堆栈将混合有 "old" 和 "new" 帧。如果 JIT 编译发生在不同的点,那么比率就会不同......因此当你达到限制时 count
会不同。
Nevertheless, I think it would be beneficial if this theory is confirmed with references to some documentation about the topic and/or concrete examples of work done by JIT in this specific example that leads to frame size changes.
这种可能性很小,恐怕......除非你准备付钱请人为你做几天研究。
1) 不存在此类 (public) 参考文档,AFAIK。至少,除了深入研究源代码之外,我一直没能找到这种东西的权威来源。
2) 查看 JIT 编译代码并不能告诉您在代码被 JIT 编译之前字节码解释器是如何处理的。所以您将无法看到帧大小是否已 更改。
观察到的差异是由后台 JIT 编译引起的。
过程是这样的:
- 方法
f()
在解释器中开始执行。 - 经过多次调用(大约 250 次)后,该方法将被安排编译。
- 编译器线程与应用程序线程并行工作。同时该方法继续在解释器中执行。
- 编译器线程完成编译后,方法入口点将被替换,因此下一次调用
f()
将调用方法的编译版本。
应用程序线程和 JIT 编译器线程之间基本上存在竞争。在方法的编译版本准备就绪之前,解释器可能会执行不同数量的调用。最后是解释和编译帧的混合。
难怪编译的框架布局与解释的不同。编译帧通常较小;他们不需要将所有执行上下文存储在堆栈上(方法引用、常量池引用、分析器数据、所有参数、表达式变量等)
此外,
让我们做一些有趣的实验来支持这个理论。
纯解释模式。没有 JIT 编译。
没有比赛 => 稳定的结果。$ java -Xint Main 11895 11895 11895
禁用后台编译。 JIT 开启,但与应用程序线程同步。
再次没有比赛,但由于编译帧,现在调用次数更高。$ java -XX:-BackgroundCompilation Main 23462 23462 23462
在执行之前用C1编译一切。与之前的情况不同,堆栈中没有解释的帧,因此数字会更高一些。
$ java -Xcomp -XX:TieredStopAtLevel=1 Main 23720 23720 23720
现在在执行之前用C2编译所有东西。这将产生具有最小框架的最优化代码。调用次数将是最高的。
$ java -Xcomp -XX:-TieredCompilation Main 59300 59300 59300
由于默认堆栈大小为 1M,这应该意味着帧现在只有 16 字节长。是吗?
$ java -Xcomp -XX:-TieredCompilation -XX:CompileCommand=print,Main.f Main 0x00000000025ab460: mov %eax,-0x6000(%rsp) ; Whosebug check 0x00000000025ab467: push %rbp ; frame link 0x00000000025ab468: sub [=14=]x10,%rsp 0x00000000025ab46c: movabs [=14=]xd7726ef0,%r10 ; r10 = Main.class 0x00000000025ab476: addl [=14=]x2,0x68(%r10) ; Main.counter += 2 0x00000000025ab47b: callq 0x00000000023c6620 ; invokestatic f() 0x00000000025ab480: add [=14=]x10,%rsp 0x00000000025ab484: pop %rbp ; pop frame 0x00000000025ab485: test %eax,-0x23bb48b(%rip) ; safepoint poll 0x00000000025ab48b: retq
其实这里的frame是32字节,只是JIT内联了一层递归
最后,让我们看一下混合堆栈跟踪。为了得到它,我们将在 WhosebugError 上使 JVM 崩溃(调试版本中可用的选项)。
$ java -XX:AbortVMOnException=java.lang.WhosebugError Main
故障转储
hs_err_pid.log
包含详细的堆栈跟踪,我们可以在其中找到底部的已解释帧,中间的 C1 帧,最后是顶部的 C2 帧。Java frames: (J=compiled Java code, j=interpreted, Vv=VM code) J 164 C2 Main.f()V (12 bytes) @ 0x00007f21251a5958 [0x00007f21251a5900+0x0000000000000058] J 164 C2 Main.f()V (12 bytes) @ 0x00007f21251a5920 [0x00007f21251a5900+0x0000000000000020] // ... repeated 19787 times ... J 164 C2 Main.f()V (12 bytes) @ 0x00007f21251a5920 [0x00007f21251a5900+0x0000000000000020] J 163 C1 Main.f()V (12 bytes) @ 0x00007f211dca50ec [0x00007f211dca5040+0x00000000000000ac] J 163 C1 Main.f()V (12 bytes) @ 0x00007f211dca50ec [0x00007f211dca5040+0x00000000000000ac] // ... repeated 1866 times ... J 163 C1 Main.f()V (12 bytes) @ 0x00007f211dca50ec [0x00007f211dca5040+0x00000000000000ac] j Main.f()V+8 j Main.f()V+8 // ... repeated 1839 times ... j Main.f()V+8 j Main.main([Ljava/lang/String;)V+0 v ~StubRoutines::call_stub