深入理解Java虚拟机:(五)JVM是如何处理异常的?

一、异常的基本概念

在讲 JVM 是如何处理异常之前,我们先来复习一下异常的分类。这张图是我们刚开始学习 Java 异常再熟悉不过的一张图了吧。在这里还是要唠叨一下,在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。第一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。第二子类则是 Exception,涵盖程序可能需要捕获并且处理的异常。Exception 有一个特殊的子类 RuntimeException,用来表示“程序虽然无法继续执行,但是还能抢救一下”的情况。

在这里插入图片描述

RuntimeException 和 Error 属于 Java 里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在 Java 语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。

抛出异常可分为显式和隐式两种。

显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。

隐式抛异常的主体则是 Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。举例来说,Java 虚拟机在执行读取数组操作时,发现输入的索引值是负数,故而抛出数组索引越界异常。

捕获异常则涉及了如下三种代码块。

1、try 代码块:用来标记需要进行异常监控的代码。
2、catch 代码块:跟在 try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
3、finally 代码块:跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。


异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。

既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非 throw 语句的位置,而是新建异常的位置。

因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,我们往往选择抛出新建异常实例的原因。

二、JVM 是如何捕获异常的?

提到 JVM 处理异常的机制,就不得不提 Exception Table,称为异常表,我们这里先不着急介绍异常表,先来看个处理异常的小例子。

1、try-catch

