Java 异常:异常类型和异常捕获原理

1 阅读20分钟

前言

本文介绍Java异常的基础知识,主要涉及如下内容:异常类型和继承体系、异常捕获底层原理、常见异常实践。如果你对异常实践补充的话,欢迎评论~

异常类型和基础体系

          Throwable
         /        \
    Error          Exception
                   /        \
           Checked     RuntimeException (Unchecked)

Java 中的所有异常对象都继承自 java.lang.Throwable 类。异常体系主要分为四个关键概念:

Throwable

Throwable 是 Java 语言中所有错误与异常的超类。Throwable 包含两个子类:Error(错误)和 Exception(异常),它们通常用于指示发生了异常情况。Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。

Error(错误)

Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。此类错误一般表示代码运行时 JVM 出现问题。通常有 Virtual MachineError(虚拟机运行错误)、NoClassDefFoundError(类定义错误)等。比如 OutOfMemoryError:内存不足错误;StackOverflowError:栈溢出错误。此类错误发生时,JVM 将终止线程。这些错误是不受检异常,非代码性错误。因此,当此类错误发生时,应用程序不应该去处理此类错误。按照Java惯例,我们是不应该实现任何新的Error子类的!

Exception(异常)

程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常。

  • 运行时异常都是RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。运行时异常的特点是Java编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。

  • 非运行时异常 (编译异常)是RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。

异常又可以分为可查的异常(checked exceptions)和不可查的异常(unchecked exceptions):

  • 可查异常(编译器要求必须处置的异常):正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。
  • 不可查异常(编译器不要求强制处置的异常)包括运行时异常(RuntimeException与其子类)和错误(Error)。# 异常基础提示接下来我们看下异常使用的基础。

异常捕获

Java 使用 try-catch-finally 块来处理异常。

基本语法结构

try {
    // 1. 可能会发生异常的代码
} catch (ExceptionType1 e1| ExceptionType2 e2) {
    // 2. 捕获并处理特定类型的异常
} catch (ExceptionType3 e3) {
    // 3. 处理另一种类型的异常
} finally {
    // 4. 无论是否发生异常,都会执行的代码(通常用于资源释放)
}

执行顺序

// 定义三种自定义异常类型,方便区分
Exception_A (子类)
Exception_B (子类)
Exception_C (不相关的类)

public static void testExceptionFlow(int scenario) {
    System.out.println("--- 步骤 1: 进入方法 ---");
    
    try {
        System.out.println("--- 步骤 2: 进入 Try 块 ---");
        
        // 模拟不同场景抛出不同异常
        if (scenario == 1) throw new Exception_A("发生异常 A");
        if (scenario == 2) throw new Exception_B("发生异常 B");
        if (scenario == 3) throw new Exception_C("发生异常 C");
        
        System.out.println("--- 步骤 3: Try 块正常执行完毕 ---");
        
    } catch (Exception_A e) {
        System.out.println("--- 步骤 4-A: 捕获到异常 A,处理中 ---");
        
    } catch (Exception_B e) {
        System.out.println("--- 步骤 4-B: 捕获到异常 B,处理中 ---");
        
    } finally {
        System.out.println("--- 步骤 5: Finally 块必定执行 ---");
    }
    
    System.out.println("--- 步骤 6: Finally 之后的代码 ---");
    System.out.println("--- 步骤 7: 方法正常结束 ---\n");
}
场景描述Try 块剩余代码匹配的 Catch 块Finally 块Finally 之后代码方法结束状态
1. 正常无异常✅ 执行❌ 跳过✅ 执行✅ 执行正常结束
2. 异常 A (匹配)❌ 中断✅ 执行 Catch A✅ 执行✅ 执行正常结束
3. 异常 B (匹配)❌ 中断✅ 执行 Catch B✅ 执行✅ 执行正常结束
4. 异常 C (无匹配)❌ 中断❌ 跳过✅ 执行❌ 不执行异常抛出 (崩溃)
  1. 当try没有捕获到异常时:try语句块中的语句逐一被执行,程序将跳过catch语句块,执行finally语句块和其后的语句;
  2. 当try捕获到异常,catch语句块里没有处理此异常的情况:当try语句块里的某条语句出现异常时,而没有处理此异常的catch语句块时,此异常将会抛给JVM处理,finally语句块里的语句还是会被执行,但finally语句块后的语句不会被执行;
  3. 当try捕获到异常,catch语句块里有处理此异常的情况:在try语句块中是按照顺序来执行的,当执行到某一条语句出现异常时,程序将跳到catch语句块,并与catch语句块逐一匹配,找到与之对应的处理程序,其他的catch语句块将不会被执行,而try语句块中,出现异常之后的语句也不会被执行,catch语句块执行完后,执行finally语句块里的语句,最后执行finally语句块后的语句;

