一、异常的基本概念
在讲 JVM 是如何处理异常之前,我们先来复习一下异常的分类。这张图是我们刚开始学习 Java 异常再熟悉不过的一张图了吧。在这里还是要唠叨一下,在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。第一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。第二子类则是 Exception,涵盖程序可能需要捕获并且处理的异常。Exception 有一个特殊的子类 RuntimeException,用来表示“程序虽然无法继续执行,但是还能抢救一下”的情况。
RuntimeException 和 Error 属于 Java 里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在 Java 语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。
抛出异常可分为显式和隐式两种。
显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。
隐式抛异常的主体则是 Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。举例来说,Java 虚拟机在执行读取数组操作时,发现输入的索引值是负数,故而抛出数组索引越界异常。
捕获异常则涉及了如下三种代码块。
1、try 代码块:用来标记需要进行异常监控的代码。
2、catch 代码块:跟在 try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
3、finally 代码块:跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。
异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。
既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非 throw 语句的位置,而是新建异常的位置。
因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,我们往往选择抛出新建异常实例的原因。
二、JVM 是如何捕获异常的?
提到 JVM 处理异常的机制,就不得不提 Exception Table,称为异常表,我们这里先不着急介绍异常表,先来看个处理异常的小例子。
1、try-catch
public class SimpleTryCatch {
public static void main(String[] args) {
try {
testNPE();
} catch (Exception e) {
e.printStackTrace();
}
}
private static void testNPE() {
}
}
上面是一个捕获空指针异常的小例子。
我们还是老规矩,用 javac 先把java文件编译成class文件,然后javap来分析字节码。
javac.exe SimpleTryCatch.java
javap -v SimpleTryCatch
public com.jvm.SimpleTryCatch();
descriptor: ()V
flags: 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: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: invokestatic #2 // Method testNPE:()V
3: goto 11
6: astore_1
7: aload_1
8: invokevirtual #4 // Method java/lang/Exception.printStackTrace:()V
11: return
Exception table:
from to target type
0 3 6 Class java/lang/Exception //异常表条目
异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下
- from 可能发生异常的起始点
- to 可能发生异常的结束点
- target 上述from和to之前发生异常后的异常处理者的位置
- type 异常处理者处理的异常的类信息
编译过后,该方法的异常表拥有一个条目。其 from 指针和 to 指针分别为 0 和 3,代表它的监控范围从索引为 0 的字节码开始,到索引为 3 的字节码结束(不包括 3)。该条目的 target 指针是 6,代表这个异常处理器从索引为 6 的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是 Exception。
那么异常表用在什么时候呢?
答案是异常发生的时候,当一个异常发生时
- JVM会在当前出现异常的方法中,查找异常表,是否有合适的处理者来处理。
- 如果当前方法异常表不为空,并且异常符合处理者的from和to节点,并且type也匹配,则JVM调用位于 target 指针指向的字节码来处理。
- 如果上一条未找到合理的处理者,则继续查找异常表中的剩余条目。
- 如果当前方法的异常表无法处理,则向上查找(弹栈处理)刚刚调用该方法的调用处,并重复上面的操作。
- 如果所有的栈帧被弹出,仍然没有处理,则抛给当前的Thread,Thread则会终止。
- 如果当前Thread为最后一个非守护线程,且未处理异常,则会导致JVM终止运行。
以上就是JVM处理异常的一些机制。
2、try-catch-finally
public class SimpleTryCatchFinally {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 11: 0
line 15: 5
line 16: 10
line 12: 13
line 13: 14
line 15: 19
line 16: 24
line 15: 27
line 17: 35
line 18: 40
可以看到,iconst_2 字节码出现了三次,所以编译结果包含三份 finally 代码块。其中,前两份分别位于 try 代码块和 catch 代码块的正常执行路径出口。最后一份则作为异常处理器,监控 try 代码块以及 catch 代码块。它将捕获 try 代码块触发的、未被 catch 代码块捕获的异常,以及 catch 代码块触发的异常。
3、 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是哪个呢?
public class SimpleTryCatchFinally {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
throw new IllegalArgumentException();
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: new #6 // class java/lang/IllegalArgumentException
22: dup
23: invokespecial #7 // Method java/lang/IllegalArgumentException."<init>":()V
26: athrow
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #8 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 28 27 any
LineNumberTable:
line 11: 0
line 16: 5
line 17: 10
line 12: 13
line 13: 14
line 14: 19
line 16: 27
line 18: 35
line 19: 40
可以看到,iconst_2 字节码只出现了两次,catch 完异常并没有走 finally 代码块,也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。
4、catch 先后顺序的问题
我们在代码中的 catch 的顺序决定了异常处理者在异常表的位置,所以,越是具体的异常要先处理,否则就会出现下面的问题:
private static void misuseCatchException() {
try {
testNPE();
} catch (Throwable t) {
t.printStackTrace();
} catch (Exception e) { //error occurs during compilings with tips Exception Java.lang.Exception has already benn caught.
e.printStackTrace();
}
}
这段代码会导致编译失败,因为先捕获 Throwable 后捕获 Exception,会导致后面的catch 永远无法被执行。
5、return 和 finally 的问题
类似这样的代码,既有 return,又有 finally,那么 finally 到底会不会执行?
public class SimpleTryCatchFinallyReturn {
public static String tryCatchFinallyReturn() {
try {
testNPE();
return "success";
} catch (Exception e) {
return "error";
} finally {
System.out.println("finally");
}
}
public static void testNPE() {
}
}
答案是 finally 会执行,那么还是使用上面的方法,我们来看一下为什么finally会执行。
public static java.lang.String tryCatchFinallyReturn();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: invokestatic #2 // Method testNPE:()V
3: ldc #3 // String success
5: astore_0
6: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #5 // String finally
11: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14: aload_0
15: areturn
16: astore_0
17: ldc #8 // String error
19: astore_1
20: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
23: ldc #5 // String finally
25: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: aload_1
29: areturn
30: astore_2
31: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
34: ldc #5 // String finally
36: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
39: aload_2
40: athrow
Exception table:
from to target type
0 6 16 Class java/lang/Exception
0 6 30 any
16 20 30 any
LineNumberTable:
line 6: 0
line 7: 3
line 11: 6
line 8: 16
line 9: 17
line 11: 20
不难看出,有三个 String finally,故既有 return,又有 finally,那么 finally 会执行。
三、Java 7 的 Suppressed 异常以及 try-with-resources 的语法糖
Java 7 引入了 Suppressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。
然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁琐。你不可能 try-finally 里一直循环内嵌 try-finally 吧,代码可读性非常差,而且严重影响性能。
FileInputStream in0 = null;
FileInputStream in1 = null;
FileInputStream in2 = null;
...
try {
in0 = new FileInputStream(new File("in0.txt"));
...
try {
in1 = new FileInputStream(new File("in1.txt"));
...
try {
in2 = new FileInputStream(new File("in2.txt"));
...
} finally {
if (in2 != null) in2.close();
}
} finally {
if (in1 != null) in1.close();
}
} finally {
if (in0 != null) in0.close();
}
为此,Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Suppressed 异常。当然,该语法糖的主要目的并不是使用 Suppressed 异常,而是精简资源打开关闭的用法。
Java 7 的 try-with-resources 语法糖,极大地简化了上述代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close() 操作。在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Suppressed 异常的功能,来避免原异常“被消失”。
public class Foo implements AutoCloseable {
private final String name;
public Foo(String name) { this.name = name; }
@Override
public void close() {
throw new RuntimeException(name);
}
public static void main(String[] args) {
try (Foo foo0 = new Foo("Foo0"); // try-with-resources
Foo foo1 = new Foo("Foo1");
Foo foo2 = new Foo("Foo2")) {
throw new RuntimeException("Initial");
}
}
}
// 运行结果:
Exception in thread "main" java.lang.RuntimeException: Initial
at Foo.main(Foo.java:18)
Suppressed: java.lang.RuntimeException: Foo2
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo1
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo0
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可。
// 在同一catch代码块中捕获多种异常
try {
...
} catch (SomeException | OtherException e) {
...
}