为什么导致 StackOverflowError 的递归方法的调用计数在程序运行之间会有所不同?

2022-09-01 07:46:23

用于演示目的的简单类:

public class Main {

    private static int counter = 0;

    public static void main(String[] args) {
        try {
            f();
        } catch (StackOverflowError 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=NONE11907

这是有道理的,因为JIT优化可能会影响堆栈帧的大小,并且JIT完成的工作肯定必须在执行之间有所不同。

尽管如此,我认为如果通过引用有关该主题的一些文档和/或JIT在此特定示例中所做的工作的具体示例来证实这一理论,从而导致帧大小变化,那将是有益的。


答案 1

观察到的方差是由后台 JIT 编译引起的。

该过程如下所示:

  1. 方法在解释器中开始执行。f()
  2. 经过多次调用(大约250次)后,该方法被安排进行编译。
  3. 编译器线程与应用程序线程并行工作。同时,该方法继续在解释器中执行。
  4. 编译器线程完成编译后,方法入口点将被替换,因此下一次调用 将调用该方法的已编译版本。f()

应用线程和JIT编译器线程之间基本上存在竞争。解释器可以在方法的编译版本准备就绪之前执行不同数量的调用。最后是解释和编译的帧的混合。

难怪编译的框架布局与解释的框架布局不同。编译的帧通常较小;它们不需要将所有执行上下文存储在堆栈上(方法引用,常量池引用,探查器数据,所有参数,表达式变量等)。

此外,分层编译(自JDK 8起默认)有更多的比赛可能性。可以有 3 种类型的帧的组合:解释器、C1 和 C2(见下文)。


让我们做一些有趣的实验来支持这个理论。

  1. 纯解释模式。无 JIT 编译。
    没有比赛=>稳定的结果。

    $ java -Xint Main
    11895
    11895
    11895
    
  2. 禁用后台编译。JIT 已打开,但与应用程序线程同步。
    不再比赛,但由于编译的帧,呼叫数量现在更高。

    $ java -XX:-BackgroundCompilation Main
    23462
    23462
    23462
    
  3. 在执行之前用C1编译所有内容。与以前的情况不同,堆栈上不会有解释的帧,因此该数字会高一些。

    $ java -Xcomp -XX:TieredStopAtLevel=1 Main
    23720
    23720
    23720
    
  4. 现在,在执行之前使用 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)    ; StackOverflow 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 已经内联了一个递归级别。

  5. 最后,让我们看一下混合堆栈跟踪。为了获得它,我们将在StackOverflowError上崩溃JVM(调试版本中可用的选项)。

    $ java -XX:AbortVMOnException=java.lang.StackOverflowError Main
    

    故障转储包含详细的堆栈跟踪,我们可以在底部找到解释的帧,在中间找到C1帧,最后在顶部找到C2帧。hs_err_pid.log

    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
    

答案 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的信号处理程序将其映射到线程堆栈上“抛出”的信号处理程序。StackOverflowError

因此,以下是对可变性的几种可能的解释:

  • 基于硬件的内存保护的粒度是页面边界。因此,如果线程堆栈已使用 分配,则该区域的开头不会页面对齐。因此,从堆栈帧的开始到“红色区域”的第一个单词(>是<页面对齐)的距离将是可变的。malloc

  • “主”堆栈可能很特殊,因为在 JVM 引导时可以使用该区域。这可能会导致一些“东西”被保留在之前被调用的堆栈上。(这并不令人信服...我不相信。main

话虽如此,您看到的“大”可变性令人困惑。页面大小太小,无法解释计数中约 7000 的差异。

更新

当 JIT 被禁用 (-Djava.compiler=NONE) 时,我总是得到相同的数字 (11907)。

有趣。除其他事项外,这可能导致堆栈限制检查以不同的方式完成。

这是有道理的,因为JIT优化可能会影响堆栈帧的大小,并且JIT完成的工作肯定必须在执行之间有所不同。

合理。在对方法进行 JIT 编译后,堆栈帧的大小可能会有所不同。假设在某个时刻编译了JIT,那么堆栈将混合使用“旧”和“新”帧。如果JIT编译发生在不同的点,那么比率将不同...因此,当您达到限制时,情况会有所不同。f()f()count

尽管如此,我认为如果通过引用有关该主题的一些文档和/或JIT在此特定示例中所做的工作的具体示例来证实这一理论,从而导致帧大小变化,那将是有益的。

恐怕机会不大...除非你准备付钱给某人为你做几天的研究。

1)不存在此类(公共)参考文档,AFAIK。至少,我一直无法为这种事情找到明确的来源......除了深入挖掘源代码。

2)查看JIT编译的代码,不会告诉你在JIT编译代码之前字节码解释器如何处理事情。因此,您将无法查看帧大小是否已更改