返回文章列表
JVM虚拟机
JVM字节码指令方法调用异常处理

15方法调用与异常捕获原理

1.方法调用的原理

方法调用的本质是通过字节码指令的执行,在栈上创建栈帧,并执行被调用方法中的字节码。以 invoke 开头的字节码指令负责完成方法调用。

以下面这段代码为例:

public class MethodDemo {
    public static void main(String[] args) {
        study();
    }
 
    public static void study(){
        eat();
        sleep();
    }
    public static void eat(){
        System.out.println("吃饭");
    }
    public static void sleep(){
        System.out.println("睡觉");
    }
}

1、调用 study 方法时会执行 invokestatic 指令,Java 虚拟机找到常量池中 #2 对应的方法(即 study 方法),创建栈帧并压入虚拟机栈。

2、eat 和 sleep 方法也是类似的处理方式:invokestatic 指令定位到方法后创建对应的栈帧。

3、方法栈帧创建之后,就可以执行方法里的字节码指令了。

![](/images/jvm/03-基础篇-Java内存区域 -p25-i1.png)

在 JVM 中,一共有五个字节码指令可以执行方法调用:

  1. invokestatic:调用静态方法。
  2. invokespecial:调用对象的 private 方法、构造方法,以及使用 super 关键字调用父类实例的方法、构造方法,以及所实现接口的默认方法。
  3. invokevirtual:调用对象的非 private 方法。
  4. invokeinterface:调用接口对象的方法。
  5. invokedynamic:用于调用动态方法,主要应用于 lambda 表达式中,机制极为复杂,了解即可。

Invoke 方法的核心作用就是找到字节码指令并执行。

Invoke 指令执行时,需要找到方法区中 InstanceKlass 中保存的方法相关的字节码信息。但是方法区中有很多类,每一个类又包含很多个方法,怎么精确地定位到方法的位置呢?这就需要借助于栈帧中帧数据部分保存的动态链接。

1.1静态绑定

1、编译期间,invoke 指令会携带一个参数符号引用,引用到常量池中的方法定义。方法定义中包含了类名 + 方法名 + 返回值 + 参数。

2、在方法第一次调用时,这些符号引用就会被替换成内存地址的直接引用,这种方式称之为静态绑定。

![](/images/jvm/03-基础篇-Java内存区域 -p45-i1.png)

当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系,这是静态绑定得以实现的基础。

静态绑定适用于处理静态方法、私有方法、或者使用 final 修饰的方法,因为这些方法不能被继承之后重写:

  • invokestatic
  • invokespecial
  • final 修饰的 invokevirtual

1.2动态绑定

对于非 static、非 private、非 final 的方法,有可能存在子类重写方法,那么就需要通过动态绑定来完成方法地址绑定的工作。比如下面这段代码中,调用的其实是 Cat 类对象的 eat 方法,但是编译完之后虚拟机指令中调用的是 Animal 类的 eat 方法,这就需要在运行过程中通过动态绑定找到 Cat 类的 eat 方法,从而实现多态。

Animal animal = new Cat();
animal.eat();  // 实际调用 Cat 的 eat

动态绑定是基于方法表来完成的,invokevirtual 使用了虚方法表(vtable),invokeinterface 使用了接口方法表(itable),整体思路类似。下面以 invokevirtual 和虚方法表来解释整个过程。

每个类中都有一个虚方法表,本质上它是一个数组,记录了方法的地址。子类方法表中包含父类方法表中的所有方法;子类如果重写了父类方法,则使用自己类中方法的地址进行替换。

产生 invokevirtual 调用时,先根据对象头中的类型指针找到方法区中 InstanceKlass 对象,获得虚方法表;再根据虚方法表找到对应的方法,获得方法的地址,最后调用方法。

1.3方法出口

方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址,保证方法返回时能正确回到调用方继续执行。

![](/images/jvm/03-基础篇-Java内存区域 -p46-i1.png)

2.异常捕获的原理

在 Java 中,程序遇到异常时会向外抛出,此时可以使用 try-catch 捕获异常的方式将异常捕获并继续让程序按程序员设计好的方式运行。比如如下代码:在 try 代码块中如果抛出了 Exception 对象或者子类对象,则会进入 catch 分支。

try {
    // 可能抛出异常的代码
} catch (Exception e) {
    // 异常处理
}

异常捕获机制的实现,需要借助于编译时生成的异常表。异常表在编译期生成,存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

![](/images/jvm/03-基础篇-Java内存区域 -p47-i1.png)

异常表中的关键字段:

  • 起始/结束 PC:此条异常捕获生效的字节码起始/结束位置。
  • 跳转 PC:异常捕获之后,跳转到的字节码位置。

例如,在位置 2 到 4 字节码指令执行范围内,如果出现了 Exception 对象或者子类对象异常,直接跳转到位置 7 的指令,也就是 i = 2 代码位置。

![](/images/jvm/03-基础篇-Java内存区域 -p48-i1.png)

2.1异常捕获过程

程序运行中触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配:

  1. 如果匹配,跳转到"跳转 PC"对应的字节码位置。
  2. 如果遍历完都不能匹配,说明异常无法在当前方法执行时被捕获,此方法栈帧直接弹出,在上一层的栈帧中进行异常捕获的查询。

2.2多个 catch 分支

多个 catch 分支情况下,异常表会从上往下遍历,先捕获 RuntimeException,如果捕获不了,再捕获 Exception:

try {
    // ...
} catch (RuntimeException e) {
    // 先匹配
} catch (Exception e) {
    // 后匹配
}

2.3 finally 的处理

finally 的处理方式相对比较复杂一点,分为以下几个步骤:

1、finally 中的字节码指令会插入到 try 和 catch 代码块中,保证在 try 和 catch 执行之后一定会执行 finally 中的代码。如下,在 i=1 和 i=2 两段字节码指令之后,都加入了 finally 下的字节码指令。

2、如果抛出的异常范围超过了 Exception,比如 Error 或者 Throwable,此时也要执行 finally,所以异常表中增加了两个条目,覆盖了 try 和 catch 两段字节码指令的范围,any 代表可以捕获所有种类的异常。

try {
    // ...
} catch (Exception e) {
    // ...
} finally {
    // 无论是否异常,都会执行
}

总结一下:方法调用是 invoke 指令在虚拟机栈上创建栈帧并执行字节码的过程,静态绑定和动态绑定分别处理不可重写方法和可重写方法的地址定位;异常捕获则依赖于编译期生成的异常表,通过起始/结束 PC 和跳转 PC 实现精确的异常分派,finally 通过把字节码指令复制到 try/catch 之后并新增 any 异常表条目来保证一定执行。