17、异常(上)
异常是 Java 语言提供的一种出错处理机制
在 Java 编程中,我们时刻都离不开异常,在编写正常逻辑代码的同时,总是需要编写处理各种异常情况的逻辑代码
尽管异常语法比较简单,但用好并不容易,不合理的使用异常会导致诸多问题,比如:程序变慢、异常信息丢失
所以,本节,我们就重点讲下如何合理的使用异常,下一节,我们会深入到异常的内部原理,讲解异常捕获太多导致程序变慢的核心原因
1、异常使用
C 语言中没有异常这样的语法机制,因此,返回错误码便是最常用的出错处理方式,而在 Java、Python 等比较新的编程语言中,都引入了异常语法
大部分情况下,我们都是使用异常来处理函数出错的情况,极少会用到错误码
异常相对于错误码,有诸多方面的优势,比如:可以携带更多的错误信息(exception 中可以有 message、stack trace 等信息)等
除此之外,异常可以将正常业务代码和异常处理代码分离,这样,代码的可读性就会更好
1.1、异常关键词
我们先简单介绍一下如何使用异常,异常关键词有这样几个:throw、throws、try、catch、finally
- throw 用来抛出异常,throws 用来在方法定义中声明方法可能抛出的异常
- try 用来标记需要监控异常的代码,catch 用来捕获代码抛出的异常并进行处理,finally 用来兜底
只要 try 标记的代码块被执行,不管有没有抛出异常,finally 中的代码都会被执行
finally 代码块一般用来做清理工作,比如关闭打开的文件等
1.2、示例
异常使用的示例代码如下所示
public byte[] readData(String filePath) throws DataReadException { InputStream in = null; try { in = new FileInputStream(filePath); byte[] data = new byte[in.available()]; in.read(data); return data; } catch (FileNotFoundException e) { // DataReadException 是自定义异常, 待会会讲解 throw new DataReadException("File not found: " + filePath, e); } catch (IOException e) { throw new DataReadException("Failed to read: " + filePath, e); } finally { if (in != null) { try { in.close(); } catch (IOException e) { // 使用日志框架记录日志 } } } }
如上代码所示,try 代码块之后,可以有多个 catch 代码块,用来捕获 try 代码块在执行时抛出的不同异常
需要注意的是,异常的捕获顺序应该是先捕获子类异常,再捕获父类异常,否则,捕获子类异常的代码块将不会执行
如上代码所示,IOException 是 FileNotFoundException 的父类,如果先捕获 IOException 异常,再捕获 FileNotFoundException 异常
那么代码抛出的 FileNotFoundException 异常会被当做 IOException 异常被捕获
1.3、两个新特性
除了以上基本用法之外,JDK 7 还为异常引入了一些新的特性,如下示例代码所示,catch 可以一次性捕获多个异常
除此之外,对于实现了 java.lang.AutoClosable 接口的资源类,我们可以使用 try-with-resources 语句来创建资源类对象
try 代码块执行完成之后,对应的资源会自动被关闭,不再需要调用 finally 语句来显式地关闭资源,避免了程序员忘记关闭资源而造成的资源泄露
这两个新特性的示例代码如下所示
public void readData(String filePath) throws DataReadException { try (InputStream in = new FileInputStream(filePath)) { Thread.sleep(10); // ... } catch (FileNotFoundException | InterruptedException e) { // ... } catch (IOException e) { throw new DataReadException("Failed to read: " + filePath, e); } }
2、异常体系
Java 中定义了很多现成的异常(叫做内建异常),这些异常又分属不同类别,不同类别的异常具有不同的特点,整个 Java 的异常体系(类图)如下图所示
Throwable 是所有异常的父类,Throwable 包含两个子类:Error 和 Exception,Exception 又派生了一个子类 RuntimeException
所有内建 Java 异常可以分为 3 类:继承自 Error 的异常、继承自 Exception 的异常、继承自 RuntimeException 的异常
- 其中,继承自的 Error 的异常,是一种比较特殊的异常,用来表示程序无法处理的严重错误,这些错误有可能导致线程或 JVM 终止
比如 OutOfMemoryError、StackOverflowError、NoClassDefFoundError 等 - 继承自 Exception 的异常叫做受检异常(Checked Exception)或编译时异常(Compile Exception)
在编写代码的时候,我们需要主动去捕获或者在函数定义中声明此类异常,否则编译就会报错
比如 IOException、FileNotFoundException、InterruptedException、ParseException 等 - 继承自 RuntimeException 的异常叫做非受检异常(Unchecked Exception)或者运行时异常(Runtime Exception)
跟编译时异常相反,在编写代码的时候,我们可以不主动去捕获和在函数定义中声明此类异常,编译器在编译代码时,并不会检查代码是否有对运行时异常做了处理
比如 NullPointerException、ArithmeticException、ArrayIndexOutOfBoundsException 等
String date = "2015-01-12 10:23:21"; SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date d = sdf.parse(date); // 日期解析异常: ParseException System.out.println(d);
3、自定义异常
在平时的开发中,我们尽量使用 Java 已经提供的内建异常,在不满足业务需求的情况下,比如,对于用户不存在这一异常情况,没有对应的内建异常,我们就需要自定义异常
如下代码所示,我们定义了 UserNotExistingException 异常,用来更加准确地描述异常发生的情况
public class UserNotExistingException extends Exception { public UserNotExistingException() { super(); } public UserNotExistingException(String msg, Throwable cause) { super(msg, cause); } public UserNotExistingException(String msg) { super(msg); } public UserNotExistingException(Throwable cause) { super(cause); } }
自定义异常跟大多数内建异常一样,要么作为受检异常继承自 Exception,要么作为非受检异常继承自 RuntimeException
那么,在定义某个异常时,我们应该选择让其继承自 Exception 呢?还是应该选择让其继承自 RuntimeException 呢?
- 对于代码 bug(比如数组越界)以及不可恢复异常(比如数据库连接失败),即便我们捕获了,也做不了太多事情,所以,我们倾向于使用非受检异常
- 对于可恢复异常、业务异常、预期可能发生的异常,比如提现金额大于余额的异常,我们更倾向于使用受检异常,明确告知调用者需要捕获处理
不过,现在我们几乎都是依赖框架来编程,业务逻辑运行在框架中
对于程序员自定义的异常,不管是受检异常还是非受检异常,大部分情况下,都会被框架兜底捕获并处理,并不会直接导致程序的终止
从这个角度上来讲,在编写业务代码时自定义的异常,定义成受检异常和非受检异常均可
实际上,Java 支持的受检异常一直被人诟病,有些人主张所有的异常情况都应该使用非受检异常,支持这种观点的理由主要有以下三个
3.1、理由一
受检异常需要显式地在函数定义中声明,如果函数的代码逻辑有可能抛出很多受检异常,那么函数定义会非常冗长,这就会影响代码的可读性,而且函数使用起来也不方便
示例函数如下所示
public long buy() throws UserNotFoundException, UserHasNoEnoughMoneyException, DuplicatedBuyException, NoEnoughNotesException, UserHasNoPermissionToBuyException;
3.2、理由二
编译器强制程序必须显示地捕获所有的受检异常,代码实现会比较繁琐
当使用如上示例函数时,我们需要显示的捕获各个异常或者在函数定义中重复声明
而非受检异常正好相反,我们不需要在定义中显示声明,并且是否需要捕获处理,也可以自由决定
3.3、理由三
受检异常的使用违反开闭原则
- 如果我们给某个函数新增一个受检异常,这个函数所在的函数调用链上的所有位于其之上的函数,都需要做相应的代码修改
直到调用链中的某个函数,将这个新增的异常,捕获处理不再抛出为止 - 相反,新增非受检异常可以不改动调用链上的代码,我们可以灵活地选择在某个函数中集中处理
比如在 Spring 中的 AOP 切面中集中处理异常
不过,非受检异常也有弊端,它的优点其实也正是它的缺点,从刚刚的表述中,我们可以看出
- 非受检异常使用起来更加灵活,怎么处理异常的主动权交给了程序员
- 过于灵活会带来不可控,非受检异常不需要显式地在函数定义中声明,那么在使用函数时,我们就需要查看函数的实现逻辑,才能知道函数具体会抛出哪些异常
因为非受检异常不需要强制捕获处理,那么一些本应该捕获处理的异常就有可能被程序员遗漏
对于应该用受检异常还是非受检异常,争论有很多,但并没有一个非常强有力的理由能够说明一个就一定比另一个更好
所以,我们只需要根据团队的开发习惯,在同一个项目中,制定统一的异常处理规范即可
4、异常处理
当某段程序抛出异常时,我们应该如何处理抛出的异常呢?一般来讲,我们有 3 种处理方法
4.1、捕获后记录日志
public void f() throws LowLevelException { // ... } public void g() { try { f(); } catch (LowLevelException e) { log.warn("...", e); // 使用日志框架记录日志 } }
4.2、原封不动再抛出
public void f() throws LowLevelException { // ... } // 如果 LowLevelException 是非受检异常, 则不需要在函数 g() 定义中声明 public void g() throws LowLevelException { f(); }
4.3、包装成新异常抛出
public void f() throws LowLevelException { // ... } public void g() { try { f(); } catch (LowLevelException e) { throw new HighLevelException("...", e); } }
4.4、总结
以上我们介绍了 3 种处理异常的方法,那么,当代码抛出异常时,我们应该选择哪一种来处理方法呢?很多程序员对处理方式的选择比较随意,也没有一个原则
实际上,选择哪种处理方法,其实有一个简单的原则可以参考,那就是:函数只抛出跟函数所涉及业务相关的异常
- 在函数内部,如果某块代码的异常行为,并不会导致调用此函数的上层代码出现异常行为
也就是说,上层代码并不关心被调用函数内部的这个异常,我们就可以在函数内部将这个异常 "消化掉":将其捕获并打印日志记录 - 相反,如果函数内部的异常行为会导致调用此函数的上层代码出现异常行为,那么,我们就必须让上层代码感知到此异常的存在
如果此异常跟函数的业务相关:上层代码在调用此函数时,知道如何处理此异常,那么直接将其抛出就可
如果此异常跟函数的业务无关:上层代码无法理解这个异常的含义,不知道如何处理,那么需要将其包裹成新的跟函数业务相关的异常重新抛出
4.5、示例
我们再来看本节开头的示例代码,对此我稍微做了修改,如下所示
// 文件打开失败 public byte[] readData(String filePath) throws DataReadException, FileNotFoundException { InputStream in = null; try { Thread.sleep(10); in = new FileInputStream(filePath); byte[] data = new byte[in.available()]; in.read(data); return data; } catch (InterruptedException e) { throw new DataReadException("Interrupted when reading: " + filePath, e); // sleep() 函数被中断 } catch (IOException e) { throw new DataReadException("Failed to read: " + filePath, e); // 文件读取失败 } finally { if (in != null) { try { in.close(); // 文件关闭失败 } catch (IOException e) { // 使用日志框架记录日志 } } } }
参照刚刚给出的 3 种异常处理方式,以及选择的原则,我们来分析一下上面的代码
- 调用 readData() 函数的上层代码并不关心文件关闭失败(对应 in.close() 语句)导致的 IOException 异常,因此,我们直接将其捕获并打印日志
- 对于文件读取失败而抛出的 IOException 异常,因为 IOException 异常比较底层,如果原封不动抛出,那么上层代码可能并不知道如何处理
所以,我们将其重新包裹成自定义的 DataReadException 异常再抛出 - 同理,对于因 sleep() 函数被中断而抛出的 InterruptedException 异常,上层代码也无法理解,因此,我们同样将其包裹为 DataReadException 异常再抛出
- 对于文件打开失败而抛出的 FileNotFoundException 异常,因为跟 readData() 函数业务相关,毕竟 readData() 函数中的参数就是文件的路径,所以,我们可以直接将其抛出
当然,如果我们想要减少 readData() 函数受检异常的个数,那么也可以将 FileNotFoundException 异常统一包裹为 DataReadException 异常再抛出
5、异常调用链
异常最终的宿命终究是被捕获并打印异常信息,以便程序员 debug 问题
比如将其打印到日志或命令行中,为了给程序员展示充足的异常信息,我们一般需要将异常调用链完整打印出来
异常调用链记录了异常引起的整个过程,当前被捕获的异常是由哪个异常引起的,跟函数调用一样,一直追溯到引起整个异常调用链的最原始的异常为止
除此之外,异常调用链还会记录每个异常的生命周期内所经历的所有函数,异常的生命周期指的是,异常从创建到被捕获并不再继续抛出的这一过程
5.1、示例
我们举个例子来看下,如下代码所示
public class Demo { // 低等级异常:编译时异常 public static class LowLevelException extends Exception { public LowLevelException() { super(); } public LowLevelException(String msg, Throwable cause) { super(msg, cause); } public LowLevelException(String msg) { super(msg); } public LowLevelException(Throwable cause) { super(cause); } } // 中等级异常:编译时异常 public static class MidLevelException extends Exception { // ... 与 LowLevelException 实现类似, 省略代码实现 ... } // 高等级异常:运行时异常 public static class HighLevelException extends RuntimeException { // ... 与 LowLevelException 实现类似, 省略代码实现 ... } public static void fa() throws LowLevelException { throw new LowLevelException("LowLevelException-msg"); // 低等级异常:编译时异常 } public static void fb() throws LowLevelException { fa(); // 低等级异常:编译时异常 } public static void fc() throws MidLevelException { try { fb(); } catch (LowLevelException e) { throw new MidLevelException("MidLevelException-msg", e); // 中等级异常:编译时异常 } } public static void fd() { try { fc(); } catch (MidLevelException e) { throw new HighLevelException("HighLevelException-msg", e); // 高等级异常:运行时异常 } } public static void fe() { fd(); } // main -> fe -> fd(高) -> fc(中) -> fb -> fa(低) public static void main(String[] args) { try { fe(); } catch (HighLevelException e) { e.printStackTrace(); } } }
5.2、解释
我们分析一下上述代码
- LowLevelException 异常为调用链中的第一个异常,在 fa() 函数中抛出,fb() 未捕获直接将其抛出,fc() 将其捕获并且重新包装成 MidLevelException 异常抛出
所以:LowLevelException 异常的生命周期经历了 3 个函数:fa()、fb()、fc() - MidLevelException 异常在 fc() 函数中创建,在 fd() 函数中被捕获,然后重新包装成 HighLevelException 异常抛出
所以:MidLevelException() 异常的生命周期经历了 2 个函数:fc() 和 fd() - HighLevelException 异常为运行时异常,在 fd() 函数中创建,fe() 函数没有将其捕获,默认原样抛出,最终被 main() 函数捕获并输出异常调用链信息,至此异常调用链结束
所以:HighLevelException 异常的生命周期经历了3个函数:fd()、fe()、main()
HighLevelException 异常由 MidLevelException 异常引起,MidLevelException 异常又由 LowLevelException 异常引起,因此,上述代码打印出来的异常调用链,如下所示
Demo$HighLevelException: HighLevelException-msg at Demo.fd(Demo.java:80) at Demo.fe(Demo.java:85) at Demo.main(Demo.java:91) Caused by: Demo$MidLevelException: MidLevelException-msg at Demo.fc(Demo.java:72) at Demo.fd(Demo.java:78) ... 2 more Caused by: Demo$LowLevelException: LowLevelException-msg at Demo.fa(Demo.java:61) at Demo.fb(Demo.java:65) at Demo.fc(Demo.java:70) ... 3 more
5.3、注意一
异常调用链可以完整的描述异常发生的整个过程
但需要特别注意的是:捕获异常并包裹成新的异常抛出时,我们一定要将先前的异常通过 cause 参数传递进新的异常,否则,异常调用链将会断开
比如,对于上述示例代码,在创建 MidLevelException 异常时,如果我们没有将 LowLevelException 异常通过 cause 参数传递给 MidLevelException
那么,通过MidLevelException 异常将无法再追踪到 LowLevelException 异常
最终打印出来的异常调用链将只包含 HighLevelException 异常信息和 MidLevelException 异常信息
// 错误做法 try { // ... } catch (CausedByException e) { throw new NewException("msg..."); // e 丢失 } // 正确的做法 try { // ... } catch (CausedByException e) { throw new NewException("msg...", e); }
5.4、注意二
在平时的开发中,我们还需要特别注意,对于异常的处理:要么记录、要么抛出,但两者不能同时执行,错误的做法如下所示
在异常调用链中,我们只需要在最后一个异常生命周期结束时,打印异常调用链即可,没必要像如下所示,重复打印部分异常调用链
既然我们已经抛出了异常,异常就理应由上层函数来负责处理(比如打印)
// 错误的做法 try { // ... } catch (CausedByException e) { logger.error("...", e); throw new NewException("msg...", e); }
6、课后思考题
在本节打印的异常调用栈信息中,"...2 more" 和 "...3 more" 具体指的是什么内容?
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17465540.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步