111

我想知道当您尝试捕获 StackOverflowError 并想出以下方法时会发生什么:

class RandomNumberGenerator {

    static int cnt = 0;

    public static void main(String[] args) {
        try {
            main(args);
        } catch (StackOverflowError ignore) {
            System.out.println(cnt++);
        }
    }
}

现在我的问题:

为什么这个方法打印'4'?

我想可能是因为System.out.println()调用堆栈上需要 3 个段,但我不知道数字 3 来自哪里。当您查看 的源代码(和字节码)时System.out.println(),通常会导致比 3 多得多的方法调用(因此调用堆栈上的 3 个段是不够的)。如果是因为 Hotspot VM 应用的优化(方法内联),我想知道结果在另一个 VM 上是否会有所不同。

编辑

由于输出似乎与 JVM 高度相关,我使用
Java(TM) SE Runtime Environment (build 1.6.0_41-b02)
Java HotSpot(TM) 64-Bit Server VM (build 20.14-b01, mixed mode)得到结果 4


解释为什么我认为这个问题与理解 Java 堆栈不同:

我的问题不是关于为什么有一个 cnt > 0 (显然是因为System.out.println()需要堆栈大小并StackOverflowError在打印某些内容之前抛出另一个),而是为什么它具有特定值 4,分别为 0、3、8、55 或其他系统。

4

7 回答 7

41

我认为其他人在解释为什么 cnt > 0 方面做得很好,但是没有足够的细节来说明为什么 cnt = 4,以及为什么 cnt 在不同的设置中变化如此之大。我将尝试在这里填补这一空白。

  • X 是总堆栈大小
  • M 是我们第一次进入 main 时使用的堆栈空间
  • R 是每次进入 main 时堆栈空间增加
  • P 是运行所需的堆栈空间System.out.println

当我们第一次进入 main 时,剩下的空间是 XM。每个递归调用占用 R 更多内存。所以对于 1 次递归调用(比原来多 1 次),内存使用量为 M + R。假设 C 递归调用成功后抛出 StackOverflowError,即 M + C * R <= X 和 M + C * (R + 1) > X。在第一个 StackOverflowError 时,还剩下 X - M - C * R 内存。

为了能够运行System.out.prinln,我们需要在堆栈上留下 P 量的空间。如果碰巧 X - M - C * R >= P,那么将打印 0。如果 P 需要更多空间,那么我们从堆栈中删除帧,以 cnt++ 为代价获得 R 内存。

println最终能够运行时,X - M - (C - cnt) * R >= P。因此,如果 P 对于特定系统很大,那么 cnt 也会很大。

让我们用一些例子来看看这个。

示例 1:假设

  • X = 100
  • M = 1
  • R = 2
  • P = 1

那么 C = floor((XM)/R) = 49,并且 cnt = ceiling((P - (X - M - C*R))/R) = 0。

示例 2:假设

  • X = 100
  • M = 1
  • R = 5
  • P = 12

然后 C = 19,并且 cnt = 2。

示例 3:假设

  • X = 101
  • M = 1
  • R = 5
  • P = 12

然后 C = 20,cnt = 3。

示例 4:假设

  • X = 101
  • M = 2
  • R = 5
  • P = 12

然后 C = 19,并且 cnt = 2。

因此,我们看到系统(M、R 和 P)和堆栈大小(X)都会影响 cnt。

catch作为旁注,启动需要多少空间并不重要。只要 没有足够的空间catch,那么 cnt 就不会增加,因此没有外部影响。

编辑

我收回我所说的catch。它确实发挥了作用。假设它需要 T 量的空间来启动。当剩余空间大于 T 时,cnt 开始递增,并println在剩余空间大于 T + P 时运行。这为计算增加了额外的步骤,进一步混淆了已经很混乱的分析。

编辑

我终于找到时间进行一些实验来支持我的理论。不幸的是,该理论似乎与实验不符。实际发生的情况非常不同。

