对于开发者来说,应该没有哪位老铁敢自诩自己开发的程序不会出错吧,越是复杂的应用,在开发的过程中,可能出现问题或者考虑不到的地方就会越多。这种可能在我们预料之外的情况,我们通常将其称为异常(Exception),只有正确处理好意外情况,才能保证程序的可靠性。Java 语言在设计之初就提供了相对完善的异常处理机制,这也是 Java 得以大行其道的原因之一,因为这种机制也大大降低了编写和维护可靠程序的门槛。
对异常的一些基础概念不了解老铁们,可以看下异常到底该怎么处理,接下来,我们聊一些关于异常处理流程相关的内容。看后,相信你会有一定的收获。
我们都知道异,常的处理在java语言层面的处理方式是利用 try{}catch()finally{} 范式来处理的。其中:
try代码块,是可能出来异常的代码。
catch代码块,是对try代码块中抛出异常的处理逻辑。
finally代码块,是try和catch代码块执行后,一定会执行的代码逻辑。
正常情况下,代码的执行流程从上到下,先执行try,在执行catch,最后执行finally。然而,很多时候,try中抛出的异常,catch可能无法处理,即使catch可以处理,但是catch在处理的过程中也有可能会产生新的异常。对于后面两种特殊情况的处理,是由finally来完成的,finally会捕获到这些异常,但是由于finally中没有执行这些异常的引用,所以finally对捕获异常的处理,只能将他们抛出去。
异常的处理流程可以参考下图:
上图是目前java中对异常处理的实现方式。java编译器会将finally代码块的内容,复制到try-catch代码块所有正常执行路径和以及异常执行路径的出口处。对于正常执行路径:try代码正常执行的情况和try触发的异常被catch捕获情况,如图中蓝色和黄色的finally代码块。对于异常执行路径:try中抛出了catch无法捕获异常,或者catch中抛出了异常,如图中红色的finally代码块。
下面我们从字节码层洞察一下,编译器对try-catch-finally的编译结果。为了更好的进行验证,异常处理的java语言层面的代码如下:
public class ExceptionTest {
int tryBlock ;
int catchBlock;
int finallyBlock;
int otherBlock;
public void tryCatchFinally() {
try{
tryBlock = 1;
}catch (Exception e) {
catchBlock = 2;
}finally {
finallyBlock = 3;
}
otherBlock = 4;
}
}
通过使用javap命令可以查看编译后的字节码:
public void tryCatchFinally();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_1
2: putfield #5 // Field tryBlock:I
5: aload_0
6: iconst_3
7: putfield #6 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_2
16: putfield #8 // Field catchBlock:I
19: aload_0
20: iconst_3
21: putfield #6 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_3
30: putfield #6 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_4
37: putfield #9 // Field otherBlock:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
从字节码中可以看出,带有try-catch-finally异常处理的方法都会附带一个异常表 Exception table,表中每一个条目代表一个异常处理器,每个条目由from指针,to指针,target指针以及可处理异常类型构成。其中from和to指针表示,这个异常处理器,监控可能出现异常的代码范围(字节码层面),也就是这个异常处理器的作用范围。
type表示这个异常处理器,可以处理的异常类型。只有异常类型相互匹配的异常,才可以被这个异常处理器处理。
target:表示这个异常处理器,处理逻辑开始的指针位置。
总结来说,就是一个异常处理器,会监控从from指针到to指针(不包括to指针位置)的代码的异常情况,如果该范围内代码抛出了异常,且异常类型和该异常处理器的异常类型可匹配的话,那么程序执行流程会跳转到target指针指向的位置。
通常情况下一个方法的异常处理器会有多个,在本例中有3个,且三个异常处理器的优先级从上到下依次递减,当出现异常后,会从上到下遍历异常表,寻找符合条件的异常处理器,如果遍历完所有异常处理器后,jvm仍未找到匹配的异常处理器的话,那么它会弹出当前方法对应的栈帧,在调用者中异常表中查找合适的异常处理器。
在本例的异常处理表中,我们可以看到第一个异常处理器的from和to指针分别执行0和5,刚好就是try代码块的范围,如果该范围内代码抛出的异常被该异常处理器处理话,那么程序就会跳转到taget指向的8,并从这个位置开始向后执行。也就是执行 catch代码块,finally代码块和otherBlock。
第二个异常处理器的from和to指针指向的位置和第一个异常处理器相同,只不过异常类型为any,any表示任何异常,该异常处理器,刚好是第一个异常处理的一个补充,用来处理try代码块抛出的异常不能被catch捕获的情况,对于这种情况,程序执行流程会跳转到target指针指向的27行,也就是finally代码块,然后再将异常throw出去,也就是34行指向的字节码。
第三个异常处理器的from和to指针指向13和19,也就是catch代码块,该异常处理器可以处理任何类型的异常,对catch代码块抛出异常的处理方式是将程序执行流程跳转到27行,和第二个异常处理器的处理逻辑相同。
看到这里,对于Java中异常处理流程,你是不是有了一个更深层的认识了呢?
优雅的处理异常
其实对于上面第三个异常处理器,还有一个小问题,catch处理try中抛出的异常的过程中,又抛出了异常,那么被finally捕获后并重新抛出的异常,是try代码块中抛出的异常,还是catch代码块中抛出的异常呢? 答案是后者。也就是说最开始触发的异常被忽略掉了,这对于问题的排查是不利的,因为异常打印的堆栈中,并没有最开始出现问题的代码信息。
为了解决这个问题,在java7中引入了Suppressed异常来解决这个问题,这个新特性,可以将一个异常附于另一个异常之上,这就可以在一个异常上附加多个异常的信息。
除此之外,为了方便对资源类型对象的释放,在java7中专门构造了一个名为try-with-resource的语法糖,该语法糖可以精简资源的打开关闭。
在java7前,为了保证打开的资源在异常情况下也可以正常关闭,每一个资源都要对应一个独立的try-finally代码块,将关闭资源的操作放在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();
}
而使用try-with-resouce语法糖,则可以极大的简化上述代码,该语法糖的使用,需要对应的资源类型实现AutoCloseable接口类,使用如下语法糖,可以实现和上述代码相同的作用,而且还会使用到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)
看到这里是不是觉得很神奇,好奇这个语法糖是如何实现的呢?其实很多语法糖都是编译器提供的一种障眼法,通过查看编译后的字节码,也就没什么秘密了。使用了语法糖可以在java语言层面简化代码,但是字节码层面,代码并没有简化。有兴趣的老铁,可以使用javap查看相应的字节码,进行验证。
异常被finally吞掉了?
最后和大家分享一个异常处理的踩坑经历,如果finally中有return语句的话,那么finally捕获到的异常,就不会抛出去了,给人一种异常被吞掉的感觉。
实验代码如下:
public void tryCatchFinallyWithReturn() {
try{
throw new RuntimeException("try exception");
}catch (NullPointerException e) {
// ignore
}finally {
return;
}
}
而如果finally中没有return的话,异常堆栈会被错误输出流打印出来,为什么使用错误输出流打印呢,可以参考 如何在多线程环境中优雅的处理异常。
为什么有return的情况下,异常不会抛出去呢,老规矩,看一下字节码就清楚了。
public void tryCatchFinallyWithReturn();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: new #10 // class java/lang/RuntimeException
3: dup
4: ldc #15 // String try exception
6: invokespecial #12 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
9: athrow
10: astore_1
11: return
12: astore_2
13: return
Exception table:
from to target type
0 10 10 Class java/lang/NullPointerException
0 11 12 any
try代码块中抛出的异常,会被第二个异常处理器处理,该异常处理器的处理逻辑从12行开始,接着13行就return了,并没有athrow指令。为了方便对比,我们再查看一下,把finally代码块中return去掉后的字节码:
public void tryCatchFinallyWithReturn();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: new #10 // class java/lang/RuntimeException
3: dup
4: ldc #15 // String try exception
6: invokespecial #12 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
9: athrow
10: astore_1
11: goto 17
14: astore_2
15: aload_2
16: athrow
17: return
Exception table:
from to target type
0 10 10 Class java/lang/NullPointerException
0 11 14 any
同样是第二个异常处理器处理处理异常情况,处理逻辑从14行开始,获取捕获的异常实例,然后通过athrow完成抛出操作。
到这里,终于明白了为啥finally中有return的情况下,异常不会抛出去了。关于语法糖,它只是编译器在语言层面的一种特殊语法的支持,可以很大程度上减少语言层面的代码量,但是在字节码层面,代码量并没有减少,有可能还会更加复杂,到这里突然想到一句很有道理的话:哪有什么岁月静好,只不过有人替你负重前行。