在 JAVA 中,异常处理的方式主要是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移。
抛出异常可以分为显示和隐式两种。显示抛出异常的主体是应用程序,它指的是在程序中使用 throw 关键字,手动将异常实例抛出。隐式抛出异常的主题是 Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。例如数组越界异常。
捕获异常主要设计一下三种代码块:
1:try 代码块,用来标记需要进行异常监控的代码。
2:catch 代码块,跟在 try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会自上而下匹配异常处理器,所以前面的 catch 代码块不能覆盖后面的,否则编译器报错。
3:finally 代码块,跟在 catch 代码块之后,用来声明一段必定运行的代码。它的设计是为了避免跳过某些关键的清理代码。比如关闭已经打开的系统资源。
在程序正常执行情况下, finally 代码块会在 try 代码块之后运行。
如果 try 代码块触发异常,异常没有被捕获的情况下,finally 代码块会直接运行,并在运行结束后重新抛出异常。如果该异常被 catch 代码块捕获,finally 代码块则会在 catch 代码块之后运行。在某些情况下,catch 代码块也触发了异常,那么 finally 代码同样会执行,并抛出 catch 代码块触发的异常。如果 finally 代码块也触发了异常,那就中断 finally 代码块,向上抛出异常。
异常的基本概念
在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实现。
Throwable 类有两大直接子类。一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 的时候,它的执行状态已经无法会发,需要终止线程甚至是终止虚拟机。一个是 Exception ,涵盖程序可能需要捕获并且处理的异常。
RuntimeException 是 Exception 的一个特殊子类,用来表示程序无法继续执行,但是还能抢救一下的情况,数组越界便是其中一种。
RuntimeException 和 Error 属于 Java 里的非检查异常。其他异常则属于检查异常。在Java 语法中,所有的检查异常都需要程序显示地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。
异常实例的构造十分昂贵。在构造异常实例时,Java 虚拟机需要生成该异常的栈轨迹。该曹组会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向的方法的名字,方法所在的类名,文件名,以及在代码中的第几行触发该异常。在生成栈轨迹时,Java 虚拟机会忽略掉异常构造器以及填充栈帧的 Java 方法,直接从新建异常位置开始算起。此外,Java 虚拟机还会忽略标记为不可见的 Java 方法栈帧。
异常实例的构造昂贵,但是却没有做缓存优化。如果做了缓存优化,那么抛出的异常实例对应的栈轨迹并非 throw 语句的位置了,而是第一次新建异常的位置。所以,为了准确的定位到错误的位置,我们往往选择抛出新建异常实例。
Java 虚拟机是如何捕获异常的
在编译生成的字节码中,每个方法都附带一个异常表,异常表中的每一个条目代表一个异常处理器,并且由 form 指针,to 指针,target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引,用来定位字节码。
from 指针和 to 指针标示了该异常所监控的范围:try 代码块所覆盖的范围。
target 指针标示了异常处理器的起始位置:catch 代码块的起始位置。
当程序处罚异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码索引值在某个异常表条目的监控范围内,Java 虚拟机再判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。
如果遍历完异常表的条目未曾匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者中重复上述操作。
finally 代码块的编译比较复杂。当前版本 Java 编译器的做法:复制 finally 代码块内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。
针对异常执行路径,Java 编译器会生成一个(上图变种2)或者多个(上图变种1)异常表条目,监控整个 try-catch 代码块,并且捕获所有种类的异常。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块(上图变种1,变种2 中红色 finally block),并且重新抛出捕获的异常。
问题:如果 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是 catch 代码块触发的新的异常,原本的异常就被忽略了。这对代码调试来说,就不友好了。
Java 7 中引出了 Supressed 异常来解决上面的问题。这个新特性允许开发人员将一个异常附在另一个异常上,这样抛出的异常就可以附带多个异常的信息。
问答
Q:为什么使用异常捕获的代码比较耗费性能
单从 Java 语法上看不出来,但是从 JVM 实现的细节上来看就明白了。构造异常实例,需要生成该异常的栈轨迹。该操作会逐一访问当前线程的栈帧,记录各种调试信息,包括类名,方法名,触发异常的代码行数等等。
Q:finally 是怎么实现无论异常与否都能执行
编译器在编译代码时会复制 finally 代码块放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口处。
Q:finally 中有 ruturn 语句,catch 中抛出的异常会被忽略,为什么
catch 抛出的异常会被 finally 捕获,执行完 finally 后会重新抛出该异常。由于 finally 中有 return 语句,在重新抛出异常之前,代码就已经返回了。
Q:方法的异常表都包含哪些异常
方法的异常表只声明这段代码会被捕获的异常,而且是非检查异常。如果 catch 中有自定义异常,那么异常表中也会包含自定义异常的条目。
Q:检查异常和非检查异常也就是其他书籍中说的编译期异常和运行时异常?
检查异常也会在运行过程中抛出。但是它会要求编译器检查代码有没有显式地处理该异常。非检查异常包括Error和RuntimeException,这两个则不要求编译器显式处理。
总结
本文创作灵感来源于 极客时间 郑雨迪老师的《深入拆解 Java 虚拟机》课程,通过课后反思以及借鉴各位学友的发言总结,现整理出自己的知识架构,以便日后温故知新,查漏补缺。