异常 Exception

316 阅读14分钟

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

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

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

⨳ 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 为父类派生出的多个通用的异常类。

image.png

Serializable 序列化接口

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

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

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

private static final long serialVersionUID = 1L ;

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

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

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

Throwable 可抛出的

Throwable 类是所有错误和异常的超类。它有两个直接子类:ErrorException。所有的异常和错误对象都是 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 也是未受检查的异常,不需要显式处理或声明。

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) :也称编译期异常,必须在代码中显式处理或声明,不处理的话代码编译都通不过,例如 IOExceptionSQLException...。

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

image.png

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

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

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

try-catch-finally

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

try-catch-finally 的结构

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

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

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

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

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

链式异常 chained exceptions

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

image.png

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

比如自定义一个异常:

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,...]:

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 类验证一下:

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

输出结果如下:

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 块中。

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

很好理解吧。

try-with-resources

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

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

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 声明中重新定义它。

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 语句中使用已经定义的资源,而不必再次显式地声明和初始化它们。这使得代码更简洁和易读。

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 的例子:

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

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

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

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

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

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

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

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

throws 关键字

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

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

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

反射异常与代理异常

反射异常 InvocationTargetException

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

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

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

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

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

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

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

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

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

怎么理解这句话呢?

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

private final Throwable target;

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

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

为什么需要 InvocationTargetException 呢?

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

那为什么不声明抛出 ThrowableException 呢?

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

代理异常 UndeclaredThrowableException

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

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

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

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 语句确保资源如文件、网络连接等被正确关闭。

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

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