底层原理

Java 的 try-catch-finally 并不是一种运行时的“魔法”,而是编译器在编译阶段通过字节码重组和生成元数据(异常表)来实现的。

简单例子

提到JVM处理异常的机制,就需要提及Exception Table,以下称为异常表。先看一个简单的 Java 处理异常的小例子:

public static void simpleTryCatch() {  
   try {  
       testNPE();  
   } catch (Exception e) {  
       e.printStackTrace();  
   }  
}

上面的代码是一个很简单的例子,用来捕获处理一个潜在的空指针异常。当然如果只是看简简单单的代码,我们很难看出什么高深之处,更没有了今天文章要谈论的内容。所以这里我们需要借助javap,一个用来拆解class文件的工具,和javac一样由JDK提供。然后我们使用javap来分析这段代码(需要先使用javac编译)

//javap -c Main  
 public static void simpleTryCatch();  
    Code:  
       0: invokestatic  #3                  // Method testNPE:()
       3: goto          11  
       6: astore_0  
       7: aload_0  
       8: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:() 
      11: return  
    Exception table:  
       from    to  target type  
           0     3     6   Class java/lang/Exception

看到上面的代码,应该会有会心一笑,因为终于看到了Exception table,也就是我们要研究的异常表。异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下:

  • from 可能发生异常的起始点
  • to 可能发生异常的结束点
  • target 上述from和to之前发生异常后的异常处理者的位置
  • type 异常处理者处理的异常的类信息

那么异常表用在什么时候呢?答案是异常发生的时候,当一个异常发生时

  1. JVM会在当前出现异常的方法中,查找异常表,是否有合适的处理者来处理
  2. 如果当前方法异常表不为空,并且异常符合处理者的from和to节点,并且type也匹配,则JVM调用位于target的调用者来处理。
  3. 如果上一条未找到合理的处理者,则继续查找异常表中的剩余条目
  4. 如果当前方法的异常表无法处理,则向上查找(弹栈处理)刚刚调用该方法的调用处,并重复上面的操作。
  5. 如果所有的栈帧被弹出,仍然没有处理,则抛给当前的Thread,Thread则会终止。
  6. 如果当前Thread为最后一个非守护线程,且未处理异常,则会导致JVM终止运行。以上就是JVM处理异常的一些机制。try catch -finally除了简单的try-catch外,我们还常常和finally做结合使用。比如这样的代码

复杂例子

假设有如下Java代码:

public static String tryCatchReturn() {
   try {
       testNPE();
       return  "OK";
   } catch (Exception e) {
       return "ERROR";
   } finally {
       System.out.println("tryCatchReturn");
   }
}

反编译后大致如下,假设L1 L2 L3分别对应try-catch-finally:

  public static java.lang.String tryCatchReturn();
    Code:
       // ===== Try 块开始 (对应 L1) =====
       0: invokestatic  #3                  // Method testNPE:()V
       3: ldc           #6                  // String OK
       5: astore_0                          // 将 "OK" 引用存入局部变量表 slot 0 (暂存)
       
       // ===== Try 块结束,内联 Finally 代码开始 =====
       6: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #8                  // String tryCatchReturn
      11: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       
      14: aload_0                           // 从 slot 0 读取 "OK"
      15: areturn                           // 返回 "OK"
       // ===== Try 正常结束路径 =====

       // ===== Catch 块开始 (对应 L2) =====
      16: astore_1                          // 异常对象引用存入 slot 1
      17: ldc           #10                 // String ERROR
      19: astore_2                          // 将 "ERROR" 引用存入局部变量表 slot 2 (暂存)
      
      // ===== Catch 块结束,内联 Finally 代码开始 =====
      20: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: ldc           #8                  // String tryCatchReturn
      25: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      
      28: aload_2                           // 从 slot 2 读取 "ERROR"
      29: areturn                           // 返回 "ERROR"
       // ===== Catch 异常处理结束路径 =====

       // ===== 公共 Finally 块 (对应 L3) =====
      30: astore_3                          // 这是一个“兜底”的 Finally,如果 try 或 catch 中发生未处理的异常,暂存到 slot 3
      31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #8                  // String tryCatchReturn
      36: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      
      39: aload_3                           // 读取 slot 3 的异常对象
      40: athrow                            // 再次抛出异常
    Exception table:
       from    to  target type
           0    14    16   Class java/lang/Exception  // [Row 1]
           0    14    30   any                      // [Row 2]
          16    29    30   any                      // [Row 3]

