为啥导致 ***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 的递归方法的调用次数在程序运行之间会有所不同? [复制]的主要内容,如果未能解决你的问题,请参考以下文章

为啥递归调用会导致不同堆栈深度的 ***?

导致 ERROR 递归调用 appender。有没有办法解决它?

为啥无限递归会导致段错误

Python算法-爬楼梯与递归函数

Python算法-爬楼梯与递归函数

为啥增加递归深度会导致堆栈溢出错误?