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

标签 java recursion stack stack-overflow

一个用于演示的简单类:

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=NONE) 我总是得到相同的数字 (11907)。

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

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

最佳答案

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

流程如下:

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

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

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

此外,Tiered Compilation 还有更多的比赛可能性(自 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
    

    故障转储 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
    

关于java - 为什么导致 StackOverflowError 的递归方法的调用次数在程序运行之间会有所不同?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/35517934/

相关文章:

hadoop - 如何在新的 Hadoop API 中递归使用目录结构?

Java - 递归 - 线程 "main"java.lang.StackOverflowError 中的异常

在 C 中的结构中创建堆栈空间

c++ - 迭代器不可取消引用堆栈(表达式树)和循环无法正常工作

java - 我如何在 Java 中使这些数据结构实现通用?

java - 严重: There is no column named: "PAYMENTDATE "

java - 提取 Wav 文件的时域双 [] 以输入到 FFT - Java

Java - 压缩输出字节数组的大小

java - 编译后从字节码中删除注释

java - 循环从 0 到 100 的数字,并使用递归打印出每三个数字而不使用模函数