为什么Java编译器会复制finally块?

23

在使用简单的try/finally块编译以下代码时,Java编译器会产生下面的输出(在ASM字节码查看器中查看):

代码:

try
{
    System.out.println("Attempting to divide by zero...");
    System.out.println(1 / 0);
}
finally
{
    System.out.println("Finally...");
}

字节码:

TRYCATCHBLOCK L0 L1 L1 
L0
 LINENUMBER 10 L0
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 LDC "Attempting to divide by zero..."
 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L2
 LINENUMBER 11 L2
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 ICONST_1
 ICONST_0
 IDIV
 INVOKEVIRTUAL java/io/PrintStream.println (I)V
L3
 LINENUMBER 12 L3
 GOTO L4
L1
 LINENUMBER 14 L1
FRAME SAME1 java/lang/Throwable
 ASTORE 1
L5
 LINENUMBER 15 L5
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 LDC "Finally..."
 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L6
 LINENUMBER 16 L6
 ALOAD 1
 ATHROW
L4
 LINENUMBER 15 L4
FRAME SAME
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 LDC "Finally..."
 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L7
 LINENUMBER 17 L7
 RETURN
L8
 LOCALVARIABLE args [Ljava/lang/String; L0 L8 0
 MAXSTACK = 3
 MAXLOCALS = 2

我在加入catch块后注意到编译器将finally块复制了3次(没有再次发布字节码)。这似乎在类文件中浪费空间。此复制也似乎不限于最大指令数(类似于内联的方式),因为即使在添加更多对System.out.println的调用时,它仍会复制finally块。


然而,我的自定义编译器使用不同的编译方法来编译相同的代码,其执行结果完全相同,但使用GOTO指令需要更少的空间:

public static main([Ljava/lang/String;)V
 // parameter  args
 TRYCATCHBLOCK L0 L1 L1 
L0
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 LDC "Attempting to divide by zero..."
 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 ICONST_1
 ICONST_0
 IDIV
 INVOKEVIRTUAL java/io/PrintStream.println (I)V
 GOTO L2
L1
FRAME SAME1 java/lang/Throwable
 POP
L2
FRAME SAME
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 LDC "Finally..."
 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L3
 RETURN
 LOCALVARIABLE args [Ljava/lang/String; L0 L3 0
 MAXSTACK = 3
 MAXLOCALS = 1

为什么Java编译器(或Eclipse编译器)会多次复制finally块的字节码,甚至在使用athrow重新抛出异常时,当可以使用goto实现相同语义时?这是优化过程的一部分还是我的编译器做错了?


(在两种情况下的输出结果都是...)

Attempting to divide by zero...
Finally...

它使用ASM框架编译为Java字节码... - Clashsoft
到目前为止,“答案”都没有回答基本问题:为什么会重复这些代码块? - Marco13
4
javac 曾经使用 jsr (跳转子例程) 只需编写一次 finally 代码,但与使用堆栈映射表的新验证相关的一些问题。我猜他们之后又回归到克隆代码,因为这是最简单的做法。 - Jeffrey Bosboom
@jeffrey-bosboom 看起来这不是最近的变化,我发现关于这个问题的讨论始于2006年。 - uraimo
3
似乎“自定义编译器”的字节码输出不正确,因为它在“L1”部分将“throwable”对象弹出了。因此,尽管可能会抛出“运行时异常”,但您的函数永远不会抛出“异常”。请注意保持原意并使内容更加通俗易懂。 - D3Hunter
显示剩余4条评论
2个回答

15

内联finally代码块

你所提出的问题已经在http://devblog.guidewire.com/2009/10/22/compiling-trycatchfinally-on-the-jvm/ (wayback机器网站存档链接)中得到了部分分析。

这篇文章将展示一个有趣的例子以及相关信息(引用):

finally代码块是通过将finally代码块内联到try或相关catch块的所有可能退出点来实现的,将整个过程包装在基本上是“catch(Throwable)”块中,当它完成时重新抛出异常,然后调整异常表,使catch子句跳过内联的finally语句。嗯?(小小的警告:在1.6编译器之前,显然,finally语句使用子程序而不是完全的代码内联。但是我们只关心1.6,在这一点上,所以这适用于1.6)。


JSR指令和内联finally

关于为什么要使用内联,有不同的观点,但我还没有从官方文件或来源中找到明确的解释。

以下是三种解释:

没有提供优势-更麻烦:

有人认为,内联finally之所以被使用,是因为JSR / RET等机制并没有提供重大优势,如What Java compilers use the jsr instruction, and what for?中的引述所示。

JSR / RET机制最初用于实现finally块。但是,他们决定代码大小的节省不值得额外的复杂性,并逐渐被淘汰。

使用堆栈映射表进行验证存在问题:

另一个可能的解释由@jeffrey-bosboom在评论中提出,我在下面引用:

javac曾经使用jsr(跳转子例程)仅一次编写finally代码,但与使用堆栈映射表进行新验证相关的一些问题。我认为,他们回到克隆代码只是因为这是最容易做的事情。

需要维护子程序脏位:

Java编译器使用jsr指令的目的是什么?的评论中,一个有趣的交流指出JSR和子程序“增加了额外的复杂性,因为需要维护本地变量的脏位栈”。

以下是交流内容:

@paj28: 如果jsr只能调用已声明的“子程序”,每个子程序只能从开头进入,只能从一个其他子程序中调用,并且只能通过ret或异常完成(return或throw)退出,那么它是否会带来这样的困难?在finally块中复制代码似乎非常丑陋,特别是因为finally相关的清理可能经常调用嵌套try块。- supercat 2014年1月28日23:18

@supercat,大部分情况下都是正确的。子程序只能从开头进入,只能从一个子程序中返回,并且只能从单个子程序中调用。但复杂性来自于必须维护本地变量的脏位堆栈,并且在返回时必须进行三路合并。- Antimony 2014年1月28日23:40


与我的问题无关,但是否仍然可以使用JSRRET指令,例如用于实际子程序(当然...)? - Clashsoft
@Clashsoft 我认为你不行。根据https://dev59.com/amEi5IYBdhLWcg3wldBu#21150629,JSR指令实际上在Java 7类文件中甚至不被允许。它只允许在版本49.0或更早版本的类文件中,相当于Java 5或更早版本。实际上,在那之前很久它就已经不再使用了。 - Menelaos
那么子程序只是编译为包含方法旁边的私有方法? - Clashsoft
不确定...等我喝完咖啡再看看。 - Menelaos
2
"Inlining was abandoned". 我有点困惑:难道我们现在得到的不就是内联吗?据我理解,当他们不使用JSR将finally块“放在一边”时,相反,finally块会被多次内联 - joeytwiddle

3
编译这个:
public static void main(String... args){
    try
    {
        System.out.println("Attempting to divide by zero...");
        System.out.println(1 / 0);
    }catch(Exception e){
        System.out.println("Exception!");
    }
    finally
    {
        System.out.println("Finally...");
    }

}

查看javap -v的结果,finally块只是简单地附加在每个处理异常的部分的末尾(添加catch,在第37行添加了一个finally块,在第49行的finally块是针对未经检查的java.lang.Errors):

public static void main(java.lang.String...);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
Code:
  stack=3, locals=3, args_size=1
     0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
     3: ldc           #3                  // String Attempting to divide by zero...
     5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
     8: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    11: iconst_1
    12: iconst_0
    13: idiv
    14: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
    17: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    20: ldc           #6                  // String Finally...
    22: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    25: goto          59
    28: astore_1
    29: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    32: ldc           #8                  // String Exception!
    34: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    37: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    40: ldc           #6                  // String Finally...
    42: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    45: goto          59
    48: astore_2
    49: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    52: ldc           #6                  // String Finally...
    54: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    57: aload_2
    58: athrow
    59: return
  Exception table:
     from    to  target type
         0    17    28   Class java/lang/Exception
         0    17    48   any
        28    37    48   any

看起来最初的 finally 块实现类似于您提出的方式,但自 Java 1.4.2 起,javac 开始内联 finally 块, 来自 Hamilton & Danicic 的 "An Evaluation of Current Java Bytecode Decompilers"[2009]:

许多旧的反编译器希望使用子例程进行 try-finally 块的操作,但 javac 1.4.2+ 生成内联代码。

一篇来自 2006 年的 博客文章 讨论了这个问题:

第 5-12 行的代码与第 19-26 行的代码相同,实际上将转换为 count++ 行。finally 块显然是复制的。


2
为什么要重复问题创作者已经提供的代码? - Menelaos
1
这是相同的代码,只是添加了一个catch。我把它放在这里以确保这就是他想要的,并展示我编译的源代码。如果你指的是javap dump,那是必需的,而且实际上比他发布的更清晰易读。 - uraimo

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