实验设置:带有默认 java 和 default-jdk 的 Ubuntu 12.04 服务器。Xss 从 70,000 开始,以 1 个字节递增到 460,000。

结果可在以下网址获得:https ://www.google.com/fusiontables/DataSource?docid=1xkJhd4s8biLghe6gZbcfUs3vT5MpS_OnscjWDbM 我创建了另一个版本,其中删除了每个重复的数据点。换言之,仅显示与先前不同的点。这使得更容易看到异常。https://www.google.com/fusiontables/DataSource?docid=1XG_SRzrrNasepwZoNHqEAKuZlHiAm9vbEdwfsUA

于 2013-07-24T13:58:17.480 回答
19

这是糟糕的递归调用的受害者。当您想知道为什么cnt的值会变化时,这是因为堆栈大小取决于平台。Windows 上的 Java SE 6 在 32 位 VM 中的默认堆栈大小为 320k,在 64 位 VM 中为 1024k。你可以在这里阅读更多。

您可以使用不同的堆栈大小运行,并且在堆栈溢出之前您会看到不同的cnt值-

java -Xss1024k RandomNumberGenerator

即使有时值大于 1 ,您也看不到多次打印cnt的值,因为您的 print 语句也抛出错误,您可以通过 Eclipse 或其他 IDE 进行调试以确保。

如果您愿意,可以将代码更改为以下代码以调试每个语句的执行 -

static int cnt = 0;

public static void main(String[] args) {                  

    try {     

        main(args);   

    } catch (Throwable ignore) {

        cnt++;

        try { 

            System.out.println(cnt);

        } catch (Throwable t) {   

        }        
    }        
}

更新:

随着这越来越受到关注,让我们再举一个例子来让事情更清楚——

static int cnt = 0;

public static void overflow(){

    try {     

      overflow();     

    } catch (Throwable t) {

      cnt++;                      

    }

}

public static void main(String[] args) {

    overflow();
    System.out.println(cnt);

}

我们创建了另一个名为overflow的方法来执行错误的递归,并从 catch 块中删除了println语句,因此它在尝试打印时不会开始抛出另一组错误。这按预期工作。你可以试试把System.out.println(cnt); 上面cnt++之后的语句并编译。然后运行多次。根据您的平台,您可能会获得不同的cnt值。

这就是为什么我们通常不会捕获错误的原因,因为代码中的神秘不是幻想。

于 2013-07-24T08:32:52.113 回答
13

