异常原理 | 优雅,永不过时

2023-12-13 15:04:25 浏览数 (1)

引言

Java 虚拟机里面的异常使用 Throwable 或其子类的实例来表示,抛异常的本质实际上是程序控制权的一种即时的、非局部(Nonlocal)的转换——从异常抛出的地方转换至处理异常的地方。绝大多数的异常的产生都是由于当前线程执行的某个操作所导致的,这种可以称为是同步的异常。与之相对的,异步异常是指在程序的其他任意地方进行的动作而导致的异常。 Java 虚拟机中异常的出现总是由下面三种原因之一导致的:

1. 虚拟机同步检测到程序发生了非正常的执行情况,这时异常将会紧接着在发生非正常执行情况的字节码指令之后抛出。

  • 字节码指令所蕴含的操作违反了 Java 语言的语义,如访问一个元素。
  • 类在加载或者链接时出现错误。
  • 使用某些资源的时候产生资源限制,例如使用了太多的内存

2. athrow 字节码指令被执行。

3. 由于以下原因,导致了异步异常的出现:

  • 调用了 Thread 或者 ThreadGroup 的
  • Java 虚拟机实现的内部程序错误。

理解异常

Java异常的底层实现涉及到编译器和虚拟机(JVM)两个层面。包括编译器如何处理异常代码以及虚拟机如何在运行时处理异常。

编译器层面

示例

代码语言:java复制
try {
    // 可能引发异常的代码
} catch (SomeException e) {
    // 处理 SomeException 的代码
} finally {
    // 无论是否发生异常都会执行的代码
}

编译器处理

编译器在将源代码编译成字节码时,会对异常相关的代码进行处理。

  1. 生成异常表(Exception Table): 编译器会生成一个异常表,其中包含了 try 块的起始和结束位置,以及每个 catch 块和 finally 块的起始位置。这个表是在字节码中的一部分,用于在运行时确定异常处理逻辑。
  2. 异常处理代码的插入: 编译器会在可能引发异常的代码周围插入异常处理代码,以确保异常发生时能够跳转到正确的 catch 块或 finally 块。

虚拟机层面

JVM实现

JVM在运行时负责执行编译生成的字节码。

  1. 异常对象的创建: 当在 try 块中的代码引发异常时,JVM会创建一个异常对象,其中包含有关异常的信息,如类型、消息和堆栈跟踪。
  2. 异常抛出: JVM使用 athrow 指令将异常对象抛出。这通常由 throw 关键字触发。
  3. 异常处理表的使用: 当异常被抛出时,JVM会检查当前方法的异常处理表。它会逐个检查 try 块,看是否匹配抛出的异常。如果找到匹配的 catch 块,JVM会跳转到该块的代码执行异常处理逻辑。
  4. finally 块的执行: 无论是否发生异常,JVM都会执行 finally 块中的代码。这是通过在 try 块的最后插入 finally 指令实现的。

源码示例

以下是 try-catch-finally 示例

代码语言:java复制
package com.example.demo.exception;

public class TryCatchFinallyExample {

    public static void main(String[] args) {
        try {
            int result = divide(10, 0);
            System.out.println("Result: "   result);
        } catch (ArithmeticException e) {
            System.out.println("Caught ArithmeticException: "   e.getMessage());
        } finally {
            System.out.println("Finally block executed");
        }
    }

    public static int divide(int a, int b) {
        return a / b;
    }
}

对应的字节码(使用 javap -c 命令查看):

  1. 先执行编译命令 javac TryCatchFinallyExample.java
  2. 在执行 javap -c TryCatchFinallyExample
