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

时间:2016-02-20 01:16:10

标签: 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在此特定示例中完成的工作的具体示例来确认该理论将导致框架大小更改,这将是有益的。

3 个答案:

答案 0 :(得分:30)

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

这是流程的样子:

  1. 方法f()在解释器中开始执行。
  2. 经过多次调用(大约250次)后,该方法计划进行编译。
  3. 编译器线程与应用程序线程并行工作。同时,该方法继续在解释器中执行。
  4. 一旦编译器线程完成编译,方法入口点就会被替换,因此下一次调用f()将调用该方法的编译版本。
  5. applcation线程和JIT编译器线程之间基本上存在竞争。解释器可以在方法的编译版本准备好之前执行不同数量的调用。最后,有一系列解释和编译的框架。

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

    此外,Tiered Compilation还有更多种族可能性(自JDK 8以来默认)。可以有3种类型的框架组合:解释器,C1和C2(见下文)。

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

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

      $ java -Xint Main
      11895
      11895
      11895
      
    2. 禁用后台编译。 JIT为ON,但与应用程序线程同步 没有比赛,但由于编译帧,调用次数现在更高。

      $ 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
      

答案 1 :(得分:6)

首先,以下内容尚未研究过。我没有"深度潜水"用于验证以下任何内容的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会发出一个系统调用来告诉操作系统标记一个"红色区域"堆栈区域末尾的页面是只读的或不可访问的。当一个线程进行一次溢出堆栈的调用时,它会访问" red zone"这会触发内存故障。操作系统通过"信号"告诉JVM,并且JVM的信号处理程序将它映射到StackOverflowError,它被抛出"在线程的堆栈上。

所以这里有几个可能的解释变异性:

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

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

说完这个,"大"你看到的可变性令人费解。页面大小太小,无法解释计数差异~7000。

<强>更新

  

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

有趣。除此之外,这可能导致堆栈限制检查以不同方式完成。

  

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

似是而非。在f()方法进行JIT编译之后,堆栈帧的大小可能会有所不同。假设f()在某些时候编译了JIT,那么堆栈将混合使用&#34; old&#34;和&#34;新&#34;帧。如果JIT编译发生在不同的点,那么比率将是不同的......因此当你达到极限时count会有所不同。

  

尽管如此,我认为如果通过参考有关该主题的一些文档和/或JIT在此特定示例中完成的工作的具体示例来确认该理论将导致框架大小更改,这将是有益的。

这种可能性很小,我很害怕......除非你准备好支付别人为你做几天的研究。

1)AFAIK不存在此类(公共)参考文档。至少,我从来没有找到这种东西的明确来源......除了深入挖掘源代码。

2)查看JIT编译代码,告诉您在代码编译JIT之前字节码解释器如何处理事物。因此,您无法查看框架尺寸是否已更改

答案 2 :(得分:1)

Java堆栈的确切功能未记录,但它完全取决于分配给该线程的内存。

尝试使用带有stacksize的Thread构造函数,看看它是否变为常量。我没试过,所以请分享结果。