在分析执行步骤前,先看懂这三条异常表规则,它们是跳转的指挥棒:

  1. [0, 14) -> 16 (Exception) : 如果在 Try 块(偏移量 0 到 14)发生了 Exception 或其子类异常,跳转到 16 执行 Catch 块。
  2. [0, 14) -> 30 (any) : 如果在 Try 块(0 到 14)发生了任何异常(包括 Catch 块没捕获的异常,或者在执行 Try 块内联 Finally 代码时发生的异常),跳转到 30 执行公共 Finally。
  3. [16, 29) -> 30 (any) : 如果在 Catch 块及其内联 Finally 代码(16 到 29)发生了任何异常,跳转到 30 执行公共 Finally。

从字节码可以看出,Java 编译器并没有使用“先执行 finally,再回来 return”的魔法指令。它采用的是代码复制和变量暂存策略:

  1. 暂存: 在执行 finally 之前,return 的值已经被 astore_x 存入了局部变量表(Slot 0 或 Slot 2)。
  2. 内联finally 的代码被直接复制到了 return 指令之前。
  3. 恢复finally 执行完,紧接着就是 aload_x 和 areturn,把之前暂存的值拿出来返回。

异常的申明(throws)

在Java中,当前执行的语句必属于某个方法,Java解释器调用main方法执行开始执行程序。若方法中存在检查异常,如果不对其捕获,那必须在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。 在方法中声明一个异常,方法头中使用关键字throws,后面接上要声明的异常。若声明多个异常,则使用逗号分割。如下所示:

public static void method() throws IOException,        FileNotFoundException{  
//something statements  
}

注意:若是父类的方法没有声明异常,则子类继承方法后,也不能声明异常。通常,应该捕获那些知道如何处理的异常,将不知道如何处理的异常继续传递下去。传递异常可以在方法签名处使用 throws 关键字声明可能会抛出的异常。

private static void readFile(String filePath) throws IOException {  
    File file = new File(filePath);  
    String result;  
    BufferedReader reader = new BufferedReader(new FileReader(file));  
    while((result = reader.readLine())!=null) {  
        System.out.println(result);  
    }  
    reader.close();  
}

Throws抛出异常的规则:

  • 如果是不可查异常(unchecked exception),即Error、RuntimeException或它们的子类,那么可以不使用throws关键字来声明要抛出的异常,编译仍能顺利通过,但在运行时会被系统抛出。

  • 必须声明方法可抛出的任何可查异常(checked exception)。即如果一个方法可能出现受可查异常,要么用try-catch语句捕获,要么用throws子句声明将它抛出,否则会导致编译错误仅当抛出了异常,该方法的调用者才必须处理或者重新抛出该异常。当方法的调用者无力处理该异常的时候,应该继续抛出,而不是囫囵吞枣。

  • 调用方法必须遵循任何可查异常的处理和声明规则。若覆盖一个方法,则不能声明与覆盖方法不同的异常。声明的任何异常必须是被覆盖方法所声明异常的同类或子类。

异常实践

提示在 Java 中处理异常并不是一个简单的事情。不仅仅初学者很难理解,即使一些有经验的开发者也需要花费很多时间来思考如何处理异常,包括需要处理哪些异常,怎样处理等等。这也是绝大多数开发团队都会制定一些规则来规范进行异常处理的原因。

当你抛出或捕获异常的时候,有很多不同的情况需要考虑,而且大部分事情都是为了改善代码的可读性或者 API 的可用性。异常不仅仅是一个错误控制机制,也是一个通信媒介。因此,为了和同事更好的合作,一个团队必须要制定出一个最佳实践和规则,只有这样,团队成员才能理解这些通用概念,同时在工作中使用它。这里给出几个被很多团队使用的异常处理最佳实践。

在 finally 块中清理资源或者使用 try-with-resource 语句

当使用类似InputStream这种需要使用后关闭的资源时,一个常见的错误就是在try块的最后关闭资源。 错误示例