代码语言:java复制
Compiled from "TryCatchFinallyExample.java"
public class com.example.demo.exception.TryCatchFinallyExample {
  public com.example.demo.exception.TryCatchFinallyExample();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        10
       2: iconst_0
       3: invokestatic  #2                  // Method divide:(II)I
       6: istore_1
       7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      10: iload_1
      11: invokedynamic #4,  0              // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
      16: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      19: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: ldc           #6                  // String Finally block executed
      24: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: goto          68
      30: astore_1
      31: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: aload_1
      35: invokevirtual #8                  // Method java/lang/ArithmeticException.getMessage:()Ljava/lang/String;
      38: invokedynamic #9,  0              // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
      43: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      46: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      49: ldc           #6                  // String Finally block executed
      51: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      54: goto          68
      57: astore_2
      58: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      61: ldc           #6                  // String Finally block executed
      63: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      66: aload_2
      67: athrow
      68: return
    Exception table:
       from    to  target type
           0    19    30   Class java/lang/ArithmeticException
           0    19    57   any
          30    46    57   any

  public static int divide(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: idiv
       3: ireturn
}

Exception table(异常表)

Exception table 是Java字节码中的一个部分,用于指定方法中的异常处理信息。它描述了在方法执行期间,哪些字节码范围可能抛出异常,以及如何处理这些异常。

我们具体解释 Exception table 部分的含义:

代码语言:java复制
Exception table:
   from    to  target type
       0    19    30   Class java/lang/ArithmeticException
       0    19    57   any
      30    46    57   any

每一行代表一个异常处理条目,它包含以下信息:

  • from: 起始字节码索引,表示异常处理的起始位置。
  • to: 结束字节码索引,表示异常处理的结束位置。
  • target: 处理异常时的目标字节码索引,表示异常被捕获后应该跳转到的位置。
  • type: 异常类型,表示应该捕获的异常类型。

第一行:如果0到19之间,发生了ArithmeticException类型的异常,调用30的位置处理异常。

  • 异常处理范围:从字节码索引0到19。
  • 异常类型:java/lang/ArithmeticException
  • 处理后跳转到字节码索引30。

第二行:如果0到19之间,发生了任何类型的异常,调用57的位置处理异常。

  • 异常处理范围:从字节码索引0到19。
  • 异常类型:any,表示捕获任何异常。
  • 处理后跳转到字节码索引57。

第三行:如果30到46之间(即catch部分),发生了任何类型的异常,调用57的位置处理异常。

  • 异常处理范围:从字节码索引30到46。
  • 异常类型:any,表示捕获任何异常。
  • 处理后跳转到字节码索引57。

通过这个异常表的信息,它告诉Java虚拟机在执行方法时,如果在指定的范围内发生了异常,应该如何处理。每个异常处理条目都包含了异常的类型和处理的范围。如果异常发生在范围内,程序将按照异常处理表中指定的方式进行处理,跳转到相应的目标位置。

再次分析上面的指令

代码语言:java复制
 public static void main(java.lang.String[]);
    Code:
    
       // try 获取 finally 的代码,如果没有异常发生,则执行输出finally的操作,跳到goto的68位置,执行返回操作。  
       0: bipush        10
       2: iconst_0
       3: invokestatic  #2                  // Method divide:(II)I
       6: istore_1
       7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      10: iload_1
      11: invokedynamic #4,  0              // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
      16: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      19: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: ldc           #6                  // String Finally block executed
      24: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: goto          68
      
      // catch 获取 finally代码,如果没有异常发生,则执行输出finally的操作,跳到goto的68位置,执行返回操作。
      30: astore_1
      31: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: aload_1
      35: invokevirtual #8                  // Method java/lang/ArithmeticException.getMessage:()Ljava/lang/String;
      38: invokedynamic #9,  0              // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
      43: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      46: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      49: ldc           #6                  // String Finally block executed
      51: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      54: goto          68
      
      // finally 的代码如果被调用,既有可能是try的异常,也有可能是catch的异常。
      57: astore_2
      58: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      61: ldc           #6                  // String Finally block executed
      63: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      66: aload_2
      
      // 如果异常没有被catch捕获,到了这里,执行完finally的语句后,也要把这个异常抛出去,传递给调用处。
      67: athrow
      68: return

关于指令的解释

