Java基础学习 - Exception 的理解与总结
前言
重新学习 Java 基础知识,对异常 Exception 有了更深入的理解,同时更好地理解有关 Exception 的最佳实践的理由和依据。
异常的本质
异常的本质是一个返回值,由特定指令 throw “返回”给上层代码。可以等同理解为没有异常处理的语言 (比如 C) 中的错误代号。它与业务逻辑返回值 (return xxx) 组成联合体,一同返回给上层代码处理。
异常的实际执行流程 (控制流,control flow) 由 Java 编译器产生,开发者无需过多关心,只需关心其中三个切点:
1.范围:try { }
设定需要关心的异常的发生范围,有异常则自动跳到 catch 块执行。
2.捕捉:catch (XxxException e) { }
相当于 case 不同的异常 XxxException 作相应的处理,没有 case 的异常自动继续向上“返回”,直到被最终捕捉或者程序终止。
3.清场:finally { }
无论有没有异常都将执行的业务逻辑,常用于清场,释放资源。
由此,开发者只需要关注业务逻辑的编写,无需关心“错误代号”在每层代码的控制流如何实现,编译器帮我们实现了。当然,某些边角的情景,还是需要进一步了解其实现,避免误用犯错,比如 try 块抛错同时 finally 块也抛错的情景等。后面最佳实践理解中再述。
异常的分类
1. Checked Excpetion
受检异常,可以理解为业务异常,通常是正常业务操作流程中可能会出现的异常场景。例如,FileNotFoundException。
受检的意思是可以被编译器静态检查,有效地利用受检异常地这个特性,可以很好地在软件工程过程中,做好业务异常的变更管理。后面最佳实践理解中再述。
这类异常的特点是:
JVM 正常运行;
用户代码技术上正常运行;
本层业务流程进入已知的异常场景,可通过稍候重试、或修改输入重试来恢复进入正常场景。
因此,通常处理方式是,捕捉这类异常,修改流程条件 (如,修改输入的值) 继续重试恢复。
2. Unchecked Exception
非受检异常,是 JVM 正常执行用户代码时发生的错误,通常是用户代码技术逻辑有错误导致的,所以可以理解为代码异常。例如,NullPointerException。
这类异常的特点是:
JVM 正常运行;
本层用户代码运行不正常,存在技术错误。
本层业务流程已经被破坏,不可重试恢复。
因此,通常处理方式是,返回上层用户代码,通过日志记录这类异常的详细信息,帮助之后分析错误修改程序。
3. Error
错误,是指 JVM 运行所需的环境和条件已经缺失,由 JVM 底层报出。例如,OutOfMemoryError。
这类异常的特点是:
JVM 运行不正常,已经崩溃。
此时程序运行混乱无法预知,无法捕捉错误,甚至某些 JVM 干脆不实现 Error 的捕捉。因此,通常处理方式是,不做任何处理,让它迅速终止。之后再详细分析程序日志、标准输出、错误输出来定位问题,良好的日志规范此时会起到比较好的作用。
Java的层次结构实现
由上面归纳的三类异常的特点,很容易理解 Java 异常的层次架构:
按 JVM 可否恢复,Throwable 分为:Error、Exception。
按用户代码可否恢复,Exception 分为:RuntimeException、其他 Exception 子类 (即受检异常,Checked Exception)。
关于最佳实践的理解
1.引入异常的目的
是为了避免问题再次发生,因此尽量提供足够的、清晰的上下文信息,帮助定位问题原因。
所以会有如下的一些建议:
(1) 禁止吞掉异常;
(2) 正确包装异常,有原始异常的话需要包装原始异常;
(3) 写日志、或者向上抛,二选一,避免反复出现重复的日志;
(4) 不能处理的异常不要捕捉后继续往上抛;
(5) 不要使用 printStackTrace() 或类似的方法,其他人看到这些信息完全不知道什么情况,也不知道该如何处理;
(6) 早抛出迟捕捉原则。
2.业务异常 (受检异常 Checked Exceptions) 处理原则
尽可能在编码阶段识别和处理。
受检异常通常都是业务流程上的异常场景,属于正常业务流程的一部分,在方法声明中显式定义具体的可抛出的异常,可以更好地做好这类异常的变更管理。一旦某个方法由于业务场景的完善,补充了新的可抛出的异常,由于编译器的静态分析,可以迅速通知到各个调用方,提醒调用方及时了解变更,对新增的业务异常做出相应的异常处理代码调整。
可能有时会认为方法声明中指定过多的具体异常过于繁琐,可以考虑采用自定义异常、包装异常的方式处理,减少数量的同时,保留原始异常的调用链信息 (stacktrace),另外还可以补充有用的上下文信息。包装的粒度、层次深度,应该按工程的具体情况而定。
同时还可以考虑新旧方法同时提供,通过过期标注 (@Deprecated) 旧方法的方式,逐步消灭旧方法的使用,从而最终消灭旧方法。
因此会有如下的建议:
(1) 方法声明中指定具体的会抛出的受检异常;
(2) 不要笼统地 catch (Exception),应该捕捉能处理的、具体的受检异常,然后进行处理。
3.代码异常 (运行时异常 RuntimeException) 处理原则
应通过良好编码风格,使得调用链信息 (stacktrace) 能够准确指示用户代码技术错误的地方。
例如,使用 Object.requireNotNull() 检查非空,可以有效地在调用链信息中清晰指示出 NullPointerException 的确切发生位置。
4.错误处理原则
错误发生时,不做任何处理,尽快终止程序,然后进入事后检查。
由于此时 JVM 已经崩溃,程序运行逻辑已经不可预知,勉强处理,或者会引入混淆信息。
事后检查需要依赖良好规范的程序日志、标准输出、错误输出,帮助定位问题。
所以会有建议:
(1) 禁止 catch (Throwable)。
5.正确理解和使用 try-catch-finally 程序流
抛出异常的本质是返回值 (异常) 给上层代码,返回值的实质是当前代码栈的某个区域,抛出异常就类似将异常的引用保存到当前代码栈的异常返回值区域。
假如 try catch 块发生异常 A,JVM 将该异常 A 的引用保存到异常返回值区域。然后进入 finally 块又再次发生异常 B 的话,JVM 又会将异常 B 的引用保存到异常返回值区域,覆盖了异常 A,结果上层代码只能接收到假的错误原因 – 异常 B,导致错误定位不可能。
类似的情形:
finally 块的 return 会覆盖 try catch 块的 return;
finally 块的 throw 会覆盖 try catch 块的 return;
finally 块的 return 会覆盖 try catch 块的 throw。这个场景甚至清除了应该抛出的错误,没有任何信息了解到发生了错误。
所以建议:
(1) 只使用 finally 块完成清场 (释放资源等);
(2) 禁止在 finally 代码块抛出异常,应该妥善处理掉不抛出,或者只记录日志;
(3) 禁止在 finally 代码块执行 return 语句。
(完)