public void doNotCloseResourceInTry() {  
    FileInputStream inputStream = null;  
    try {  
        File file = new File("./tmp.txt");  
        inputStream = new FileInputStream(file);  
        // use the inputStream to read a file  
        // do NOT do this  
        inputStream.close();  
    } catch (FileNotFoundException e) {  
        log.error(e);  
    } catch (IOException e) {  
        log.error(e);  
    }  
}

问题就是,只有没有异常抛出的时候,这段代码才可以正常工作。try 代码块内代码会正常执行,并且资源可以正常关闭。但是,使用 try 代码块是有原因的,一般调用一个或多个可能抛出异常的方法,而且,你自己也可能会抛出一个异常,这意味着代码可能不会执行到 try 代码块的最后部分。结果就是,你并没有关闭资源。所以,你应该把清理工作的代码放到 finally 里去,或者使用 try-with-resource 特性。

方法一:使用 finally 代码块与前面几行 try 代码块不同,finally 代码块总是会被执行。不管 try 代码块成功执行之后还是你在 catch 代码块中处理完异常后都会执行。因此,你可以确保你清理了所有打开的资源。

public void closeResourceInFinally() {  
    FileInputStream inputStream = null;  
    try {  
        File file = new File("./tmp.txt");  
        inputStream = new FileInputStream(file);  
        // use the inputStream to read a file  
    } catch (FileNotFoundException e) {  
        log.error(e);  
    } finally {  
        if (inputStream != null) {  
            try {  
                inputStream.close();  
            } catch (IOException e) {  
                log.error(e);  
            }  
        }  
    }  
}

方法二:Java 7 的 try-with-resource 语法如果你的资源实现了 AutoCloseable 接口,你可以使用这个语法。大多数的 Java 标准资源都继承了这个接口。当你在 try 子句中打开资源,资源会在 try 代码块执行后或异常处理后自动关闭。

public void automaticallyCloseResource() {  
    File file = new File("./tmp.txt");  
    try (FileInputStream inputStream = new FileInputStream(file);) {  
        // use the inputStream to read a file  
    } catch (FileNotFoundException e) {  
        log.error(e);  
    } catch (IOException e) {  
        log.error(e);  
    }  
}

尽量使用标准的异常代码

重用是值得提倡的,这是一条通用规则,异常也不例外。重用现有的异常有几个好处:它使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥着程序员不熟悉的异常。异常类越少,意味着内存占用越小,并且转载这些类的时间开销也越小。

Java标准异常中有几个是经常被使用的异常:

  • IllegalArgumentException参数的值不合适

  • IllegalStateException参数的状态不合适

  • NullPointerException在null被禁止的情况下参数值为null

  • IndexOutOfBoundsException下标越界

  • ConcurrentModificationException在禁止并发修改的情况下,对象检测到并发修改

  • UnsupportedOperationException对象不支持客户请求的方法

虽然它们是Java平台库迄今为止最常被重用的异常,但是,在许可的条件下,其它的异常也可以被重用。例如,如果你要实现诸如复数或者矩阵之类的算术对象,那么重用ArithmeticException和NumberFormatException将是非常合适的。如果一个异常满足你的需要,则不要犹豫,使用就可以,不过你一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是名字的基础上。

对异常进行文档说明

当在方法上声明抛出异常时,也需要进行文档说明。目的是为了给调用者提供尽可能多的信息,从而可以更好地避免或处理异常。在 Javadoc 添加 @throws 声明,并且描述抛出异常的场景。

/**  
* Method description  
*   
* @throws MyBusinessException - businuess exception description  
*/  
public void doSomething(String input) throws MyBusinessException {  
   // ...  
}

同时,在抛出MyBusinessException 异常时,需要尽可能精确地描述问题和相关信息,这样无论是打印到日志中还是在监控工具中,都能够更容易被人阅读,从而可以更好地定位具体错误信息、错误的严重程度等。

优先捕获最具体的异常

大多数 IDE 都可以帮助你实现这个最佳实践。当你尝试首先捕获较不具体的异常时,它们会报告无法访问的代码块。但问题在于,只有匹配异常的第一个 catch 块会被执行。 因此,如果首先捕获 IllegalArgumentException ,则永远不会到达应该处理更具体的 NumberFormatException 的 catch 块,因为它是 IllegalArgumentException 的子类。总是优先捕获最具体的异常类,并将不太具体的 catch 块添加到列表的末尾。你可以在下面的代码片断中看到这样一个 try-catch 语句的例子。 第一个 catch 块处理所有 NumberFormatException 异常,第二个处理所有非 NumberFormatException 异常的IllegalArgumentException 异常。

