为什么这个方法会输出4?

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
在我的本地环境中,我得到了预期的“0”。 - RaceBase
2
这可能涉及到很多架构方面的内容。因此最好在帖子中附上您使用的JDK版本和输出结果。对我来说,在JDK 1.7上的输出结果为0。 - Lokesh
3
我使用 Java 1.7.0_10 得到了 5638 - Kon
8
当你使用与底层架构相关的技巧时,输出结果可能会不同。 - m0skit0
3
“@flrnb这只是我用来对齐括号的一种风格。这使得我更容易知道条件和函数的开始和结束位置。如果你想的话,可以更改它,但在我看来,用这种方式更易读。” - syb0rg
显示剩余15条评论
7个回答

41

我认为其他人已经很好地解释了为什么cnt > 0,但是关于为什么cnt = 4以及为什么在不同的设置中cnt变化如此之大的细节不够。我将在此尝试填补这个空白。

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

当我们第一次进入main时,剩余的空间为X-M。每个递归调用会占用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((X-M)/R) = 49,cnt = ceiling((P - (X - M - C*R))/R) = 0。 示例2:假设
  • X = 100
  • M = 1
  • R = 5
  • P = 12

假设:

  • X = 101
  • M = 1
  • R = 3
  • 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开始递增,并且当剩余空间大于T + P时,println运行。这会为计算添加额外的步骤,并进一步混淆已经混乱的分析。
编辑
我终于找到时间运行一些实验来支持我的理论。不幸的是,理论似乎与实验不符。实际发生的事情非常不同。
实验设置: Ubuntu 12.04服务器,默认java和默认jdk。 Xss从70,000开始,以1字节增量增加到460,000。

结果可以在以下链接中查看:https://www.google.com/fusiontables/DataSource?docid=1xkJhd4s8biLghe6gZbcfUs3vT5MpS_OnscjWDbM 我创建了另一个版本,其中删除了每个重复的数据点。换句话说,只显示与之前不同的数据点。这使得更容易发现异常。https://www.google.com/fusiontables/DataSource?docid=1XG_SRzrrNasepwZoNHqEAKuZlHiAm9vbEdwfsUA


@flrnb M、R 和 P 是特定于系统的。您不能轻松更改它们。我预计它们在某些版本之间也会有所不同。 - John Tseng
由于这些变量的离散性质,仅有X的改变就会导致cnt的变化。例如2和3只在X上不同,但cnt是不同的。 - John Tseng
两个不同X值的cnt如何相差超过1? - flrnb
跟进:在大多数情况下,它们实际上只因不同的-Xss值而有所不同。然而,在我的机器上,-Xss4M打印出4,而-Xss6M打印出50,但这似乎是一个特殊情况。 - flrnb
1
@JohnTseng 我认为你的回答到目前为止是最易懂和最完整的 - 无论如何,我真的很想知道当 StackOverflowError 被抛出时堆栈实际上是什么样子的,以及这如何影响输出。如果它只包含了堆上的一个堆栈帧引用(正如Jay所建议的那样),那么对于给定的系统,输出应该是相当可预测的。 - flrnb
显示剩余14条评论

19
这是一个错误递归调用的受害者。如果您想知道为什么cnt的值会变化,那是因为堆栈大小取决于平台。在Windows上,Java SE 6在32位VM中具有默认堆栈大小为320k,在64位VM中为1024k。您可以在此处阅读更多信息 您可以使用不同的堆栈大小运行,然后在堆栈溢出之前,将看到不同的cnt值-

java -Xss1024k RandomNumberGenerator

有时即使值大于1,您也不会看到cnt的值被打印多次,因为您的打印语句也会抛出错误,您可以通过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语句,以便在尝试打印时不会开始抛出另一组错误。这按预期工作。您可以在上面的cnt++之后放置System.out.println(cnt);语句并编译。然后多次运行。根据您的平台,您可能会获得不同的cnt值。
这就是为什么通常我们不捕获错误,因为代码中的神秘感不是幻想。

13
行为取决于堆栈大小(可以使用 Xss 手动设置)。堆栈大小是与架构有关的。从 JDK 7 开始,源代码

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

所以当抛出 StackOverflowError 时,该错误在 catch 块中被捕获。这里的 println() 是另一个调用堆栈,它再次引发异常。这被重复进行。

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

使用 -Xss 4M 调用 java ,我得到了41。因此它们是相关的。


