你知道吗,Java中的受查和非受查异常,其实并不存在区别......
相信写过 Java 的人都会知道,在 Java 的异常系统中,存在“受查(checked)”异常和“非受查(unchecked)”两座大山,两者虽然均为异常,但是却有着微妙的区别。但是你知道吗,实际上在 JVM 的世界里,这种区别根本不存在......
“受查”和“非受查”
为什么有时候调用某些方法的时候需要强制 try-catch 它们,亦或者在调用方法上加入 throws 关键字声明抛出,而有的方法虽然会抛出异常,但是并不会要求你这么做...... 如果有一位 Java 新手带着这样的疑惑问你,你一定会轻车熟路的告诉他:所有继承自 java.lang.RuntimeException
的异常,他们都是非受查异常,这些异常允许你不必强制在方法体上声明他们,亦或者强制通过 try-catch 捕获;而除此之外的异常,则都是受查异常,你必须按照上述的方法声明和捕获他们。
举个例子:以下代码是无法正常编译的:
import java.io.IOException;
public class Main {
public static void main(String[] args){
throw new IOException("Goodbye, World!");
}
}
因为 java.io.IOException
没有继承自 java.lang.RuntimeException
,因此是一个非受查异常,而我们并没有通过 try-catch 捕获异常或是在调用函数上声明抛出该异常。因此我们会得到如下编译错误:
Main.java:6: error: unreported exception IOException; must be caught or declared to be thrown
throw new IOException("Goodbye, World!");
^
改进措施也很简单,在 main 方法上声明抛出异常即可正常编译:
import java.io.IOException;
public class Main {
public static void main(String[] args) throws IOException {
throw new IOException("Goodbye, World!");
}
}
亦或者,我们也可以通过 try-catch 来捕获这个异常:
import java.io.IOException;
public class Main {
public static void main(String[] args) {
try {
throw new IOException("Goodbye, World!");
} catch (IOException e){
throw new RuntimeException("Caught!");
}
}
}
我们需要更深入点
而如果你是一个善于提出问题的人,你可能会接着问下去:既然 Java 代码最终会编译为 JVM 字节码,那么在 JVM 字节码层面,这些代码是如何表示的呢?
通过 javap
实用工具,我们得以有机会一窥上述代码的真面孔:
public class Main
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #14 // Main
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Class #8 // java/io/IOException
#8 = Utf8 java/io/IOException
#9 = String #10 // Goodbye, World!
#10 = Utf8 Goodbye, World!
#11 = Methodref #7.#12 // java/io/IOException."<init>":(Ljava/lang/String;)V
#12 = NameAndType #5:#13 // "<init>":(Ljava/lang/String;)V
#13 = Utf8 (Ljava/lang/String;)V
#14 = Class #15 // Main
#15 = Utf8 Main
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 main
#19 = Utf8 ([Ljava/lang/String;)V
#20 = Utf8 Exceptions
#21 = Utf8 SourceFile
#22 = Utf8 Main.java
{
public Main();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]) throws java.io.IOException;
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: new #7 // class java/io/IOException
3: dup
4: ldc #9 // String Goodbye, World!
6: invokespecial #11 // Method java/io/IOException."<init>":(Ljava/lang/String;)V
9: athrow
LineNumberTable:
line 6: 0
Exceptions:
throws java.io.IOException
}
眼尖的你可能已经注意到最下面两行已经展示出了我们想要的东西:我们在方法声明中填写的异常抛出声明,会作为 JVM 字节码方法表中的 Exception
属性表的一部分提供给 JVM 虚拟机。
而当我们通过 try-catch 来显式捕获异常的时候,它看起来是这样的:
public class Main
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #19 // Main
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Class #8 // java/io/IOException
#8 = Utf8 java/io/IOException
#9 = String #10 // Goodbye, World!
#10 = Utf8 Goodbye, World!
#11 = Methodref #7.#12 // java/io/IOException."<init>":(Ljava/lang/String;)V
#12 = NameAndType #5:#13 // "<init>":(Ljava/lang/String;)V
#13 = Utf8 (Ljava/lang/String;)V
#14 = Class #15 // java/lang/RuntimeException
#15 = Utf8 java/lang/RuntimeException
#16 = String #17 // Caught!
#17 = Utf8 Caught!
#18 = Methodref #14.#12 // java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
#19 = Class #20 // Main
#20 = Utf8 Main
#21 = Utf8 Code
#22 = Utf8 LineNumberTable
#23 = Utf8 main
#24 = Utf8 ([Ljava/lang/String;)V
#25 = Utf8 StackMapTable
#26 = Utf8 SourceFile
#27 = Utf8 Main.java
{
public Main();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: new #7 // class java/io/IOException
3: dup
4: ldc #9 // String Goodbye, World!
6: invokespecial #11 // Method java/io/IOException."<init>":(Ljava/lang/String;)V
9: athrow
10: astore_1
11: new #14 // class java/lang/RuntimeException
14: dup
15: ldc #16 // String Caught!
17: invokespecial #18 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
20: athrow
Exception table:
from to target type
0 10 10 Class java/io/IOException
LineNumberTable:
line 7: 0
line 8: 10
line 9: 11
StackMapTable: number_of_entries = 1
frame_type = 74 /* same_locals_1_stack_item */
stack = [ class java/io/IOException ]
}
try-catch 会被转换为 JVM 字节码的异常表(Exception table),异常表会负责捕获指定范围内(from 和 to)的指定类型异常(type),当异常抛出时,将代码跳转到指定的 JVM 代码行中(target)。
看到这里你可能就会开始提问:那么受查异常和非受查异常的差别呢,如何体现在 JVM 字节码里呢?
而答案是:完全没有区别。
编译器诡计:所见不一定所得
其实 Java 中并不缺乏这种“编译器诡计”的例子,从泛型到自动拆装箱,从字符串连接再到 lambda 表达式...... Java 的语言设计者赋予 Java 编译器巨大的魔力,在不变动中间表示代码(这里是 JVM 字节码)的情况下提供更多的语法特性或者语义限制。而受查异常和非受查异常显然就是其中的一部分 —— 在 JVM 字节码的层面,它们不能说是一模一样,只能说是毫无区别。
Kotlin: 规则破坏者
其实 Java 的受查异常是一个饱受诟病的语法特性,就和 Java 的泛型一样远近闻名:这些异常声明可能会随着调用链的增加越来越长,而有时也许你根本不想捕获这些异常,你只想简单的抛出他们。Java 社区中著名的 Lombok 项目甚至专门提供了一个 @SneakyThrows
注解来替你生成这些冗长的模板代码。那么是否有一个 JVM 语言抛弃了这个设定?答案是肯定的,那就是大名鼎鼎的 Kotlin。
Kotlin does not have checked exceptions. There are many reasons for this, but we will provide a simple example that illustrates why it is the case.
Kotlin 没有受查异常。造成这种情况的原因有很多,但我们将提供一个简单的示例来说明为什么会出现这种情况。The following is an example interface from the JDK implemented by the
StringBuilder
class:
以下是 JDK 中由StringBuilder
类实现的示例接口:Appendable append(CharSequence csq) throws IOException;
This signature says that every time I append a string to something (a
StringBuilder
, some kind of a log, a console, etc.), I have to catch theIOExceptions
. Why? Because the implementation might be performing IO operations (Writer
also implementsAppendable
). The result is code like this all over the place:
这个签名表明,每次我将字符串附加到某些东西(StringBuilder
、某种日志、控制台等)时,我都必须捕获IOExceptions
。为什么?因为该实现可能正在执行 IO 操作(Writer
也实现Appendable
)。结果到处都是这样的代码:try { log.append(message) } catch (IOException e) { // Must be safe }
And that's not good. Just take a look at Effective Java, 3rd Edition, Item 77: Don't ignore exceptions.
这很不好。只需看一下《Effective Java》,第 3 版,第 77 条:不要忽略异常。Bruce Eckel says this about checked exceptions:
Bruce Eckel 对于受查异常是这样说的:Examination of small programs leads to the conclusion that requiring exception specifications could both enhance developer productivity and enhance code quality, but experience with large software projects suggests a different result – decreased productivity and little or no increase in code quality.
对小型程序的检查得出的结论是,要求异常规范既可以提高开发人员的生产力,又可以提高代码质量,但大型软件项目的经验表明了不同的结果——生产力下降,代码质量几乎没有提高。......
—— https://kotlinlang.org/docs/exceptions.html#checked-exceptions
那么对于和上述代码类似的 Kotlin 代码:
import java.io.IOException;
fun main(){
throw IOException("Goodbye, World!");
}
可以正常通过编译并运行。那么 Kotlin 是做了什么魔法呢?依然用 javap
来看看:
public final class MainKt
minor version: 0
major version: 52
flags: (0x0031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER
this_class: #2 // MainKt
super_class: #4 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 2
Constant pool:
#1 = Utf8 MainKt
#2 = Class #1 // MainKt
#3 = Utf8 java/lang/Object
#4 = Class #3 // java/lang/Object
#5 = Utf8 main
#6 = Utf8 ()V
#7 = Utf8 java/io/IOException
#8 = Class #7 // java/io/IOException
#9 = Utf8 Goodbye, World!
#10 = String #9 // Goodbye, World!
#11 = Utf8 <init>
#12 = Utf8 (Ljava/lang/String;)V
#13 = NameAndType #11:#12 // "<init>":(Ljava/lang/String;)V
#14 = Methodref #8.#13 // java/io/IOException."<init>":(Ljava/lang/String;)V
#15 = Utf8 ([Ljava/lang/String;)V
#16 = NameAndType #5:#6 // main:()V
#17 = Methodref #2.#16 // MainKt.main:()V
#18 = Utf8 args
#19 = Utf8 [Ljava/lang/String;
#20 = Utf8 Lkotlin/Metadata;
#21 = Utf8 mv
#22 = Integer 1
#23 = Integer 9
#24 = Integer 0
#25 = Utf8 k
#26 = Integer 2
#27 = Utf8 xi
#28 = Integer 48
#29 = Utf8 d1
#30 = Utf8 \u0000\u0006\n\u0000\n\u0002\u0010\u0002\u001a\u0006\u0010\u0000\u001a\u00020\u0001
#31 = Utf8 d2
#32 = Utf8
#33 = Utf8 Main.kt
#34 = Utf8 Code
#35 = Utf8 LineNumberTable
#36 = Utf8 LocalVariableTable
#37 = Utf8 SourceFile
#38 = Utf8 RuntimeVisibleAnnotations
{
public static final void main();
descriptor: ()V
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
Code:
stack=3, locals=0, args_size=0
0: new #8 // class java/io/IOException
3: dup
4: ldc #10 // String Goodbye, World!
6: invokespecial #14 // Method java/io/IOException."<init>":(Ljava/lang/String;)V
9: athrow
LineNumberTable:
line 4: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x1009) ACC_PUBLIC, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #17 // Method main:()V
3: return
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 args [Ljava/lang/String;
}
作为受查异常的 IOException
依然通过 athrow
指令照常抛出,但是却没有任何的处理措施 —— 无论是异常表还是 Exception
属性表。万里长城今犹在,不见当年秦始皇。