Java异常处理的最佳实践

异常处理的原则

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会报告无法访问的代码块。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,874评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,151评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,270评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,137评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,116评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,935评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,261评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,895评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,342评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,854评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,978评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,609评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,181评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,182评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,402评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,376评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,677评论 2 344

推荐阅读更多精彩内容