通过JVM深入理解Java异常机制
JVM内部结构
要深入理解JVM异常处理机制,需要从JVM内部结构开始。
下图描述的主要是Java程序在执行时,由JVM管理的运行时数据区;包括方法区、Java堆、Java虚拟机栈、PC寄存器、本地方法栈,还有常量池。它们又被分为两大类——线程共享和线程私有数据区。
- 线程共享数据区包括:Java堆、方法区/永久代/元空间、常量池。它们会随着虚拟机启动而创建,随着虚拟机退出而销毁。
- 线程私有数据区包括:PC寄存器、JVM栈、native本地方法栈。它们是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
而今天要聊的Java异常处理机制,跟线程私有的 JVM栈和PC寄存器有关。
JVM 用栈来跟踪一系列的方法调用过程。该堆栈保存了每个调用方法的信息。当一个新的方法被调用时,JVM把描述该方法的信息封装为栈帧置入栈顶,位于栈顶的方法为正在执行的方法。
每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在JVM栈中从入栈到出栈的过程。
如果在执行的方法过程中抛出异常,JVM必须找到能捕获处理该异常的catch块
- JVM首先观察当前方法是否存在能够处理该异常的catch块,如果存在,就执行该catch代码块
- 如果不存在,JVM会从从栈中弹出该方法的栈帧,继续到前一个方法中查找合适的catch块
- 当JVM追溯到调用栈的最底部的方法时,如果仍然没有找到处理该异常的代码块,将调用异常对象的printStackTrace()方法,打印来自方法调用栈的异常信息随后终止整个应用程序。
Java异常表(Exception table)
提到JVM的异常处理机制,不得不提及Exception Table;以下称为异常表。
Java 异常表(Exception table)是 Java 编译器为每个方法生成的一张映射表,它用于记录方法中每个代码块(try-catch/try-finally 等)及其对应的异常信息。在 Java 方法执行时,如果发生异常,Java 虚拟机会根据异常表的信息查找并确定正确的异常处理程序。
每个通过try-catch捕获异常的方法,都有一个异常表与之关联;该表随方法的字节码序列一起在字节码文件中存放。对于 try 块捕获的每个异常,异常表都有一个条目。
如果在方法执行期间抛出异常,Java 虚拟机会在异常表中搜索匹配的条目。如果抛出异常代码行号(当前PC程序计数器)在条目指定的范围内,并且抛出的异常类是条目指定的异常类(或者是指定异常类的子类),则异常表条目匹配。
Java 虚拟机按照条目在表中出现的顺序搜索异常表。
- 当找到第一个匹配项时,Java 虚拟机将PC寄存器(程序计数器)设置为新的 pc 偏移位置并在那里继续执行。
- 如果未找到匹配项,Java 虚拟机将弹出当前堆栈帧并重新抛出相同的异常。当 Java 虚拟机弹出当前栈帧时,它有效地中止了当前方法的执行并返回到调用该方法的上一级方法。但是它并没有在上一级方法中继续正常执行,而是在那个方法的调用处抛出了同样的异常,这导致Java虚拟机经历了同样的过程,搜索那个方法的异常表。
Java字节码是通过javac编译器 基于Java源代码文件生成的,就是按照字节码规范重新将源代码换成JVM可理解的表达方式而已;
因此Java源代码中,使用try-catch块捕获异常的方法,生成字节码时,会通过异常表(Exception table)来描述源代码中的try-catch;具体来说,异常表中的每个条目,必须包含 try 块的起始位置和结束位置、catch 块中捕获的异常类型以及捕获异常后跳转执行的位置。
字节码文件中,方法Code里的Exception table,包含如下信息:
- from 可能发生异常的起始点
- to 可能发生异常的结束点
- target 上述from和to之前发生异常后的异常处理器的位置
- type 异常处理器(能)处理的异常类型
其中,from、to和target都是PC寄存器(程序计数器)所指示的字节码行号;程序的跳转执行,就是修改PC寄存器,改变下一行要执行的代码位置来实现的。
Java异常表实战
下面我们创建ExceptionTable类,编译后再通过javap反汇编字节码文件;
public class ExceptionTable {
public static void main(String[] args) throws Exception {
try {
throw new Exception();
} catch (Exception e) {
System.out.println("Caught!");
} finally {
System.out.println("Finally!");
}
}
}
javap -v ExceptionTable.class
// 省略Constant pool
{
public exception.ExceptionTable();
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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lexception/ExceptionTable;
public static void main(java.lang.String[]) throws java.lang.Exception;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/lang/Exception
3: dup
4: invokespecial #3 // Method java/lang/Exception."<init>":()V
7: athrow
8: astore_1
9: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
12: ldc #5 // String Caught!
14: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
17: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
20: ldc #7 // String Finally!
22: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25: goto 39
28: astore_2
29: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
32: ldc #7 // String Finally!
34: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
37: aload_2
38: athrow
39: return
Exception table:
from to target type
0 8 8 Class java/lang/Exception
0 17 28 any
LineNumberTable:
line 6: 0
line 7: 8
line 8: 9
line 10: 17
line 11: 25
line 10: 28
line 12: 39
LocalVariableTable:
Start Length Slot Name Signature
9 8 1 e Ljava/lang/Exception;
0 40 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 3
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 83 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 10 /* same */
Exceptions:
throws java.lang.Exception
}
SourceFile: "ExceptionTable.java"
main方法的Code指令后面,我们能看到Exception table:
异常表第一行:它是我们的try-catch语句,如果在字节码的0-8行发生异常,将跳转到第8行进行处理
异常表第二行:它是我们的finally语句,无论0-17行发生什么,最终都将由第28行进行处理。
异常表中的 any
- 如果命中了 any 之后,会将异常继续向上抛出去,交由该方法的调用方法处理。
被冗余/重复的finally
仔细观察ExceptionTable#main方法的Code指令,会发现finally块的内容是重复的。
在 JDK1.4.2之前,javac 编译器使用 jsr
和 ret
指令来实现 finally 语句,但是JDK1.4.2之后自动在每段可能的分支路径后将 finally 语句块内容冗余生成一遍来实现。JDK1.7及之后版本,则完全禁止在字节码文件中使用 jsr
和 ret
指令。
冗余finally块的指令,就是把finally 代码块对应的指令复制一份,分别放到了 try/catch 指令的后面,就能达到 finally 一定会被执行的效果。
jsr
和ret
是早期Java字节码中的两个指令,用于实现Java方法的子程序调用和返回。
- jsr指令(Jump Subroutine)用于实现Java方法的子程序调用。它的作用是将当前方法的返回地址压入操作数栈中,并跳转到指定的子程序执行。在子程序执行完毕后,使用ret指令返回到原来的方法,并将返回值压入操作数栈中。在 JDK1.4.2之前,JVM处理异常时,调用异常处理程序就是通过jsr。
- ret指令(Return from Subroutine)用于实现Java方法的返回。它的作用是将操作数栈顶的值作为返回值返回,并跳转到之前使用jsr指令保存的返回地址处继续执行。
jsr
和 ret
指令在Java SE 6及以前的版本中是合法的指令,但在Java SE 7中已经被废弃。在Java SE 7及以后的版本中,应该使用invokedynamic指令来实现动态语言支持,而不是使用jsr和ret指令。
总结
1.JVM异常处理流程
- 当Java程序中发生异常时,JVM会在方法的异常表中查找相应的异常处理代码。如果找到了匹配的异常处理代码,JVM会执行该代码来处理异常;如果没有找到匹配的代码,JVM会将异常向上抛出,弹出当前方法的栈帧,返回到调用该方法的上一级方法,查找上一级方法的异常表;
- 依次沿调用栈查找,如果所有的栈帧被弹出,仍然没有处理,则将异常抛给当前的Thread,Thread会终止;如果当前Thread为最后一个非守护线程,且未处理异常,则会导致JVM终止运行。
当 JVM 中的所有非守护线程都已经结束时,JVM 就会自动退出,而无论该线程是否抛出了未捕获的异常。但是,如果这个非守护线程抛出了未被处理的异常,JVM 会在退出之前将堆栈信息打印到控制台。此外,非守护线程如果没有设置 setUncaughtExceptionHandler,也会将未捕获的异常传递给默认的未捕获异常处理程序来进行处理,该处理程序简单地打印了异常的堆栈轨迹到控制台上。
编译器生成字节码指令时,通过冗余finally块指令内容,达到 finally 一定会被执行的效果。
2.Java异常表
- 编译器生成的异常表,是按Java源代码中catch块声明的顺序依次列出每个异常条目;
- 每个异常条目包含from、to、target和type;finally块对应的条目一定在最后一行,from~to的范围是整个try块,且type是any 代表一定会执行。