为啥导致 ***Error 的递归方法的调用次数在程序运行之间会有所不同? [复制]
Posted
技术标签:
【中文标题】为啥导致 ***Error 的递归方法的调用次数在程序运行之间会有所不同? [复制]【英文标题】:Why does the count of calls of a recursive method causing a ***Error vary between program runs? [duplicate]为什么导致 ***Error 的递归方法的调用次数在程序运行之间会有所不同? [复制] 【发布时间】:2016-06-01 18:31:11 【问题描述】:一个用于演示的简单类:
public class Main
private static int counter = 0;
public static void main(String[] args)
try
f();
catch (***Error 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 所做的工作肯定会在执行之间有所不同。
尽管如此,我认为如果通过参考有关该主题的一些文档和/或 JIT 在此特定示例中所做的工作的具体示例来证实这一理论,这将是有益的,这会导致帧大小发生变化。
【问题讨论】:
主机操作系统为 JVM 提供的资源因程序的不同执行而异,因此最大堆栈大小也不同也就不足为奇了。 你不能在main()
中递归吗?
【参考方案1】:
观察到的差异是由后台 JIT 编译引起的。
流程如下:
-
方法
f()
开始在解释器中执行。
在多次调用(大约 250 次)之后,该方法被安排编译。
编译器线程与应用程序线程并行工作。同时该方法继续在解释器中执行。
一旦编译器线程完成编译,方法入口点就会被替换,因此下一次对f()
的调用将调用该方法的编译版本。
应用程序线程和 JIT 编译器线程之间基本上存在竞争。在方法的编译版本准备好之前,解释器可能会执行不同数量的调用。最后是解释和编译帧的混合。
难怪编译框架布局与解释框架布局不同。编译后的帧通常更小;他们不需要将所有执行上下文存储在堆栈上(方法引用、常量池引用、分析器数据、所有参数、表达式变量等)
此外,Tiered Compilation(自 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) ; *** check
0x00000000025ab467: push %rbp ; frame link
0x00000000025ab468: sub $0x10,%rsp
0x00000000025ab46c: movabs $0xd7726ef0,%r10 ; r10 = Main.class
0x00000000025ab476: addl $0x2,0x68(%r10) ; Main.counter += 2
0x00000000025ab47b: callq 0x00000000023c6620 ; invokestatic f()
0x00000000025ab480: add $0x10,%rsp
0x00000000025ab484: pop %rbp ; pop frame
0x00000000025ab485: test %eax,-0x23bb48b(%rip) ; safepoint poll
0x00000000025ab48b: retq
其实这里的帧是32字节,但是JIT已经内联了一层递归。
最后,让我们看看混合堆栈跟踪。为了得到它,我们将在 ***Error 上使 JVM 崩溃(调试版本中可用的选项)。
$ java -XX:AbortVMOnException=java.lang.***Error 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
【讨论】:
感谢示例的详细解释。为答案和问题 +1。 有趣的是,这种行为不仅出现在 Java 中,在 C 程序中也可见。 JIT 编译并不是此类行为的唯一解释(尽管它在 Java 程序中不太可能出现)。看到这个答案:***.com/a/31183987/1411628. 将此主题与 C 堆栈中发生的任何事情联系起来是无稽之谈。【参考方案2】:首先,以下内容尚未研究。我没有“深入研究”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)
我得到的“计数”在 ~250 的范围内变化。 (没有你看到的那么多)
首先是一些背景。典型 Java 实现中的线程堆栈是在线程启动之前分配的连续内存区域,并且永远不会增长或移动。当 JVM 尝试创建堆栈帧以进行方法调用时,会发生堆栈溢出,并且该帧超出了内存区域的限制。测试可以通过显式测试 SP 来完成,但我的理解是它通常是使用内存页面设置的巧妙技巧来实现的。
当分配堆栈区域时,JVM 会进行系统调用以告诉操作系统将堆栈区域末尾的“红色区域”页面标记为只读或不可访问。当一个线程进行溢出堆栈的调用时,它会访问“红色区域”中的内存,从而触发内存故障。操作系统通过“信号”告诉 JVM,JVM 的信号处理程序将其映射到线程堆栈上“抛出”的***Error
。
所以这里有几个可能的对可变性的解释:
基于硬件的内存保护的粒度是页面边界。因此,如果线程堆栈已使用malloc
分配,则该区域的开始不会是页面对齐的。因此,从堆栈帧开始到“红色区域”的第一个单词(>is
“主”堆栈可能很特殊,因为在 JVM 引导时可能会使用该区域 。这可能会导致在调用 main
之前将一些“东西”留在堆栈中。 (这没有说服力……我也不相信。)
话虽如此,您所看到的“大”可变性令人费解。页面尺寸太小,无法解释约 7000 的计数差异。
更新
当 JIT 被禁用 (-Djava.compiler=NONE) 时,我总是得到相同的数字 (11907)。
有趣。除其他外,这可能会导致堆栈限制检查以不同方式进行。
这是有道理的,因为 JIT 优化可能会影响堆栈帧的大小,并且 JIT 所做的工作肯定必须在执行之间有所不同。
合理。在 f()
方法被 JIT 编译后,堆栈帧的大小可能会有所不同。假设 f()
在某个时候被 JIT 编译,你的堆栈将混合“旧”和“新”帧。如果 JIT 编译发生在不同的点,那么比率会有所不同……因此,当您达到限制时,count
会有所不同。
尽管如此,我认为如果通过参考有关该主题的一些文档和/或 JIT 在该特定示例中所做的工作的具体示例来证实这一理论,这将是有益的,这会导致帧大小发生变化。
恐怕这种可能性很小……除非你准备花钱请人为你做几天的研究。
1) 不存在这样的(公共)参考文档,AFAIK。至少,除了深入研究源代码之外,我从未能够找到此类事情的明确来源。
2) 查看 JIT 编译的代码不会告诉您在代码被 JIT 编译之前字节码解释器是如何处理事情的。因此,您将无法查看帧大小是否已更改。
【讨论】:
在specs 中明确提到“Java 虚拟机堆栈的内存不需要是连续的”并且“规范允许 Java 虚拟机堆栈是固定大小或根据计算需要动态扩展和收缩。” @MickMnemonic - 1) 就像我说的,我没有“深潜”。 2)我之前看过(我认为)OpenJDK 6 中线程堆栈是如何分配的,那里的线程堆栈>确实看起来是 感谢您的详细回答。页面对齐的理论也很有意义;在某些情况/平台中,它可能还会导致调用计数差异。只是出于好奇,您认为方法内联可能是造成这种情况的原因之一吗?我的意思是,通常它当然可以(堆栈上的帧更少),但是f()
可以在递归的某些级别被内联吗?
在这种情况下不是。您不能(有用地)内联递归函数......考虑一下。而且 Java 不进行尾调用优化……否则这将是一个无限循环,而不是 ***Error。【参考方案3】:
Java 堆栈的确切功能未记录,但它完全取决于分配给该线程的内存。
只需尝试使用带有 stacksize 的 Thread 构造函数,看看它是否保持不变。我还没试过,所以请分享一下结果。
【讨论】:
以上是关于为啥导致 ***Error 的递归方法的调用次数在程序运行之间会有所不同? [复制]的主要内容,如果未能解决你的问题,请参考以下文章