2024异常详解

在 Java 中,异常(Exception)是一种处理错误和异常情况的机制。异常允许程序在运行时处理错误情况而不中断程序的正常执行。Java 的异常处理机制包括两个主要概念:异常类和异常处理语句。

异常处理机制自 1.0 开始就已经存在,可以说是Java元老中的元老。

⨳ JDK 1.0 版本包含了 Java 异常处理的基础设施,包括 trycatchfinallythrow 和 throws

⨳ JDK 1.4 引入了链式异常 (chained exceptions),允许一个异常将另一个异常作为其原因。

⨳ JDK 7 引入了多重捕获异常(multi-catch)功能,允许在一个 catch 块中捕获多个异常。并且引入了 try-with-resources 语法,简化了资源的关闭操作。

⨳ JDK 9 改进了 try-with-resources 语句,使其支持在已有资源的基础上重新定义新的资源。

⨳ JDK 14 引入了 Helpful NullPointerExceptions,在空指针异常(NullPointerException)中提供更详细的信息

⨳ ...

异常继承体系

一切都是对象,异常也是对象,JDK的异常是以 Throwable 为父类派生出的多个通用的异常类。

Serializable 序列化接口

Serializable 接口用于指示一个类的对象可以被序列化。这意味着对象的状态可以被转换为字节流,以便保存到文件、数据库,或通过网络传输。

对于需要序列化的对象需要实现 Serializable 接口,这个接口只是⼀个标记,没有具体的作⽤,但是如果不实现这个接口,在有些序列化场景会报错。

序列化还涉及一个 serialVersionUID 的东西:

 

java

代码解读

复制代码

private static final long serialVersionUID = 1L ;

ID 的数字其实不重要,只要序列化时候对象的 serialVersionUID 和反序列化时候对象的 serialVersionUID ⼀致的话就⾏,如果没有显⽰指定 serialVersionUID ,则编译器会根据类的相关类信息⾃动⽣成⼀个。

Java 中的异常类(包括 Error 和 Exception)都实现了 Serializable 接口,这样它们的实例可以被序列化和反序列化。

比如在分布式系统中,Java 的远程方法调用 (RMI) 允许在不同 JVM 之间调用方法。如果一个远程方法抛出异常,该异常对象需要通过网络传输到调用者的 JVM。为了实现这一点,异常对象必须是可序列化的。

Throwable 可抛出的

Throwable 类是所有错误和异常的超类。它有两个直接子类:Error 和 Exception。所有的异常和错误对象都是 Throwable 类的实例。Throwable 类提供了很多有用的方法,用于捕获和处理异常及错误。

⨳ String getMessage():返回异常的详细消息;

⨳ void printStackTrace():打印异常和它的堆栈跟踪到标准错误流;

⨳ Throwable getCause():返回导致此 Throwable 被抛出的原因;

Throwable 的有个成员属性 cause 也是 Throwable 类型的,这表示异常可以嵌套,也就是 1.4 版本引入的链式异常

Error 错误

Error 是 Java 中表示严重错误的类,它们通常是系统级错误或者虚拟机错误,这些错误通常是程序无法控制或恢复的。

⨳ 系统级错误:通常由 JVM 引发,表示严重的问题,如内存不足(OutOfMemoryError)、栈溢出(StackOverflowError)、虚拟机错误(VirtualMachineError)等。

⨳ 不可恢复:一般来说,程序无法从 Error 中恢复,虽然可以使用 try-catch 捕获,但不建议捕获和处理它们。

⨳ 未受检查的异常:与 RuntimeException 一样,Error 也是未受检查的异常,不需要显式处理或声明。

 

java

代码解读

复制代码

