聊一聊Java异常:JVM是如何捕获异常原理和实例分析原创
正文
异常的基本概念
在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。
- Throwable 是所有异常的根,java.lang.Throwable
- Error 是错误,java.lang.Error
- Exception 是异常,java.lang.Exception
Error类一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢出等。对于这类错误导致的应用程序中断,仅靠程序本身无法恢复和和预防,遇到这样的错误,建议让程序终止。
Exception类表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。
Exception 又包含了运行时异常(RuntimeException, 又叫非检查异常)和非运行时异常(又叫检查异常)
-
运行时异常都是 RuntimeException 类及其子类,如 NullPointerException、IndexOutOfBoundsException 等, 这些异常是不检查的异常, 是在程序运行的时候可能会发生的, 所以程序可以捕捉, 也可以不捕捉。这些错误一般是由程序的逻辑错误引起的, 程序应该从逻辑角度去尽量避免。
-
检查异常是运行时异常以外的异常, 也是 Exception 及其子类, 这些异常从程序的角度来说是必须经过捕捉检查处理的, 否则不能通过编译. 如 IOException、SQLException 等。
通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。
异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。
当然,在生成栈轨迹时,Java 虚拟机会忽略掉异常构造器以及填充栈帧的 Java 方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外,Java 虚拟机还会忽略标记为不可见的 Java 方法栈帧。
JVM是如何捕获异常的?
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。
⚠️:每个方法都带有异常表吗?还是只有实现了catch代码块的方法才带有?经过实际验证,只有实现了catch代码块的方法才有异常表。
如下述案例所示:
public class NoExceptionTest {
public static void main(String[] args) {
eat();
}
public static void eat() {
System.out.println("恰个饭吧");
}
}
查看其字节码文件,没有发现 Exception table 相关内容。
其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。
public class ExceptionTest {
public static void main(String[] args) {
try {
mayArithmeticException(0);
} catch (Exception e) {
e.printStackTrace();
}
}
public static void mayArithmeticException(int num) {
double result = 1 / num;
}
}
//查看其字节码
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: iconst_0
1: invokestatic #2 // Method mayArithmeticException:(I)V
4: goto 12
7: astore_1
8: aload_1
9: invokevirtual #4 // Method java/lang/Exception.printStackTrace:()V
12: return
Exception table:
from to target type
0 4 7 Class java/lang/Exception
查看字节码后,我们可以知道,main 方法中对应有一个异常表,该表拥有一个条目。其 from 指针和 to 指针分别为 0 和 4,代表它的监控范围从索引为 0 的字节码开始,到索引为 4 的字节码结束(不包括 4)。该条目的 target 指针是 7,代表这个异常处理器从索引为 7 的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是 Exception。
当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。如我们的示例代码,抛出的是 ArithmeticException,而我们 catch 的 Exception 是所有异常的父类,所以是匹配的。
public class MyException extends ArithmeticException {
@Override
public void printStackTrace() {
System.out.println("自定义的错误");
}
}
public class ExceptionTest {
public static void main(String[] args) {
int sum = 0;
try {
mayArithmeticException(0);
} catch (MyException e) {
sum = 121;
} catch (ArithmeticException e) {
sum = 119;
}
System.out.println(sum);
}
public static void mayArithmeticException(int num) {
double result = 1 / num;
}
}
执行结果为 119,由此我们可知 MyException 虽然是 ArithmeticException 的子类,但是两者并不匹配。
如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。
比如说我们将上述代码中的 ArithmeticException 异常改为 NullPointerException,那么遍历完所有异常表条目也没有匹配上的异常处理器,那么则会抛出这种代码未捕获的异常。
finally代码块
finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。
针对上述 ExceptionTest 的实现我们做一点修改:
int sum = 0;
try {
mayArithmeticException(0);
} catch (MyException e) {
sum = 121;
} catch (ArithmeticException e) {
sum = 119;
} finally {
System.out.println(sum);
sum = 999;
}
System.out.println(sum);
执行结果为:
119
999
查看编译后的字节码文件,重点查看 Exception table
Exception table:
from to target type
2 6 20 Class com/msdn/java/hotspot/exception/MyException
2 6 38 Class java/lang/ArithmeticException
2 6 56 any
20 24 56 any
38 42 56 any
观察可知,加了 finally 后,Java 编译器监控整个 try-catch 代码块,并且捕获所有种类的异常(在 javap 中以 any 指代)。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且,在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。
如果 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是哪个呢?
答案是后者。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。如下述代码所示,最终执行结果只会抛出空指针异常,而我们是无法查看上一层的异常,即算术运算异常。
public static void main(String[] args) {
int sum = 0;
try {
mayArithmeticException(0);
} catch (MyException e) {
sum = 121;
} catch (ArithmeticException e) {
sum = 119;
mayNullPointerException();
} finally {
System.out.println(sum);
sum = 999;
}
System.out.println(sum);
}
public static void mayNullPointerException(){
Object object = null;
System.out.println(object.toString());
}
关于上述问题后文将给出答案,我们继续往下看。
Suppressed 异常以及语法糖
Java 7 引入了 Suppressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。
来看一个简单的示例:
public class Main {
public static void main(String[] args) throws Exception {
Exception origin = null;
try {
System.out.println(Integer.parseInt("abc"));
} catch (Exception e) {
origin = e;
throw e;
} finally {
Exception e = new IllegalArgumentException();
if (origin != null) {
e.addSuppressed(origin);
}
throw e;
}
}
}
执行结果如下:
Exception in thread "main" java.lang.IllegalArgumentException
at com.msdn.java.hotspot.exception.Main.main(Main.java:33)
Suppressed: java.lang.NumberFormatException: For input string: "abc"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:580)
at java.lang.Integer.parseInt(Integer.java:615)
at com.msdn.java.hotspot.exception.Main.main(Main.java:28)
总结来说,先用origin
变量保存原始异常,然后调用Throwable.addSuppressed()
,把原始异常添加进来,最后在finally
抛出。
然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁琐。
为此,Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Suppressed 异常。当然,该语法糖的主要目的并不是使用 Suppressed 异常,而是精简资源打开关闭的用法。
我们来看一个资源关闭的示例代码,每次打开之后都需要关闭,如果有多个文件打开,那关闭就更麻烦了。
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 语法糖,极大地简化了上述代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close() 操作。在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Suppressed 异常的功能,来避免原异常“被消失”。
public class ExceptionalResource implements AutoCloseable {
public void processSomething() {
throw new IllegalArgumentException("Thrown from processSomething()");
}
@Override
public void close() throws Exception {
throw new NullPointerException("Thrown from close()");
}
public static void main(String[] args) throws Exception {
try (ExceptionalResource exceptionalResource = new ExceptionalResource()) {
exceptionalResource.processSomething();
}
}
}
除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。
// 在同一catch代码块中捕获多种异常
try {
...
} catch (SomeException | OtherException e) {
...
}
扩展
try-catch-finally 中,如果 catch 中 return 了,finally 还会执行吗?
public class TryStudy {
public static int test(String str){
try {
return str.charAt(0) - '0';
}catch (Exception e){
return 1;
}finally {
return 2;
}
}
public static int test2(String str){
try {
return str.charAt(0) - '0';
}catch (Exception e){
return 1;
}finally {
System.out.println("finally....");
}
}
public static void main(String[] args) {
//当finally里有return时,返回finally里return的结果,撤销之前的return语句
System.out.println(test(null)+","+test("3"));
System.out.println("****************");
//当try里无异常,且有返回值时,先执行finally再返回值;当try有异常,catch里有返回,先执行finally在return
System.out.println(test2(null)+","+test2("3"));
}
}
//执行结果为:
2,2
****************
finally....
finally....
1,3
异常处理完成以后,Exception对象会发生什么变化?
某个 Exception 异常被处理后,该对象不再被引用,gc 将其标记,在下一个回收过程中被回收。