public void catchMostSpecificExceptionFirst() {  
    try {  
        doSomething("A message");  
    } catch (NumberFormatException e) {  
        log.error(e);  
    } catch (IllegalArgumentException e) {  
        log.error(e)  
    }  
}

不要捕获 Throwable 类

Throwable 是所有异常和错误的超类。你可以在 catch 子句中使用它,但是你永远不应该这样做!如果在 catch 子句中使用 Throwable ,它不仅会捕获所有异常,也将捕获所有的错误。JVM 抛出错误,指出不应该由应用程序处理的严重问题。 典型的例子是 OutOfMemoryError 或者 StackOverflowError 。两者都是由应用程序控制之外的情况引起的,无法处理。所以,最好不要捕获 Throwable ,除非你确定自己处于一种特殊的情况下能够处理错误。

public void doNotCatchThrowable() {  
    try {  
        // do something  
    } catch (Throwable t) {  
        // don't do this!  
    }  
}

不要忽略异常

很多时候,开发者很有自信不会抛出异常,因此写了一个catch块,但是没有做任何处理或者记录日志。

public void doNotIgnoreExceptions() {  
    try {  
        // do something  
    } catch (NumberFormatException e) {  
        // this will never happen  
    }  
}

但现实是经常会出现无法预料的异常,或者无法确定这里的代码未来是不是会改动(删除了阻止异常抛出的代码),而此时由于异常被捕获,使得无法拿到足够的错误信息来定位问题。合理的做法是至少要记录异常的信息。

public void logAnException() {  
    try {  
        // do something  
    } catch (NumberFormatException e) {  
        log.error("This should never happen: " + e); // see this line  
    }  
}

不要记录并抛出异常

这可能是本文中最常被忽略的最佳实践。可以发现很多代码甚至类库中都会有捕获异常、记录日志并再次抛出的逻辑。如下:

try {  
    new Long("xyz");  
} catch (NumberFormatException e) {  
    log.error(e);  
    throw e;  
}

这个处理逻辑看着是合理的。但这经常会给同一个异常输出多条日志。如下:

17:44:28,945 ERROR TestExceptionHandling:65 - java.lang.NumberFormatException: For input string: "xyz"  
Exception in thread "main" java.lang.NumberFormatException: For input string: "xyz"  
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)  
at java.lang.Long.parseLong(Long.java:589)  
at java.lang.Long.(Long.java:965)  
at com.stackify.example.TestExceptionHandling.logAndThrowException(TestExceptionHandling.java:63)  
at com.stackify.example.TestExceptionHandling.main(TestExceptionHandling.java:58)

如上所示,后面的日志也没有附加更有用的信息。如果想要提供更加有用的信息,那么可以将异常包装为自定义异常。

public void wrapException(String input) throws MyBusinessException {  
    try {  
        // do something  
    } catch (NumberFormatException e) {  
        throw new MyBusinessException("A message that describes the error.", e);  
    }  
}

因此,仅仅当想要处理异常时才去捕获,否则只需要在方法签名中声明让调用者去处理。

包装异常时不要抛弃原始的异常

捕获标准异常并包装为自定义异常是一个很常见的做法。这样可以添加更为具体的异常信息并能够做针对的异常处理。 在你这样做时,请确保将原始异常设置为原因(注:参考下方代码 NumberFormatException e 中的原始异常 e )。Exception 类提供了特殊的构造函数方法,它接受一个 Throwable 作为参数。否则,你将会丢失堆栈跟踪和原始异常的消息,这将会使分析导致异常的异常事件变得困难。

public void wrapException(String input) throws MyBusinessException {  
    try {  
        // do something  
    } catch (NumberFormatException e) {  
        throw new MyBusinessException("A message that describes the error.", e);  
    }  
}

不要使用异常控制程序的流程

不应该使用异常控制应用的执行流程,例如,本应该使用if语句进行条件判断的情况下,你却使用异常处理,这是非常不好的习惯,会严重影响应用的性能。

代码1:

if (obj != null) {  
  //...  
}

代码2:

try {   
  obj.method();   
} catch (NullPointerException e) {  
  //...  
}

不要在finally块中使用return

try块中的return语句执行成功后,并不马上返回,而是继续执行finally块中的语句,如果此处存在return语句,则在此直接返回,无情丢弃掉try块中的返回点。如下是一个反例:

private int x = 0;
public int checkReturn() {
    try {
        // x等于1,此处不返回
        return ++x;
    } finally {
        // 返回的结果是2
        return ++x;
    }
}