4
我不明白为什么堆栈大小会影响结果,因为当我们尝试打印cnt的值时,它已经超出了堆栈大小。因此,唯一可能的区别来自于“每个函数调用的堆栈大小”。我不明白为什么同样运行相同JVM版本的两台机器之间会有这种差异。 - flrnb
只有从JVM源代码中才能获得确切的行为。但原因可能是这样的。请记住,即使catch也是一个块,并且占用堆栈上的内存。无法知道每个方法调用需要多少内存。当堆栈被清除时,您正在添加另一个catch块,因此可能会出现这种行为。这只是猜测。 - Jatin
堆栈大小可能在两台不同的机器上有所不同。堆栈大小取决于许多基于操作系统的因素,例如内存页面大小等。 - Jatin

6
我认为显示的数字是 System.out.println 调用抛出 Stackoverflow 异常的次数。
这可能取决于 println 的实现以及在其中进行的堆栈调用次数。
举个例子: main() 调用在第 i 次时触发 Stackoverflow 异常。 main 的第 i-1 次调用捕获异常并调用 println,触发第二个 Stackoverflowcnt 增加到 1。 main 的第 i-2 次调用现在捕获异常并调用 println。 在 println 中调用一个方法,触发第三个异常。 cnt 增加到 2。 直到 println 可以进行所有必要的调用并最终显示 cnt 的值为止,这将一直持续下去。
这取决于 println 的实际实现。
对于 JDK7,它要么检测循环调用并更早地抛出异常,要么保留一些堆栈资源并在达到限制之前抛出异常以给予一些修复逻辑的空间,要么 println 实现不进行调用,要么 ++ 操作在 println 调用之后执行,因此被异常绕过。

这就是我所说的“我认为可能是因为System.out.println在调用堆栈上需要3个段”,但我很困惑为什么恰好是这个数字,现在我更加困惑为什么这个数字在不同(虚拟)机器之间差别如此之大。 - flrnb
我部分地同意它,但我不同意的是“取决于println的实际实现”的说法。这与每个JVM中的堆栈大小有关,而不是实现。 - Jatin

6
  1. main函数递归调用自身,直到在递归深度R处溢出堆栈。
  2. 在递归深度R-1处运行catch块。
  3. 在递归深度R-1处,catch块计算cnt++
  4. 在深度为R-1的catch块中调用println,将cnt的旧值放入堆栈。 println将内部调用其他方法并使用局部变量和其他内容。所有这些过程都需要堆栈空间。
  5. 由于堆栈已经接近极限,并且调用/执行println需要堆栈空间,因此在深度为R-1而不是深度为R触发了新的堆栈溢出。
  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-1R-2R-3R-4和最后的R-5处进行了后增操作。第五个后增返回四,这就是打印出的结果。
  12. 成功在深度为R-5处完成main函数后,整个堆栈解除,无需运行更多的catch块,程序完成。

1

在搜索一段时间后,我不能说我找到了答案,但我认为现在已经很接近了。

首先,我们需要知道什么情况下会抛出StackOverflowError。实际上,java线程的堆栈存储帧,其中包含调用方法和恢复所需的所有数据。根据Java 6语言规范,在调用方法时,

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

其次,我们应该清楚什么是“没有足够的内存来创建这样一个激活帧”。根据Java 6虚拟机规范

帧可以分配在堆中。

因此,当创建一个帧时,应该有足够的堆空间来创建一个堆栈帧,并有足够的堆栈空间来存储新引用,该引用指向新的堆栈帧(如果帧是堆分配的)。
现在让我们回到问题。从上面可以知道,当执行方法时,可能只需要相同数量的堆栈空间。调用System.out.println(可能)需要5个方法调用级别,因此需要创建5个帧。然后当抛出StackOverflowError时,它必须返回5次以获得足够的堆栈空间来存储5个帧的引用。因此打印出4。为什么不是5?因为你使用了cnt++。将其更改为++cnt,然后您将获得5。
您会注意到,当堆栈大小达到较高水平时,有时会得到50。这是因为需要考虑可用堆空间的数量。当堆栈的大小过大时,也许堆空间会在堆栈之前耗尽。而System.out.println的实际堆栈帧大小约为main的51倍,因此它返回51次并打印50。

我的第一反应也是计算方法调用的层次(你说得对,我没有注意到我后置递增了 cnt),但如果解决方案如此简单,为什么在不同平台和虚拟机实现之间结果会有如此大的差异呢? - flrnb
@flrnb 这是因为不同的平台可能会影响堆栈帧的大小,不同版本的jre也会影响System.out.print的实现或方法执行策略。正如上面所述,VM实现也会影响堆栈帧的实际存储位置。 - Jay

0

这并不是对问题的确切回答,但我想在原问题上添加一些东西,以及我如何理解这个问题:

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

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

但在早期版本的jdk中,似乎异常没有在第一次出现的地方被捕获,因此是4、50等。

现在,如果您按以下方式删除try catch块

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

然后您将看到cnt的所有值以及抛出的异常(在jdk 1.7上)。

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接