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

Dra*_*vic 31 java stack-overflow recursion stack

用于演示目的的简单类:

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();
    }
}
Run Code Online (Sandbox Code Playgroud)

我执行了5次上面的程序,结果是:

22025
22117
15234
21993
21430
Run Code Online (Sandbox Code Playgroud)

为什么结果每次都不同?

我尝试设置最大堆栈大小(例如-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)
Run Code Online (Sandbox Code Playgroud)

编辑

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

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

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

apa*_*gin 30

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

这是过程的样子:

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

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

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

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


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

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

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

    $ java -XX:-BackgroundCompilation Main
    23462
    23462
    23462
    
    Run Code Online (Sandbox Code Playgroud)
  3. 执行用C1编译所有内容.与以前的情况不同,堆栈上不会有解释帧,因此数字会更高一些.

    $ java -Xcomp -XX:TieredStopAtLevel=1 Main
    23720
    23720
    23720
    
    Run Code Online (Sandbox Code Playgroud)
  4. 现在执行之前用C2编译所有内容.这将生成具有最小帧的最优化代码.通话次数最多.

    $ java -Xcomp -XX:-TieredCompilation Main
    59300
    59300
    59300
    
    Run Code Online (Sandbox Code Playgroud)

    由于默认堆栈大小为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
    
    Run Code Online (Sandbox Code Playgroud)

    实际上,这里的帧是32个字节,但是JIT已经内联了一个级别的递归.

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

    $ java -XX:AbortVMOnException=java.lang.StackOverflowError Main
    
    Run Code Online (Sandbox Code Playgroud)

    崩溃转储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
    
    Run Code Online (Sandbox Code Playgroud)


Ste*_*n C 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)
Run Code Online (Sandbox Code Playgroud)

我得到的"计数"在~250的范围内变化.(没有你看到的那么多)

首先是一些背景.典型Java实现中的线程堆栈是在线程启动之前分配的连续内存区域,并且永远不会增长或移动.当JVM尝试创建堆栈帧以进行方法调用时,会发生堆栈溢出,并且帧超出了内存区域的限制.测试可以通过明确地测试SP来完成,但我的理解是它通常使用内存页面设置的巧妙技巧来实现.

当分配堆栈区域时,JVM会发出一个系统调用,告诉操作系统在堆栈区域末尾标记"红色区域"页面为只读或不可访问.当一个线程进行一次溢出堆栈的调用时,它会访问"red zone"中的内存,从而触发内存故障.操作系统通过"信号"告诉JVM,JVM的信号处理程序将其映射到StackOverflowError线程堆栈上"抛出"的内容.

所以这里有几种可能的变化解释:

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

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

话虽如此,您所看到的"大"变化令人困惑.页面大小太小,无法解释计数差异~7000.

UPDATE

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

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

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

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

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

很少有机会,我害怕......除非你准备支付某人为你做几天的研究.

1)AFAIK不存在此类(公共)参考文献.至少,我从来没有找到这种东西的权威来源......除了深度潜水的源代码.

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