public class ErrorExample { public static void main(String[] args) { try { // 触发 StackOverflowError recursiveMethod(); } catch (StackOverflowError e) { System.err.println("Caught StackOverflowError: " + e); } } public static void recursiveMethod() { recursiveMethod(); // 无限递归,导致 StackOverflowError } }

Exception 异常

Exception 类表示应用程序级的异常情况。这些异常通常由程序错误或外部条件引起,通常是可以被程序捕获和处理的。Exception 类分为受检查的异常和未受检查的异常。

⨳ 受检查的异常 (Checked Exception) :也称编译期异常,必须在代码中显式处理或声明,不处理的话代码编译都通不过,例如 IOException 和 SQLException...。

⨳ 未受检查的异常 (Unchecked Exception) :也称运行时异常,异常只有在程序运行时才会出现,不必显示捕获,包括 RuntimeException 及其子类都是运行时异常,如 NullPointerException 和 ArrayIndexOutOfBoundsException...。

像类型强转异常(ClassCastException) 、空指针异常(NullPointerException)、数组越界异常(ArrayIndexOutOfBoundsException)都是运行时异常,这都是在编码中可以避免的异常,毕竟大部分正常人不会操作空对象,不会操作超过数组长度的元素,更不会写个除以0的表达式。

编译期异常,都是与外部环境有关的异常,比如 IO 异常与文件有关,SQL异常与数据库有关,网络异常是与网络有关,这种异常很难避免掉,于是编译期进行检测。

事实上,编译期异常设计的初衷更是为了通过强制的 try-catch,让程序能从异常情况中恢复,但是,其实我们大多数情况下,根本就不可能恢复,如 IO 异常 找不到外部文件,找不到就是找不到,就算 catch 了也找不到,所以必检异常能起到的作用就是提示程序员,该操作可能发生的风险,请注意规避。

try-catch-finally

try-catch-finally 是 Java 中处理异常的一种机制。try 块包含可能抛出异常的代码,catch 块处理这些异常,finally 块包含无论是否发生异常都要执行的代码,通常用于清理资源。

try-catch-finally 的结构

 

java

代码解读

复制代码

try { // 可能抛出异常的代码 } catch (ExceptionType1 e1) { // 处理 ExceptionType1 类型的异常 } catch (ExceptionType2 e2) { // 处理 ExceptionType2 类型的异常 } finally { // 无论是否发生异常,都会执行的代码 }

catch 的目的是捕获异常,捕获异常的目的是为了恢复程序运行,这一点已经讲过了。比如在处理数据库操作时,如果某些数据插入失败,可以记录错误并继续处理其他数据,而不是中止整个批处理操作。

在这里我想明确一点,catch 的目的是为了恢复程序运行,而不仅仅是打印异常日志,异常日志就算不捕获也会输出到异常文件,只要你日志框架配置得当。

finally 是无论如何都会执行的代码,无论是代码异常中断向下执行,还是在 catch 中 return,都会执行的,使用 javap 反编译字节码就可以看出端倪。

捕获异常还有一点需要注意,如果 catch 中的异常类是父子继承关系,先 catch 谁,后 catch 谁都没关系,如果 catch 中的异常类有父子关系,父类要写在最下面,也就是子类异常 应该在 父类异常 之前捕获,这一点编译器会自动校验,了解一下就行。

链式异常 chained exceptions

前文讲了, Throwable 的成员属性 cause 也是 Throwable 类型的,这表示异常可以嵌套,也就是 1.4 版本引入的链式异常

链式异常的存在,可以让一个异常包含另一个异常作为其原因(cause)。这样,异常的传递链条能够反映出问题的根本原因。链式异常通常用于捕获和重新抛出异常时,将原始异常作为新的异常的原因,提供更详细的错误上下文。

比如自定义一个异常:

 

java

代码解读

复制代码

package com.cango.exception; class CustomException extends Exception { public CustomException(String message, Throwable cause) { super(message, cause); } }

注意,因为这里自定义的异常是直接继承 Exception 而不是 RuntimeException ,所以它是一个必检异常,必须显示捕获。

再写一下方法链式调用,method1 调用 method2method2 抛出 Exception,而 method1 会抛出 CustomException[cause =Exception,...]:

 

java

代码解读

复制代码

package com.cango.exception; public class ChainedExceptionExample { public static void method1() throws CustomException { try { method2(); } catch (Exception e) { throw new CustomException("Exception in method1", e); } } public static void method2() throws Exception { throw new Exception("Exception in method2"); } }

写个 Test 类验证一下:

 

java

代码解读

复制代码

public static void main(String[] args) { ChainedExceptionExample chainedExceptionExample = new ChainedExceptionExample(); try { chainedExceptionExample.method1(); } catch (CustomException e) { e.printStackTrace(); } }

输出结果如下:

 

js

代码解读

复制代码

com.cango.exception.CustomException: Exception in method1 at com.cango.exception.ChainedExceptionExample.method1(ChainedExceptionExample.java:17) at com.cango.exception.ChainedExceptionExample.main(ChainedExceptionExample.java:7) Caused by: java.lang.Exception: Exception in method2 at com.cango.exception.ChainedExceptionExample.method2(ChainedExceptionExample.java:22) at com.cango.exception.ChainedExceptionExample.method1(ChainedExceptionExample.java:15) ... 1 more

可以发现,日志中共输出了两个异常, Exception in method1 Caused by Exception in method2。

使用链式异常可以在保留原始异常的信息的情况下,重新抛出异常时提供额外的上下文。

多重捕获异常 multi-catch

多重捕获异常是JDK 7 引入的,可以在一个 catch 块中捕获多个异常类型。当多个异常类型具有相同的处理逻辑时,可以使用多重捕获来将这些异常集中在一个 catch 块中。

 

java

代码解读

复制代码

try { // 可能抛出 IOException 或 SQLException 的代码 } catch (IOException | SQLException e) { // 处理 IOException 和 SQLException 的公共逻辑 }

很好理解吧。

try-with-resources

try-with-resources 也是 Java 7 引入的一种语法,用于简化资源的自动关闭。它通过自动管理资源(如文件、流、数据库连接等),确保在 try 块结束后这些资源能被自动关闭,从而避免了资源泄漏的问题。

可以在 try-with-resources 语句中同时声明多个资源,它们会按声明的顺序被关闭:

 

java

代码解读

复制代码

try (BufferedReader reader = new BufferedReader(new FileReader("file.txt")); PrintWriter writer = new PrintWriter(new FileWriter("output.txt"))) { String line; while ((line = reader.readLine()) != null) { writer.println(line); } } catch (IOException e) { e.printStackTrace(); }

try-with-resources 可以代替在 finally 代码块中执行回收资源的操作。

要想使用try-with-resources 有个前提就是,在 try(...) 中初始化的资源必须实现 java.lang.AutoCloseable 接口,这样 Java 才能帮你自动调用 close 方法呀。

那如果try(...) 中初始化的资源时报错了,会怎么样呢?Java 自动调用 close 方法报错了,会怎么样呢?

⨳ 如果try(...) 中初始化的资源时报错,例如因为文件不存在或数据库连接失败,try 块内的代码不会被执行,且 catch 块也不会捕获这种初始化异常,这个异常会被直接抛出,这需要注意。

⨳ 如果 close 方法抛出异常,这个异常会与 try 块中的原始异常一起被捕获和处理。

在 JDK 7 和 8 中,try-with-resources 语句要求所有资源在 try 声明中显式初始化。即使你已经有了一个现成的资源,也需要在 try 声明中重新定义它。

 

java

代码解读

复制代码

BufferedReader reader = null; try { reader = new BufferedReader(new FileReader("file.txt")); try (BufferedReader br = reader) { // 再次定义资源 System.out.println(br.readLine()); } } catch (IOException e) { e.printStackTrace(); }

在 JDK 9 中,你可以直接在 try-with-resources 语句中使用已经定义的资源,而不必再次显式地声明和初始化它们。这使得代码更简洁和易读。

 

js

代码解读

复制代码

BufferedReader reader = null; try { reader = new BufferedReader(new FileReader("file.txt")); } catch (IOException e) { e.printStackTrace(); } try (reader) { // 使用已声明的资源 System.out.println(reader.readLine()); }

Helpful NullPointerExceptions

Java 14 引入了 Helpful NullPointerExceptions(有帮助的 NullPointerExceptions)功能,这项功能旨在提供更多关于引发 NullPointerException 异常的信息,帮助开发人员更快速地定位和修复代码中的空指针问题。

先看一个没有启用 Helpful NullPointerExceptions 的例子:

 

java

代码解读

复制代码

public class NPEExample{ public static void main(String[] args) { String str = null; System.out.println(str.length()); } }

如果上面的代码中存在 NullPointerException,它仅显示哪个类哪一行有空指针:

 

js

代码解读

复制代码

Exception in thread "main" java.lang.NullPointerException at NPEExampleWithoutHelpfulNPE.main(NPEExample.java:4)

而启用了 Helpful NullPointerExceptions 后,相同的代码会提供更详细的异常信息。

 

js

代码解读

复制代码

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null at NPEExample.main(NPEExample.java:4)

在 Java 14 及更高版本中,Helpful NullPointerExceptions 是默认启用的,用 JDK8 的同学就不要羡慕了。

throw 和 throws

throw 关键字

throw 关键字用于显式地抛出一个异常。它可以在方法体内的任何位置使用,通常在遇到特定错误条件时使用。

 

java

代码解读

复制代码

void myMethod() { if (someCondition) { throw new MyException("An error occurred"); } }

当 throw 语句执行时,它会立即中断当前代码的执行流程。

throw 关键字经常应用在异常链(Chained Exceptions)中。先捕获一个异常,使用 throws 关键字重新抛出新的异常,这就可以保留原始异常的信息,有助于调试和诊断问题。

throws 关键字

throws 关键字用于声明一个方法可能抛出的异常。它出现在方法签名中,并列出该方法可能抛出的所有编译期异常(checked exceptions)。

 

java

代码解读

复制代码

void myMethod() throws IOException, SQLException { // method body }

虽然throws 也可以声明运行时异常,但没必要,声明编译期异常的目的就是提醒使用该方法可能出现的问题。

反射异常与代理异常

反射异常 InvocationTargetException

反射涉及的异常有很多,如:

⨳ ClassNotFoundException:试图通过类的完全限定名加载类时,如果类不存在或类加载器无法找到类时抛出

⨳ NoSuchMethodException:试图获取一个类的方法但该方法不存在时抛出。

⨳ NoSuchFieldException:试图获取一个类的字段但该字段不存在时抛出。

⨳ IllegalAccessException:试图访问一个无法访问的字段、方法或构造函数时(如试图访问一个私有方法)时抛出。

⨳ InvocationTargetException:当通过反射调用方法时,如果该方法抛出异常。

⨳ InstantiationException:当试图通过反射创建一个抽象类或接口的实例,或尝试实例化一个没有默认构造函数的类时抛出。

这些异常类似于我们自定义的异常,看见异常名就知道程序到底出了什么问题,这很好理解。

这其中特殊的异常是 InvocationTargetException,直意是“调用目标异常”,它是对调用的方法内部抛出的异常的封装。

怎么理解这句话呢?

我们知道所有 Exception 都从父类 Throwable 继承了 cause 属性,这个属性也是异常链的基础,而 InvocationTargetException 有个它特有的属性:

 

java

代码解读

复制代码

private final Throwable target;

这是属性保存的是反射调用方法内部抛出的异常。

所以当你捕获的异常是 InvocationTargetException,要进一步调用其的 getTargetException方法才行:

为什么需要 InvocationTargetException 呢?

反射调用方法可能抛出的异常多种多样,需要有一个统一的异常明确的表示出来,于是JDK就使用 InvocationTargetException 将异常统一包装起来。

那为什么不声明抛出 Throwable 或 Exception 呢?

Throwable 或 Excepton 虽然可以承接被调用方法抛出的方法,但是太宽泛了,难以准确反映异常的原因和意图。

代理异常 UndeclaredThrowableException

UndeclaredThrowableException 也是一个包装异常,它是在动态代理中,如果被代理的方法抛出一个没有在接口中声明的受检异常,代理会捕获该异常并将其封装在 UndeclaredThrowableException 中抛出。

所以为了获取真正方法调用产生的异常,需要调用其的 getUndeclaredThrowable 方法。

如在 Mybatis 框架中,就有一个异常解析类,会将这两种包装异常进行拆包:

 

java

代码解读

复制代码

public class ExceptionUtil { public static Throwable unwrapThrowable(Throwable wrapped) { Throwable unwrapped = wrapped; while (true) { if (unwrapped instanceof InvocationTargetException) { unwrapped = ((InvocationTargetException) unwrapped).getTargetException(); } else if (unwrapped instanceof UndeclaredThrowableException) { unwrapped = ((UndeclaredThrowableException) unwrapped).getUndeclaredThrowable(); } else { return unwrapped; } } } }

总结

异常处理是编写健壮和可靠的 Java 程序的重要部分。当 Java 程序抛出异常而不捕获时,会发生什么呢?

JVM 会使用默认的异常处理机制来处理未捕获的异常:

  1. 打印异常栈跟踪:JVM 会打印异常的栈跟踪信息(stack trace),包括异常的类型、异常的消息、以及异常发生时的调用栈。这些信息有助于开发者理解异常发生的位置和原因。

  2. 终止线程:抛出异常的线程会终止。如果异常发生在主线程中,并且没有被捕获,则整个程序会终止。

下面分享一下使用异常的一些小技巧:

⨳ 捕获特定异常:尽量捕获具体的异常类型,而不是使用泛型的 Exception 类。

⨳ 不要忽略异常:不要空捕获异常,至少应记录异常信息。

⨳ 使用自定义异常:根据业务需求定义自定义异常,以便提供更有意义的错误信息。

⨳ 确保资源释放:使用 finally 块或 try-with-resources 语句确保资源如文件、网络连接等被正确关闭。

⨳ 避免异常控制流:不要使用异常来控制程序的正常流程,这会导致代码难以理解和维护。

异常大致就这些内容,希望大家在分析框架源码时,可以留意一下高手们是怎么处理异常的,有时候魔鬼就出现在细节中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值