本文为《深入学习 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 将其标记,在下一个回收过程中被回收。