public class SimpleTryCatch {
    public static void main(String[] args) {
        try {
            testNPE();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void testNPE() {
    }
}

上面是一个捕获空指针异常的小例子。

我们还是老规矩,用 javac 先把java文件编译成class文件,然后javap来分析字节码。

javac.exe SimpleTryCatch.java
javap -v SimpleTryCatch
public com.jvm.SimpleTryCatch();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=1, locals=1, args_size=1
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
    LineNumberTable:
      line 3: 0

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=1, locals=2, args_size=1
       0: invokestatic  #2                  // Method testNPE:()V
       3: goto          11
       6: astore_1
       7: aload_1
       8: invokevirtual #4                  // Method java/lang/Exception.printStackTrace:()V
      11: return
    Exception table:
       from    to  target type
           0     3     6   Class java/lang/Exception //异常表条目

异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下

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

编译过后,该方法的异常表拥有一个条目。其 from 指针和 to 指针分别为 0 和 3,代表它的监控范围从索引为 0 的字节码开始,到索引为 3 的字节码结束(不包括 3)。该条目的 target 指针是 6,代表这个异常处理器从索引为 6 的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是 Exception。

那么异常表用在什么时候呢?

答案是异常发生的时候,当一个异常发生时

  1. JVM会在当前出现异常的方法中,查找异常表,是否有合适的处理者来处理。
  2. 如果当前方法异常表不为空,并且异常符合处理者的from和to节点,并且type也匹配,则JVM调用位于 target 指针指向的字节码来处理。
  3. 如果上一条未找到合理的处理者,则继续查找异常表中的剩余条目。
  4. 如果当前方法的异常表无法处理,则向上查找(弹栈处理)刚刚调用该方法的调用处,并重复上面的操作。
  5. 如果所有的栈帧被弹出,仍然没有处理,则抛给当前的Thread,Thread则会终止。
  6. 如果当前Thread为最后一个非守护线程,且未处理异常,则会导致JVM终止运行。

以上就是JVM处理异常的一些机制。

2、try-catch-finally

public class SimpleTryCatchFinally {
    private int tryBlock;
    private int catchBlock;
    private int finallyBlock;
    private int methodExit;

    public void test() {
        try {
            tryBlock = 0;
        } catch (Exception e) {
            catchBlock = 1;
        } finally {
            finallyBlock = 2;
        }
        methodExit = 3;  
    }
}
public void test();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=2, locals=3, args_size=1
       0: aload_0
       1: iconst_0
       2: putfield      #2                  // Field tryBlock:I
       5: aload_0
       6: iconst_2
       7: putfield      #3                  // Field finallyBlock:I
      10: goto          35
      13: astore_1
      14: aload_0
      15: iconst_1
      16: putfield      #5                  // Field catchBlock:I
      19: aload_0
      20: iconst_2
      21: putfield      #3                  // Field finallyBlock:I
      24: goto          35
      27: astore_2
      28: aload_0
      29: iconst_2
      30: putfield      #3                  // Field finallyBlock:I
      33: aload_2
      34: athrow
      35: aload_0
      36: iconst_3
      37: putfield      #6                  // Field methodExit:I
      40: return
    Exception table:
       from    to  target type
           0     5    13   Class java/lang/Exception
           0     5    27   any
          13    19    27   any
    LineNumberTable:
      line 11: 0
      line 15: 5
      line 16: 10
      line 12: 13
      line 13: 14
      line 15: 19
      line 16: 24
      line 15: 27
      line 17: 35
      line 18: 40

可以看到,iconst_2 字节码出现了三次,所以编译结果包含三份 finally 代码块。其中,前两份分别位于 try 代码块和 catch 代码块的正常执行路径出口。最后一份则作为异常处理器,监控 try 代码块以及 catch 代码块。它将捕获 try 代码块触发的、未被 catch 代码块捕获的异常,以及 catch 代码块触发的异常。

3、 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是哪个呢?

public class SimpleTryCatchFinally {
    private int tryBlock;
    private int catchBlock;
    private int finallyBlock;
    private int methodExit;

    public void test() {
        try {
            tryBlock = 0;
        } catch (Exception e) {
            catchBlock = 1;
            throw new IllegalArgumentException();
        } finally {
            finallyBlock = 2;
        }
        methodExit = 3;
    }
}
public void test();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=2, locals=3, args_size=1
       0: aload_0
       1: iconst_0
       2: putfield      #2                  // Field tryBlock:I
       5: aload_0
       6: iconst_2
       7: putfield      #3                  // Field finallyBlock:I
      10: goto          35
      13: astore_1
      14: aload_0
      15: iconst_1
      16: putfield      #5                  // Field catchBlock:I
      19: new           #6                  // class java/lang/IllegalArgumentException
      22: dup
      23: invokespecial #7                  // Method java/lang/IllegalArgumentException."<init>":()V
      26: athrow
      27: astore_2
      28: aload_0
      29: iconst_2
      30: putfield      #3                  // Field finallyBlock:I
      33: aload_2
      34: athrow
      35: aload_0
      36: iconst_3
      37: putfield      #8                  // Field methodExit:I
      40: return
    Exception table:
       from    to  target type
           0     5    13   Class java/lang/Exception
           0     5    27   any
          13    28    27   any
    LineNumberTable:
      line 11: 0
      line 16: 5
      line 17: 10
      line 12: 13
      line 13: 14
      line 14: 19
      line 16: 27
      line 18: 35
      line 19: 40

可以看到,iconst_2 字节码只出现了两次,catch 完异常并没有走 finally 代码块,也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。

4、catch 先后顺序的问题

我们在代码中的 catch 的顺序决定了异常处理者在异常表的位置,所以,越是具体的异常要先处理,否则就会出现下面的问题:

private static void misuseCatchException() {
	try {
		testNPE();
	} catch (Throwable t) {
		t.printStackTrace();
	} catch (Exception e) { //error occurs during compilings with tips Exception Java.lang.Exception has already benn caught.
		e.printStackTrace();
	}
}

这段代码会导致编译失败,因为先捕获 Throwable 后捕获 Exception,会导致后面的catch 永远无法被执行。

5、return 和 finally 的问题

类似这样的代码,既有 return,又有 finally,那么 finally 到底会不会执行?

public class SimpleTryCatchFinallyReturn {
    public static String tryCatchFinallyReturn() {
        try {
            testNPE();
            return "success";
        } catch (Exception e) {
            return "error";
        } finally {
            System.out.println("finally");
        }
    }

    public static void testNPE() {
    }
}

答案是 finally 会执行,那么还是使用上面的方法,我们来看一下为什么finally会执行。

public static java.lang.String tryCatchFinallyReturn();
  descriptor: ()Ljava/lang/String;
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=3, args_size=0
       0: invokestatic  #2                  // Method testNPE:()V
       3: ldc           #3                  // String success
       5: astore_0
       6: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #5                  // String finally
      11: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: aload_0
      15: areturn
      16: astore_0
      17: ldc           #8                  // String error
      19: astore_1
      20: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: ldc           #5                  // String finally
      25: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      28: aload_1
      29: areturn
      30: astore_2
      31: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #5                  // String finally
      36: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_2
      40: athrow
    Exception table:
       from    to  target type
           0     6    16   Class java/lang/Exception
           0     6    30   any
          16    20    30   any
    LineNumberTable:
      line 6: 0
      line 7: 3
      line 11: 6
      line 8: 16
      line 9: 17
      line 11: 20

不难看出,有三个 String finally,故既有 return,又有 finally,那么 finally 会执行。

三、Java 7 的 Suppressed 异常以及 try-with-resources 的语法糖

Java 7 引入了 Suppressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。

然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁琐。你不可能 try-finally 里一直循环内嵌 try-finally 吧,代码可读性非常差,而且严重影响性能。


FileInputStream in0 = null;
FileInputStream in1 = null;
FileInputStream in2 = null;
...
try {
	in0 = new FileInputStream(new File("in0.txt"));
  	...
  	try {
    	in1 = new FileInputStream(new File("in1.txt"));
    	...
    	try {
      		in2 = new FileInputStream(new File("in2.txt"));
      		...
    	} finally {
      		if (in2 != null) in2.close();
    	}
  	} finally {
    	if (in1 != null) in1.close();
  	}
} finally {
	if (in0 != null) in0.close();
}

为此,Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Suppressed 异常。当然,该语法糖的主要目的并不是使用 Suppressed 异常,而是精简资源打开关闭的用法。

Java 7 的 try-with-resources 语法糖,极大地简化了上述代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close() 操作。在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Suppressed 异常的功能,来避免原异常“被消失”。


public class Foo implements AutoCloseable {
	private final String name;
    public Foo(String name) { this.name = name; }

  	@Override
  	public void close() {
    	throw new RuntimeException(name);
  	}

  	public static void main(String[] args) {
    	try (Foo foo0 = new Foo("Foo0"); // try-with-resources
         	Foo foo1 = new Foo("Foo1");
         	Foo foo2 = new Foo("Foo2")) {
      	throw new RuntimeException("Initial");
    	}
  	}
}

// 运行结果:
Exception in thread "main" java.lang.RuntimeException: Initial
        at Foo.main(Foo.java:18)
        Suppressed: java.lang.RuntimeException: Foo2
                at Foo.close(Foo.java:13)
                at Foo.main(Foo.java:19)
        Suppressed: java.lang.RuntimeException: Foo1
                at Foo.close(Foo.java:13)
                at Foo.main(Foo.java:19)
        Suppressed: java.lang.RuntimeException: Foo0
                at Foo.close(Foo.java:13)
                at Foo.main(Foo.java:19)

除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可。

// 在同一catch代码块中捕获多种异常
try {
	...
} catch (SomeException | OtherException e) {
    ...
}
发布了332 篇原创文章 · 获赞 198 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/103979138