行为取决于堆栈大小(可以使用手动设置Xss。堆栈大小是特定于体系结构的。来自 JDK 7源代码

// Windows 上的默认堆栈大小由可执行文件确定(java.exe
// 默认值为 320K/1MB [32bit/64bit])。根据 Windows 版本,将
// ThreadStackSize 更改为非零可能会对内存使用产生重大影响。
// 参见 os_windows.cpp 中的注释。

因此,当StackOverflowError抛出 时,错误会在 catch 块中捕获。这println()是另一个再次引发异常的堆栈调用。这会重复。

它重复了多少次?- 这取决于 JVM 何时认为它不再是 stackoverflow。这取决于每个函数调用的堆栈大小(很难找到)和Xss. 如上所述,每个函数调用的默认总大小和大小(取决于内存页面大小等)是特定于平台的。因此行为不同。

打电话java-Xss 4M41。因此,相关性。

于 2013-07-24T11:57:00.673 回答
6

我认为显示的数字是System.out.println调用引发Stackoverflow异常的次数。

它可能取决于它的实现println和堆栈调用的数量。

举例说明:

main()调用在调用 i 处触发Stackoverflow异常。main 的 i-1 调用捕获异常并调用println触发第二个Stackoverflow. cnt将增量增加到 1。 main catch 的 i-2 调用现在是异常并调用println。在println一个方法中调用触发第三个异常。 cnt将增量增加到 2。这一直持续到println可以进行所有需要的调用并最终显示cnt.

这取决于println.

对于 JDK7,它要么检测循环调用并提前抛出异常,要么保留一些堆栈资源并在达到限制之前抛出异常,以便为修复逻辑提供一些空间,要么println实现不进行调用,要么 ++ 操作在之后完成因此,该println调用被异常绕过。

于 2013-07-24T11:06:02.753 回答
6
  1. main在自身上递归,直到它在递归深度溢出堆栈R
  2. 运行递归深度的 catch 块R-1
  3. 递归深度的 catch 块R-1计算cnt++.
  4. 深度处的 catch 块R-1调用println,将cnt的旧值放入堆栈。println将在内部调用其他方法并使用局部变量和事物。所有这些进程都需要堆栈空间。
  5. 因为堆栈已经超出限制,并且调用/执行需要堆栈空间,所以在 depth而不是 depthprintln触发了新的堆栈溢出。R-1R
  6. 步骤 2-5 再次发生,但在递归深度R-2
  7. 步骤 2-5 再次发生,但在递归深度R-3
  8. 步骤 2-5 再次发生,但在递归深度R-4
  9. 步骤 2-4 再次发生,但在递归深度R-5
  10. 碰巧现在有足够的堆栈空间println来完成(请注意,这是一个实现细节,可能会有所不同)。
  11. cnt在深度R-1, R-2, R-3,处后增量R-4,最后在R-5. 第五个后增量返回四个,这就是打印的内容。
  12. maindepth 成功完成后R-5,整个堆栈展开,没有运行更多的 catch 块并且程序完成。
于 2013-07-24T16:59:45.817 回答
1

挖了一圈之后,我不能说我找到了答案,但我认为现在已经很接近了。

首先,我们需要知道什么时候StackOverflowError会抛出 a。实际上,Java 线程的堆栈存储帧,其中包含调用方法和恢复所需的所有数据。根据Java Language Specifications for JAVA 6,在调用方法时,

如果没有足够的可用内存来创建这样的激活帧,则会抛出 StackOverflowError。

其次,我们应该明确什么是“没有足够的可用内存来创建这样的激活框架”。根据JAVA 6 的 Java 虚拟机规范

帧可能是堆分配的。

因此,当创建一个帧时,应该有足够的堆空间来创建堆栈帧和足够的堆栈空间来存储指向新堆栈帧的新引用,如果该帧是堆分配的。

现在让我们回到这个问题。从上面我们可以知道,当一个方法被执行时,它可能只花费相同数量的堆栈空间。而调用System.out.println(可能)需要5级方法调用,所以需要创建5个frame。然后当StackOverflowError被抛出时,它必须返回 5 次以获得足够的堆栈空间来存储 5 帧的引用。因此 4 被打印出来。为什么不是5?因为你使用cnt++. 将其更改为++cnt,然后您将获得 5。

而且你会注意到,当堆栈大小变高时,有时你会得到 50。那是因为那时需要考虑可用堆空间的数量。当堆栈的大小太大时,可能堆空间会在堆栈之前用完。并且(也许)堆栈帧的实际大小System.out.println约为 的 51 倍main,因此它返回 51 次并打印 50。

于 2013-07-24T18:38:30.183 回答
0

这不完全是问题的答案,但我只是想在我遇到的原始问题以及我如何理解问题中添加一些内容:

在原始问题中,异常在可能的地方被捕获:

例如,在 jdk 1.7 中,它在第一次出现时被捕获。

但在早期版本的 jdk 中,看起来异常没有在第一个发生的地方被捕获,因此是 4、50 等。

现在,如果您删除 try catch 块,如下所示

public static void main( String[] args ){
    System.out.println(cnt++);
    main(args);
}

然后你会看到cntant 抛出的异常的所有值(在 jdk 1.7 上)。

我使用 netbeans 查看输出,因为 cmd 不会显示所有输出和抛出的异常。

于 2013-07-24T09:27:33.753 回答