异常处理的原则
1.
抛出异常,要针对具体问题来抛出异常,抛出的异常要足够具体详细;
- 抛出的异常,应能通过异常类名和message准确说明异常的类型和产生的原因。
2.
捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之;
- 如果不想处理它,应将该异常抛给它的调用者;永远不要在没有充分理由的情况下吞掉异常。即要么处理,要么向上抛,决不能吃掉:You either handle it, or throw it. You don’t eat it.
- 最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。
3.
尽早抛出,延迟捕获
- 尽早抛出的基本目的是为了防止问题扩散,这样就给排查问题增加了难度。
- 延迟捕获说的是对异常的捕获和处理需要根据当前代码的能力来做,如果当前方法内无法对异常做处理就抛给调用者;如果调用者也无法处理理论上它也应该继续上抛,这样异常最终会在一个适当的位置被catch下来,而比起异常出现的位置,异常的捕获和处理是延迟了很多。但是也避免了不恰当的处理。
最佳实践
1.
Don’t log and rethrow Java exceptions
日志记录和异常处理就像一个豆荚里的两颗豌豆。当你的 Java 代码中出现问题时,这通常意味着你有一个需要处理的异常,当然,任何时候发生错误或意外事件时,都应该适当地记录该事件。
在Java 中执行异常处理时,开发人员实际上有两种选择:
- 在抛出异常时处理异常,并在错误发生时从错误中恢复。
- 重新抛出异常,以便应用程序的另一部分可以处理该问题。
在分层架构的应用中,第二个选项特别常见,因为执行堆栈的顶部通常只有一个层,专门用于处理异常和从异常中恢复的任务。
但是,开发者最常犯的错误之一是在重新抛出异常之前记录异常。这种做法必须被视为最高级别的bad case。
比如下面的代码:
/* log and rethrow exception example */
try {
Class.forName("com.mcnz.Example");
} catch (ClassNotFoundException ex) {
log.warning("Class was not found.");
throw ex;
}
异常最终会在多层中多次记录。当通过日志文件进行跟踪故障时,排查过程令人窒息,排除人员不知道从哪里开始以及何时结束。
这样做会导致代码重复,并在日志文件中散布重复的记录,这使得对代码进行故障排除变得更加困难。
正确的做法是:仅在真正处理异常时记录异常log;真正处理异常意味着不再将异常上抛,或再往上已没有调用者。
2.
在流量出口处设置全局异常处理器
上一条提到:仅在真正处理异常时记录异常log;在业务系统中,通常能真正处理异常的地方,就是在流量出口,即最外层的使用层。
因此,捕获异常的处理逻辑,应该尽量放在流量出口的末尾。 这会在业务工程中放置更少的 catch 块,并使工程代码更易于阅读和维护。
使用全局异常处理器,因为总会有未捕获的异常潜入到代码中。始终包含一个全局异常处理程序来处理任何未捕获的异常,这不仅可以让你记录并处理可能发生的异常,还可以防止你的应用程序在运行时崩溃。
最外层的业务使用者,必须处理异常,并将其转化为用户可以理解的内容。
3.
检查suppressed exception,防止异常被覆盖
Suppressed Exception是一种相对较新的语言特性,并非所有开发人员都知道。
上篇 别被坑在finally代码块上 提到:如果程序执行try块出现异常,且进入执行finally 块也抛出异常,则最后抛出给调用方的异常是finally中的,try/catch中的不会再抛出,因为被覆盖掉了,在Java中,对上层调用方只能抛出一个异常。没有抛出的异常被称为“被屏蔽”的异常(suppressed exception)。
suppressed exception其实并不好翻译。有些平台译为“被压制的异常”。“压制”这个词的含义是“使某物变小”,延伸到在 Java 中,被压制的异常指的是在 try-with-resources 语句块中,因为 try 块和 finally 块都抛出了异常,导致 finally 块中的异常被“压制”了,没有被正确捕获和处理。因此,我们可以称这些未被正确处理的异常为被压制的异常。
Throwable#suppressedExceptions
- 被屏蔽的异常,可通过Throwable.getSuppressed()获取;
- 可以通过addSuppressed(Throwable exception)添加,这个函数一般是在try-with-resources语句中由自动调用的;因为try-with-resources结构会自动回收资源,通常不需要显示添加finally块;因此try-with-resources自动回收资源时出现异常,会自动调用addSuppressed。
想要同时保留 finally 块和 try 块中的异常信息,上篇给出了一种方法:用变量保存try块的原始异常,在finally也出现异常时进行取舍。
下面给出采用suppressed exception的一种新方式。
try {
//可能抛出 IOException 异常
BufferedReader br = new BufferedReader(new FileReader(path));
String line = br.readLine();
throw new IOException("mock IOException");
} catch (IOException e) {
//try块里抛出的异常是e
System.out.println("try块里抛出的异常是: " + e.getMessage());
//finally块里抛出的异常是e.getSuppressed()
Throwable[] suppressed = e.getSuppressed();
String suppressedException = Arrays.stream(suppressed).map(Throwable::getMessage).collect(Collectors.joining(","));
System.out.println("finally块里抛出的异常是: " + suppressedException);
} finally {
//此示例是为了让finally块产生异常,从而出现“被屏蔽”的异常
throw new RuntimeException("mock throwExceptionInFinally");
}
//执行结果
//try块里抛出的异常是: mock IOException
//finally块里抛出的异常是:
//Exception in thread "main" java.lang.RuntimeException: mock throwExceptionInFinally
// at FinallyTest.main(……
需要注意的是,由于在Java中对上层调用方只能抛出一个异常;因此两种方式,都是只能同时保留 finally 块和 try 块中的异常信息,最终还需取舍,一个记录到日志文件,一个向上抛出。
4.
通过前置防御规避非受检异常,而非通过catch去捕获处理
重学Java异常体系提到受检异常 vs 非受检异常的差异;
- 非受检异常的发生通常是由于程序 bug 所致,应该尽量通过预先检查进行规避;比如在面对可能抛NullPointerException的地方时主动判断是不是null 并处理、循环处理时要检查下标边界防止出现IndexOutOfBounds。
- 这种异常源于开发者的疏忽,很多开发人员将其跟真正需要处理的异常混为一谈,然后统一的在catch里忽略掉这种异常,是对自己的“放纵”。
对于非受检异常,我们应该修正代码,而不是去通过异常处理器去处理。
具体来说,就是RuntimeException及其子类(JDK内置的大多数RuntimeException子类), 尽量通过预先检查进行规避,而不应该通过 catch 来处理。
5.
保留异常链
- 丢失异常的另一种场景是丢失异常链
- 永远要记得:包装异常但不要丢弃原始异常
异常链是一种面向对象编程技术,指将捕获的异常包装进一个新的异常中并重新抛出的异常处理方式。原异常被保存为新异常的一个属性(比如cause)。这个想法是指一个方法应该抛出定义在相同的抽象层次上的异常,但不会丢弃更低层次的信息。
通过把原始异常传递给新的异常,使得即使在当前位置创建并抛出了新的异常,也能通过这个异常链追踪到异常最初发生的位置。
6.
对异常进行文档说明
- 为你的异常生成足够的文档说明,至少要有 Javadoc
当在方法上声明抛出异常时,也需要进行文档说明。目的是为了给调用者提供尽可能多的信息,从而可以更好地避免或处理异常。
在 Javadoc 添加 @throws 声明,并且描述抛出异常的场景(when and why抛出异常)。
/**
* 方法描述
*
* @throws MyBusinessException - when and why抛出异常
*/
public void doSomething(String input) throws MyBusinessException {
// ...
}
7.
如无必要,勿增自定义异常
- 优先使用Java内置异常
- 自定义的异常数量需要控制
在Java中,我们可以使用内置的异常类,也可以自定义异常类。
- 对于重要的通用异常类型——例如空指针异常、数组下标越界异常、类型转换异常等等,Java语言内置了相应的异常类型。当我们需要捕捉这些异常时,可以直接使用内置的异常类型。这样可以简化代码,提高可读性。此外,内置异常类还具有特定的语义,可以帮助开发者更加准确地进行异常处理。
- 当我们需要处理某些特定的异常类型时,为了便于开发者的理解和使用,我们可能需要创建自定义异常类。自定义异常类的命名应具有一定的描述性,可以在异常抛出时帮助开发者快速理解异常的含义和产生原因。
对于业务系统而言,由于自定义异常通常设计成非受检异常(Unchecked Exception,即RuntimeException及其子类)以免强制捕获处理异常,当系统抛出太多RuntimeException子类,到了流量出口的全局异常处理器时,可能根本不知道应该捕获什么!
- 如果只捕获你知道抛出的异常,那么你怎么知道抛出了哪些异常呢?当其他开发者抛出一个新的RuntimeException子类型并忘记在全局异常处理器捕获它时,可能会发生危险情况。
- 如果直接捕获RuntimeException类型,那定义不同的异常类型还有什么意义,因为异常处理器对它们一视同仁。因此,自定义的异常数量不宜过度、需要控制。
对于非业务系统,即Java中间件、组件、库等,需要单独考虑。
8.
不要在finally块抛出异常
在 finally 中抛出异常同样会导致程序出现预期之外的行为。如果 finally 块中的代码抛出了异常,而且未在finally块捕捉处理,那么该异常将被抛出到上一级调用者,并且 try 或 catch 块中抛出的异常将会被覆盖(丢失)。因此,在 finally 代码块中抛出异常可能会对程序的逻辑造成混乱,不利于代码的维护和调试。
详见 finally最佳实践
9.
不要使用异常控制程序的流程
在程序设计中,异常机制的作用是用来处理错误或者异常情况,而不是用来控制程序的流程。在程序的正常执行流程中,异常应该是意外情况才会出现的,而不是作为正常流程中可预知的分支。
使用异常控制程序的流程,可能会导致以下问题:
- 代码可读性差:使用异常来控制程序流程,可能会使代码结构和逻辑变得复杂,难以阅读和理解。
- 难以调试:异常的跳转会扰乱程序的执行流程并难以判断,给代码调试和维护增加难度。
- 性能问题:抛出和捕获异常会消耗比较大的时间和资源。如果大量使用异常来控制程序的流程,可能会导致性能问题,降低程序的运行效率。
因此,不用使用异常来管理业务逻辑,应该使用条件语句。如果一个控制逻辑可通过 if-else 语句来简单完成的,那就不用使用异常。
主要影响性能的地方是往异常填充堆栈信息,在确定不需要堆栈信息的异常,可以重写fillInStackTrace方法,重写该方法不填充堆栈可以提升性能。
之所以将这条最佳实践放在最后,是因为要完美实施它其实很难。一是正常的业务主流程(normal),跟“异常情况”(abnormal)有时候很难界定。比如用户注册,用户想注册的id一般来说都是被别人先注册了的。那么catch UserexistException的机会将会多于try中的主流程。二是在try-catch结构中,catch块对异常的处理,很容易包含特定异常情况下的处理逻辑。
10.
优先捕获最具体的异常Catch the most specific exception first
在Java中,出现异常从异常表查找异常处理程序时,会根据catch声明的顺序来依次匹配,只有匹配异常的第一个 catch 块会被执行。因此,如果首先捕获 IllegalArgumentException ,则永远不会到达应该处理更具体的 NumberFormatException 的 catch 块,因为它是 IllegalArgumentException 的子类。
总是优先捕获最具体的异常类,子类必须放在父类的前面,并将不太具体的 catch 块添加到列表的末尾。
大多数 IDE 都可以辅助实现这个最佳实践。当你尝试首先捕获较不具体的异常时,IDE会报告无法访问的代码块。