  • bipush 10:将整数值10推送到操作数栈上。
  • iconst_0:将整数值0推送到操作数栈上。
  • invokestatic #2(调用静态方法)调用静态方法divide,传入两个整数参数,并接收一个整数结果。
  • istore_1:将操作数栈顶的整数值存储到本地变量表的第一个位置。
  • getstatic #3:获取System.out字段并将其推送到操作数栈上。
  • iload_1:将第一个局部变量(即从divide方法返回的结果)加载到操作数栈上。
  • invokedynamic #4, 0(调用动态方法)动态生成并调用一个方法,该方法接受一个整数参数,并返回一个字符串。
  • invokevirtual #5(调用实例方法)调用PrintStream.println方法,打印出字符串。
  • getstatic #3:获取System.out字段并将其推送到操作数栈上。
  • ldc #6( 将 int, float 或 String 型常量值从常量池中推送至栈顶。)将常量池中的字符串"Finally block executed"加载到操作数栈上。
  • invokevirtual #5:调用PrintStream.println方法,打印出字符串。
  • goto 68:无条件跳转至第68行。

  • astore_1:将操作数栈上的值存储到本地变量表的第一个位置(发生异常时,将异常对象存入这个位置)。
  • getstatic #3:获取System.out字段并将其推送到操作数栈上。
  • aload_1:将第一个局部变量(即捕获到的异常对象)加载到操作数栈上。
  • invokevirtual #8:调用ArithmeticException.getMessage方法,获取异常消息并将其推送到操作数栈上。
  • invokedynamic #9, 0:动态生成并调用一个方法,该方法接受一个字符串参数,并返回一个字符串。
  • invokevirtual #5:调用PrintStream.println方法,打印出字符串。
  • getstatic #3:获取System.out字段并将其推送到操作数栈上。
  • ldc #6:将常量池中的字符串"Finally block executed"加载到操作数栈上。
  • invokevirtual #5:调用PrintStream.println方法,打印出字符串。
  • goto 68:无条件跳转至第68行。

  • astore_2:将操作数栈上的值存储到本地变量表的第二个位置(发生异常时,将新的异常对象存入这个位置)。
  • getstatic #3:获取System.out字段并将其推送到操作数栈上。
  • ldc #6:将常量池中的字符串"Finally block executed"加载到操作数栈上。
  • invokevirtual #5:调用PrintStream.println方法,打印出字符串。
  • aload_2:将第二个局部变量(即新的异常对象)加载到操作数栈上。
  • athrow: 将栈顶的异常抛出。
  • return:返回void。

关于指令的操作,大家可以阅读《Java虚拟机规范》- 第 6 章 Java 虚拟机指令集。

总结

当程序执行过程中发生异常时,Java虚拟机(JVM)会按照以下流程处理异常:

  1. 执行 try :程序执行到 try 块中的字节码指令。
  2. 检测异常发生:当在 try 块中发生异常时,Java虚拟机会检测到异常的发生。
  3. 异常表匹配:异常表是在编译时生成的,它包含了每个 try-catch 块的起始位置、结束位置、异常处理器的位置以及期望捕获的异常类型。异常表将被检查以查找与发生的异常类型匹配的处理器。
  4. 执行字节码指令:try 块中的字节码指令将继续执行,直到异常发生。
  5. 抛出异常:当异常发生时,Java虚拟机会创建一个异常对象,并将其抛出。
  6. 查找匹配的异常处理器:异常表中的每一项都将被检查,如果发生的异常类型匹配,就会选择相应的异常处理器。
  7. 遇到异常处理指令:当匹配到异常处理器时,控制流将跳转到异常处理器的起始位置。这可能涉及到 goto 指令或其他控制流程的改变。
  8. 异常表中的处理器执行:执行异常处理器(catch 块或 finally 块)中的字节码指令。在 catch 块中,会进行对异常对象的处理,而 finally 块则无论是否发生异常都会执行。
  9. 执行 catch 或 finally:在异常处理器中执行相应的字节码指令,处理异常或执行清理代码。
  10. 控制流继续执行:一旦异常处理完成,程序的控制流将继续执行异常处理代码块之后的代码。

我正在参与2023腾讯技术创作特训营第四期有奖征文,快来和我瓜分大奖!

0 人点赞