本文是笔者在经过学习及工作实践后的一个关于Java异常机制的阶段性总结,同时分享出来,也希望能够帮助到其他正在学习Java异常的读者。受限于本人知识的浅薄,文中难免会有错误或解释不到位的地方,希望能够和读者于评论区中进行友善的交流与评论。
在内容上,本文仅会简单介绍异常机制的简单概念以及相关理念,会着重于介绍Java异常的底层实现,并试图通过示例代码帮助辅助读者理解,同时会在文章后半部分列举一些关于异常的使用实践和注意事项。
显然的是,没有人能够保证自己写的程序完全没有bug。因此,首先会想到的是在错误出现时,能够按照指定的、事先安排好的流程去处理它,最大程度上避免不可控的情况出现。所以异常机制的出现,就是为了使程序具有更好的容错性,让程序更加健壮。
当程序出现意外情形时,系统会自动生成一个包装好的异常对象来通知程序,从而实现“业务功能实现代码”和“错误处理代码”分离,提供更好的可读性。
首先,异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移,是处理程序处于非预期状态的方式。
抛出异常分为显示和隐式两种。
显示抛出异常的主体是应用程序(程序员当前在编写的代码),也就是程序中使用“throw”关键字,手动抛出异常实例。Java认为这种Checked异常都是可以在编译阶段被处理的异常,所以它强制程序处理所有的Checked异常。否则程序在编译阶段就会发生错误,无法通过编译。这样的设计思想,实际上是想表达出:没有完善错误处理的代码不能被执行。这在很大程度上,增加了程序的健壮性。
隐式异常的主体是Java虚拟机,它指的是JVM在执行过程中,碰到无法继续执行的异常状态,自动抛出的异常。Runtime异常并不需要应用程序手动处理。如果程序需要捕获Runtime异常,也可以使用try…catch块来实现。
捕获异常主要由下面三种代码块进行完成:
try代码块:用来标记需要进行异常监控的代码。
catch代码块:跟在try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
finally 代码块:跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。
其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。
下面会在举例的代码中补充注释进行说明以上原理(使用##标注)。
源代码:
public class ExceptionByteCode {
public static void main(String[] args) {
try {
throwNewException();
} catch (Exception e) {
e.printStackTrace();
}
}
private static void throwNewException() {
throw new RuntimeException();
}
}
编译后生成的字节码:
Compiled from "ExceptionByteCode.java" ## 字节码中出现的数字标识也就是是字节码索引
public class com.zzz.exception.ExceptionByteCode {
public com.zzz.exception.ExceptionByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: invokestatic #2 // Method throwNewException:()V
3: goto 11
6: astore_1 ## target=6,标识异常处理器从索引为6的字节码开始,也就是异常处理器的真实位置(catch)
7: aload_1
8: invokevirtual #4 // Method java/lang/Exception.printStackTrace:()V
11: return
Exception table: ## 这里就是main()中附带的异常表
from to target type ## frome和to指示的0-3表面了该异常处理器监控的范围
0 3 6 Class java/lang/Exception ## 这里就是一个条目,也即一个异常处理器
}
当程序实际触发异常时,JVM会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内(比如0-3),Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。
如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器(也就是提前预想到捕获的异常,通常是运行时异常),那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,JVM需要遍历当前线程 Java 栈上所有方法的异常表。
finally代码块的内容在编译时比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。
图片来源
针对异常执行路径,Java 编译器会生成一个或多个异常表条目,监控整个 try-catch 代码块,并且捕获所有种类的异常(在 javap 中以 any 指代)。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且,在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。
为了辅助理解上面的描述,请看下面的代码示例:
测试类源代码:
public class ExceptionFinally {
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;
}
}
# 在测试类所在包路径下,执行以下命令,获得字节码信息
javac ExceptionFinally.java
javap -c ExceptionFinally
测试类的字节码信息:
Compiled from "ExceptionFinally.java"
public class com.zzz.exception.ExceptionFinally {
public com.zzz.exception.ExceptionFinally();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public void test();
Code:
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I ## tryBlock中的finallyBlock
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 ##catchBlock中的finallyBlock
24: goto 35
27: astore_2 ## 这里对应了“这些异常表条目的target指针将指向另一份复制的finally代码块”
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow ## 这里就是将栈顶的异常抛出 “Java编译器会重新抛出所捕获的异常”
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
}
观察字节码组成可以看到,其中包含了三份finally代码块。其中,前两份分别位于 try 代码块和 catch 代码块的正常执行路径出口。最后一份则作为异常处理器,监控 try 代码块以及 catch 代码块。它将捕获 try 代码块触发的、未被 catch 代码块捕获的异常,以及 catch 代码块触发的异常(使用any确保捕获)。
在可能存在的一种场景中,存在着异常屏蔽的情况:
如果catch代码块中捕获了异常,但触发了新的异常,那么finally捕获并且重抛的异常会是后一个异常,这样原本的异常就会被忽略掉。
如果finally代码块中也抛出了异常,那么会是这个异常向上传递,try中的异常也就被“屏蔽”了。
这样的情况对bug调试很不利。
以下是代码举例说明情况:
源代码:
public class ExceptionShield {
public static void main(String[] args) {
testExceptionShield();
}
private static void testExceptionShield() {
try {
double a = 1 / 0;
System.out.println(a);
} catch (Exception e) {
int[] a = {1, 2};
System.out.println(a[2]);
} finally {
System.out.println("finally");
}
}
}
运行结果:
finally
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 2
at com.zzz.exception.ExceptionShield.testExceptionShield(ExceptionShield.java:15)
at com.zzz.exception.ExceptionShield.main(ExceptionShield.java:6)
可以看到,try内的原始错误应该为 java.lang.ArithmeticException: / by zero 异常,但却被catch内的新异常给“屏蔽”了。
那么Java是如何解决这个问题的呢?
Java7引入了Suppressed异常机制来解决这个问题,其核心就是允许开发者将一个异常附于另一个异常之上,这样抛出的异常就可以附带多个异常的信息。实际上这是通过对Throwable类进行了修改以支持这种情况。
在java7中为Throwable类增加addSuppressed方法。当一个异常被抛出的时候,可能有其他异常因为该异常而被抑制住,从而无法正常抛出。这时可以通过addSuppressed方法把这些被抑制的方法记录下来。被抑制的异常会出现在抛出的异常的堆栈信息中,也可以通过getSuppressed方法来获取这些异常。这样做的好处是不会丢失任何异常,方便开发人员进行调试。参考以下代码:
private static void testExceptionShield() {
try {
double a = 1 / 0;
System.out.println(a);
} catch (Exception e) {
try {
int[] a = {1, 2};
System.out.println(a[2]);
} catch (Exception exception) {
exception.addSuppressed(e);
throw exception;
}
} finally {
System.out.println("finally");
}
}
得到被抑制的异常
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 2
at com.zzz.exception.ExceptionShield.testExceptionShield(ExceptionShield.java:16)
at com.zzz.exception.ExceptionShield.main(ExceptionShield.java:5)
Suppressed: java.lang.ArithmeticException: / by zero
at com.zzz.exception.ExceptionShield.testExceptionShield(ExceptionShield.java:11)
... 1 more
这里同时还是存在一个问题,假设需要在finally代码块中捕获try或者catch中的异常,由于缺乏引用,开发者需要在try代码块上面提前定义好异常的变量,以便引用,这样使用起来就比较繁琐。所以引出了下面要说的,关于try-with- resource的实现。
Java7中专门构造了这个try-with-resource的语法糖,它的主要目的是精简资源打开关闭的用法,同时它还会在字节码层面上自动使用Suppressed异常功能。本文不再赘述它的用法,着重介绍一下它的实现。
示例源代码:
public class TryWithResource implements AutoCloseable {
private final String name;
public TryWithResource(String name) {
this.name = name;
}
@Override
public void close() {
throw new RuntimeException(name);
}
public static void main(String[] args) {
try (TryWithResource test1 = new TryWithResource("test1");
TryWithResource test2 = new TryWithResource("test2");
TryWithResource test3 = new TryWithResource("test3");) {
throw new RuntimeException("Initial");
}
}
}
经过反编译字节码后的代码(编译器优化过后):
public class TryWithResource implements AutoCloseable {
private final String name;
public TryWithResource(String var1) {
this.name = var1;
}
public void close() {
throw new RuntimeException(this.name);
}
public static void main(String[] var0) {
TryWithResource var1 = new TryWithResource("test1");
Throwable var2 = null;
try {
TryWithResource var3 = new TryWithResource("test2");
Throwable var4 = null;
try {
TryWithResource var5 = new TryWithResource("test3");
Throwable var6 = null;
try {
throw new RuntimeException("Initial");
} catch (Throwable var44) {
var6 = var44;
throw var44;
} finally {
if (var5 != null) {
if (var6 != null) {
try {
var5.close();
} catch (Throwable var43) {
var6.addSuppressed(var43);
}
} else {
var5.close();
}
}
}
} catch (Throwable var46) {
var4 = var46;
throw var46;
} finally {
if (var3 != null) {
if (var4 != null) {
try {
var3.close();
} catch (Throwable var42) {
var4.addSuppressed(var42);
}
} else {
var3.close();
}
}
}
} catch (Throwable var48) {
var2 = var48;
throw var48;
} finally {
if (var1 != null) {
if (var2 != null) {
try {
var1.close();
} catch (Throwable var41) {
var2.addSuppressed(var41);
}
} else {
var1.close();
}
}
}
}
}
通过观察反编译后的字节码,可以看到try-with-resource语法糖为我们自动实现了suppressed异常。
并且资源的自动关闭是通过调用接口AutoClosable的close()实现的。
通过观察JDK类库可以观察到,JDK已经帮我们实现了大部分常用的类的close()方法,这些方法会在try-with-resource语法糖的加持下实现“自动关闭”。
除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。
实际实现非常简单,生成多个异常表条目即可。
示例源代码:
public class MoreException {
public static void main(String[] args) {
try {
final ArrayList<Object> objects = new ArrayList<>();
} catch (ArithmeticException | IndexOutOfBoundsException exception) {
throw exception;
}
}
}
编译后的字节码:
public class com.zzz.exception.MoreException {
public com.zzz.exception.MoreException();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."":()V
7: astore_1
8: goto 14
11: astore_1
12: aload_1
13: athrow
14: return
Exception table:
from to target type
0 8 11 Class java/lang/ArithmeticException
0 8 11 Class java/lang/IndexOutOfBoundsException
}
可以看到,异常条目表中只是多了具有相同from、to的条目。
这是因为在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。
以上的内容,就是异常机制的在字节码层面的体现,接下来会总结列出一些与实际开发更加息息相关的实践。
首先我们要明确,成功的异常处理应该实现的功能:
使程序代码混乱最小化
捕获并保留诊断信息
通知合适的人员
采用合适的方式结束异常活动
为了达到上述的的目标,在进行异常处理时,我们既要遵循基本使用原则,也可以学习一些得到开发人员共识的优秀经验。
滥用异常机制会带来一些负面影响,包括但不限于以下两个方面:
由于异常实例的创建对于JVM来说是十分昂贵的,所以我们应该避免异常的滥用。
对于完全已知的错误,应该编写处理这种错误的代码,增加程序的健壮性;只有对外部的、不能确定和预知的运行时错误才使用异常。
当try代码块过于庞大时,难免会在try代码块后紧跟大量的catch块才可以针对不同的异常提供不同的处理逻辑。同一个try代码块后紧跟大量的catch代码块则需要分析它们之间的逻辑关系,反而增加了编程复杂度。
正确的做法是,把大块的try代码块分割成多个可能出现异常的程序段落,并把它们放在单独的try代码块中,从而分别捕获并处理异常。
所谓catch all就是在catch语句块中,捕获程序中可能发生的所有异常,也就是直接捕获Throwable异常。这种处理方式有如下两点不足之处。
不要忽略异常,如果catch块中捕获到了异常,却什么也不做,那任何人都看不到程序出了什么问题。所以对于异常,我们要采取适当的措施:
下面贴一些阿里巴巴公布的《Java开发手册》中推荐的异常处理建议:
1.[强制] Java 类库中定义的可以通过预检查方式规避的 RuntimeException 异常不应该通过
catch 的方式来处理,比如:NullPointerException,IndexOutOfBoundsException 等等。
说明:无法通过预检查的异常除外,比如,在解析字符串形式的数字时,可能存在数字格式错误,不得不
通过 catch NumberFormatException 来实现。
正例:if (obj != null) {…}
反例:try { obj.method(); } catch (NullPointerException e) {…}
5.【强制】事务场景中,抛出异常被 catch 后,如果需要回滚,一定要注意手动回滚事务。
9.【强制】在调用 RPC、二方包、或动态生成类的相关方法时,捕捉异常必须使用 Throwable
类来进行拦截。
说明:通过反射机制来调用方法,如果找不到方法,抛出 NoSuchMethodException。什么情况会抛出
NoSuchMethodError 呢?二方包在类冲突时,仲裁机制可能导致引入非预期的版本使类的方法签名不匹配,
或者在字节码修改框架(比如:ASM)动态创建或修改类时,修改了相应的方法签名。这些情况,即使代
码编译期是正确的,但在代码运行期时,会抛出 NoSuchMethodError。
其他详细内容,可以参考完整版的内容。
另外补充一下catch和finally中的return和throw的问题。
1、finally覆盖catch(开头引子的例子):
1)如果finally有return会覆盖catch里的throw,同样如果finally里有throw会覆盖catch里的return。
(这里可以参考上面关于finally代码块的字节码实现)
2)如果catch里和finally都有return, finally中的return会覆盖catch中的。throw也是如此。
2、catch有return而finally没有:
当 try 中抛出异常且catch 中有 return 语句,finally 中没有 return 语句, java 先执行 catch 中非 return 语句,再执行 finally 语句,最后执行 catch 中 return 语句。
3、try有return语句,后续还有return语句,分为以下三种情况:
情况一:如果finally中有return语句,则会将try中的return语句”覆盖“掉,直接执行finally中的return语句,得到返回值,这样便无法得到try之前保留好的返回值。
情况二:如果finally中没有return语句,也没有改变要返回值,则执行完finally中的语句后,会接着执行try中的return语句,返回之前保留的值。
情况三:如果finally中没有return语句,但是改变了要返回的值,这里有点类似与引用传递和值传递的区别,分以下两种情况,:
1)如果return的数据是基本数据类型或文本字符串,则在finally中对该基本数据的改变不起作用,try中的return语句依然会返回进入finally块之前保留的值。
2)如果return的数据是引用数据类型,而在finally中对该引用数据类型的属性值的改变起作用,try中的return语句返回的就是在finally中改变后的该属性的值。
参考资料:
《深入拆解Java虚拟机》郑雨迪 付费专栏
《疯狂Java讲义》