概述
异常(exception),该术语是异常事件(exceptional event)的简称。 异常是一个事件,它发生在程序执行过程中,扰乱程序指令的正常流程。
异常对象(exception object),发生错误的方法创建,包含错误信息,包括其类型和发生错误时的程序状态。
创建一个异常对象并将其交由运行时系统(runtime system)处理,这被称为抛出异常(throwing an exception)。
分类
异常分为两大类:受检(checked)异常和非受检(unchecked)异常。
非受检异常包括:错误(Error)和运行时异常(RuntimeException)。与非受检异常对应,其他所有异常都是受检异常。
语法
1 | try |
Java 7 加入多重捕获(multicatch)语法:
1 | catch(ExceptionType | ExceptionType2 ex) |
Java 7 还加入了 Try-with-resources(TWR)语法,用以管理自动关闭的资源:
1 | try(AutoCloseableImp auto = ...) |
新特性
多重异常捕获(multicatch)
多重 catch 子句可捕获的异常是表示其异常参数类型的并集中的类型。
实际上,多重异常捕获是编译器把捕获多个异常的 catch 子句转换成了多个 catch 子句,在每个 catch 子句中捕获一个异常。
多重异常捕获catch子句中异常参数的具体类型是所有列出异常在类继承层次结构上的最小祖先类。 因为所有异常都是 Exception
类的后代,所以这样一个最小的上界总是会存在的。
多重异常捕获时,异常变量有隐式的 final
修饰,因此 catch
子句内不能对异常变量重新赋值。 过渡阶段最好显式地使用 final
修饰异常变量,以使语意明确。
Try-with-resources(TWR)
try
语句中声明的资源类型都要实现 java.lang.AutoCloseable
接口,否则编译错误。
传统的 try…finally… 写法关闭资源时,如果 try
块和 finally
块中都抛出异常,那么 try
块中的异常将被抑制(suppressed); TWR 则不会,原来的异常会重新抛出,而 close()
方法抛出的异常会“被抑制”,这些异常将自动捕获,并由 addSuppressed()
方法增加到原来的异常,可调用 getSuppressed()
方法获取。
TWR 中资源是按创建顺序相反的顺序关闭的。
TWR 语句也可以有 catch
和 finally
块,它们将在声明的资源被关闭后运行。不过在实际中,一个 try
语句中加入这么多内容可能不是一个好主意。
Closeable
接口继承自 AutoCloseable
接口,前者 close
方法抛出 IOException
,后者抛出 Exception
。
final rethrow
有一种异常处理场景是:捕获异常仅仅为了记录日志,之后会重新抛出捕获的异常。
在 Java 6 及之前的编译器中存在一个“缺陷”:如果捕获的是 Exception
或某种比实际异常类型更宽泛的异常类型, 那么重抛的异常类型将视为这种宽泛的异常类型,而丢失了具体性。
但是,Java 7 的编译器可以更精确地检查重抛异常的类型,而不会丢失异常的具体性。不过,如果在 catch
块中给异常重新赋值, 那么编译器将不能精确识别异常类型,而退化为之前的方式处理。
Lambda表达式
Java 8 加入 Lambda 表达式,它不能抛出任何异常类,并且没有任何可以在其上执行异常分析的直接子表达式。包含表达式和语句的 Lambda 表达式体可以抛出异常类。
特殊说明
将异常声明为受检异常还是非受检异常的一条指导原则是:如果客户端可以合理地从一个异常中恢复,那么应该将异常声明为受检异常, 否则,应该声明为非受检异常。
受检异常必须服从捕获或指定要求(the Catch or Specify Requirement)。
编写良好的程序应该预见并恢复受检异常。
错误通常不可恢复,也不必捕获或抛出。但如果是在一个测试执行引擎中,测试脚本所抛出的错误也应在捕获范畴。
运行时异常通常不可预见或恢复,并且往往它暗示程序中存在 bug,比如逻辑错误或是不恰当的 API 使用。
throw
语句可抛出的是任意 Throwable
及其子类的实例,对应地,throws
声明方法可能抛出的异常类型与之类似。
可以在任意地方捕获 RuntimeException
和 Exception
,但受检异常只有当 try
块可能抛出该异常时才能捕获。
如果只是把当前异常重新抛出,那么 printStackTrace()
方法显示的将是原来异常抛出点的调用栈信息,而并非重新抛出点的信息。 要想更新这个信息,可以调用 fillInStackTree()
方法。如果要把其他类型的异常链接起来,应该使用 initCause()
方法而不是构造器。
尽量避免在 finally
块中递归调用可能引起异常的方法,这将导致该方法的异常不能被正常抛出,最终被 StackOverflowError
掩盖。
尽量避免在 finally
块中使用 return
或 throw
等导致方法控制权转移的语句。
涉及方法覆盖时,覆盖方法的 throws
子句声明可能抛出的异常应是被覆盖方法可能抛出异常的子集; 当涉及覆盖多个接口的同一方法时,throws
子句声明可能抛出的异常应是它们可能抛出异常的交集而非并集,极端情况下覆盖方法可能根本不能有 throws
子句。
该规定的原因在于,对于任何适用于父类或父接口(下统一简称“父类”)的方法调用,应同样适用于子类或实现类(下统一简称“子类”),这是符合父子类概念的。 否则,如果子类方法声明的异常范围能超出父类声明的范围,那么,理应存在未处理的异常而导致编译期错误,但对于上转型或接口回调等情况下,编译期是不能对其检查的。
关于执行流程
当程序进入 try
块后,才可能执行 catch
和 finally
块。通常情况下,finally
块是尽一切可能确保执行的, 但是未必一定会执行,以下情况时将不会执行:
- 执行
try
或catch
块时,JVM 退出(如System.exit(0)
); - 正在执行
try
或catch
块的线程被中断或 kill 掉,即使整个程序还在继续执行。
finally
在 try
或 catch
块中存在控制转移语句时,finally
块的执行情况会有一些特殊。
控制转移语句包括:return
、throw
和 break
、continue
,前两者将控制权转交给方法调用者,后两者控制权在方法内转移。
finally
块将在控制权转移前执行。对于 return
的返回值和 throw
抛出的异常,是不能在 finally
块中改变的, 因为底层会将返回值及异常事先保存在本地变量表中,当 finally
块执行完成后再取回。
此外,如果 return
和 throw
后是一个表达式,那么执行 finally
块前这个表达式的值会被计算。 由于该表达式计算过程中引起的变量值的变化在执行 finally
块前,所以在 finally
块中使用这些改变了值的变量时要注意其当前值。
如果在 try
或 catch
块中执行 System.exit(0)
,则由于 JVM 退出,从而 finally
块不会被执行。此时,清理工作可在如下两个地方执行:
- 系统中注册的关闭钩子:
Runtime.getRuntime().addShutdownHook(Thread thread);
- 若调用了
System.runFinalizerOnExit(true)
,JVM 会对未结束的对象调用Finalizer
。(该方法极度危险,不推荐使用)
Java异常处理机制的优势
- 分组和区别错误类型;
- 深入了解错误产生的原因,传播错误的调用栈;
- 将错误处理代码与常规代码分离;
- 强制用户处理特定异常(受检异常)。
惯用技巧
双层捕获关闭流
1 | InputStream in = ...; |
说明:内层的 try
语句块只有一个职责,就是确保关闭输入流。 外层的 try
语句块也只有一个职责,就是确保报告出现的错误。 这种设计方式不仅清楚,而且还具有一个功能,就是将会报告 finally
子句中出现的错误。
抛出受检异常
1 | public class ThrowCheckedExceptionToUnckecked |
说明:对于受检异常捕获/抛出检查是由编译器进行的,而非 JVM。而通过泛型的特殊应用,我们可以绕过编译器检查抛出受检异常。
附录
定义
异常转译
捕获原始异常,然后抛出一个新的业务异常,新的业务异常中包含了对用户的提供信息。
杂项
RuntimeException
这个名字具有迷惑性,因为,我们讨论的所有错误都发生在运行时。
Java7 几乎把所有“资源类”(包括文件 IO 的各种类、JDBC 编程的 Connection
、Statement
等接口)进行了改写,改写后资源类都实现了 AutoCloseable
或 Closeable
接口。