Java 方法调用的底层原理
我们平时写 Java 方法调用时看起来非常简单:
java
animal.speak();
但 JVM 真正执行这行代码时,并不是在 .class 文件里提前保存一个 speak() 的内存地址,然后运行到这里直接跳过去。Java 程序编译完成以后,并不知道这个类最终会被加载到哪块内存,也不知道某个方法运行时的具体地址,更重要的是,像多态这种情况,最终调用哪个方法甚至要等程序运行以后才能确定。
所以 Java 方法调用真正需要解决的是两个问题:编译阶段如何描述"我要调用哪个方法",以及运行阶段如何找到最终真正要执行的方法。
整个过程也正好可以沿着这两个问题往下看。
方法调用在字节码里是什么样的
Java 中和方法调用直接相关的字节码主要有五种:
| 指令 | 主要用途 |
|---|---|
invokestatic |
调用静态方法 |
invokespecial |
调用构造方法、private 方法、super.xxx() 等特殊实例方法 |
invokevirtual |
调用普通实例方法,支持运行时多态 |
invokeinterface |
通过接口调用实现类方法 |
invokedynamic |
动态调用点,具体调用规则由 Bootstrap Method 决定 |
比如:
java
Animal animal = new Dog();
animal.speak();
编译以后可能会看到类似:
text
invokevirtual #7
这里最容易产生一个误解:#7 并不是某个方法的内存地址,它只是当前 class 文件常量池中的一个索引。
顺着 #7 找下去,可能得到:
text
CONSTANT_Methodref
Animal.speak:()V
也就是说,编译器只是告诉 JVM:
text
我要调用 Animal 里面
名字叫 speak
参数为空
返回值为 void
的这个方法
至于这个方法最后在内存中的什么位置,甚至最后到底是不是调用 Animal.speak(),编译阶段并不需要知道。
这就是符号引用存在的意义。
为什么需要符号引用
Java 源代码被编译成 .class 文件以后,class 文件中会存在一个静态常量池,其中保存字面量以及类、字段、方法等各种符号引用。
对于普通类方法,通常使用 CONSTANT_Methodref 符号描述,而接口方法则使用 CONSTANT_InterfaceMethodref。
逻辑上可以把一个 Methodref 理解成:
text
类
+
方法名
+
方法描述符
比如:
text
Animal
+
speak
+
()V
实际的 class 文件结构会再拆成 Class 和 NameAndType 等常量池项,但最终表达的就是这些信息。
接口也是一样:
text
Animal接口
+
speak
+
()V
只是对应的常量池项变成 InterfaceMethodref。
之所以不直接保存内存地址,本质上还是因为 .class 文件必须和具体运行环境解耦,编译时候并不清楚运行时时候具体的内存地址。
今天这个类可能被加载到某块内存,下一次运行可能又是另外一个位置;同一个 .class 也可能跑在不同操作系统、不同 JVM 实现甚至不同机器上。因此编译器只能保存一个与具体内存位置无关的描述,也就是符号引用。
类真正被 JVM 加载以后,JVM 会基于 class 文件常量池构建对应的运行时常量池。后续这些类、字段、方法的符号引用,就可以在运行期间逐渐被解析成 JVM 能直接使用的内部引用。
所以执行:
text
invokevirtual #7
的时候,可以把它理解成 JVM 根据当前类对应的运行时常量池,找到 #7 所描述的那个方法引用。
这里有时候会提到栈帧里的"动态连接"。它表达的是栈帧需要支持方法运行过程中把符号引用转换成直接引用这种能力,但不要把它机械地理解成栈帧里一定存在一个叫"动态连接指针"的东西,每次 invokevirtual 都必须先通过这个指针跳到运行时常量池。
真正重要的是:字节码里面保存的是常量池索引,而常量池里面一开始保存的是符号引用。
符号引用是怎么变成直接引用的
只有方法名字还不够,JVM 最终还是必须找到真正的方法。
这一步就是解析。
假设常量池里面有:
text
Methodref Animal.speak:()V
JVM 会根据 Animal 定位对应的类,再根据方法名 speak 和方法描述符 ()V 按照 JVM 的方法解析规则寻找对应方法,同时进行类型关系以及访问权限等检查。
如果找不到目标方法,或者这个引用本身不符合 JVM 规定,就可能出现:
text
NoSuchMethodError
IllegalAccessError
IncompatibleClassChangeError
之类的链接错误。
解析成功之后,JVM 没必要以后每执行一次方法调用,都重新拿着:
text
Animal + speak + ()V
重新查找一次。
因此解析结果通常会被保存到 JVM 对应的运行时数据结构中,后续可以直接使用已经解析好的结果。
而且解析并不意味着必须在类刚加载的时候把整个常量池一次性全部处理完。JVM 允许很多符号引用采用惰性解析,也就是第一次真正使用到的时候再解析。
到这里,一个很重要的概念就出现了:
符号引用解析成功,不代表最终执行的方法已经确定。
这两个过程很容易被混在一起。
解析出来的方法,不一定是最终执行的方法
还是这个例子:
java
Animal animal = new Dog();
animal.speak();
因为变量 animal 的编译期类型是 Animal,所以字节码中的调用点可能对应:
text
Methodref Animal.speak:()V
解析阶段解决的是:
text
Animal.speak:()V
到底描述的是哪个方法
所以解析得到的目标可以理解成 Animal.speak()。
但是程序真正运行的时候:
java
animal
实际指向的是:
text
Dog
如果 Dog 重写了 speak(),最终显然应该执行:
java
Dog.speak()
所以这里实际上存在两个不同阶段:
text
符号引用解析
↓
确定调用点描述的是 Animal.speak
运行时动态分派
↓
根据 receiver 实际类型确定最终执行 Dog.speak
也就是说:
text
解析
≠
动态分派
解析解决的是"这个符号引用到底代表什么",动态分派解决的是"这一次调用最终进入哪个实现"。
理解了这个区别以后,invokevirtual 的实现就比较容易理解了。
invokevirtual 为什么需要虚方法表
假设有:
java
class Animal {
void speak() {
}
void eat() {
}
}
class Dog extends Animal {
@Override
void speak() {
}
}
一种最直观的动态分派实现方式,是 JVM 拿到 Dog 对象以后先去 Dog 中寻找 speak(),找不到再去 Animal 中找,再找不到继续沿着继承关系向上搜索。
逻辑上当然可以这么做,但如果每调用一次虚方法都沿着继承链查找一次,成本显然比较高。
所以 JVM 通常会利用虚方法表,也就是 vtable,把这种继承关系提前整理好。
可以把 Animal 的 vtable 简化理解成:
text
slot 0 -> Animal.speak
slot 1 -> Animal.eat
Dog 继承 Animal 时,会继承这种槽位布局。
因为 Dog 重写了 speak(),所以对应槽位被替换:
text
Dog vtable
slot 0 -> Dog.speak
slot 1 -> Animal.eat
如果 Dog 没有重写 eat(),这个槽位就继续指向父类实现。
这样执行:
java
animal.speak();
的时候,运行过程就可以简化成:
text
从操作数栈取得 receiver
↓
receiver 实际是 Dog 对象
↓
获取 Dog 对应的类型信息
↓
访问 Dog 的 vtable
↓
根据 speak 对应的槽位取得方法
↓
Dog.speak
所以 vtable 真正解决的问题,并不是"帮 JVM 更快地沿继承链搜索",而是尽量把原本需要运行时搜索的继承关系提前整理成固定槽位,让动态分派可以直接通过槽位定位方法。
如果 Dog 没有重写 speak():
text
Dog vtable
slot 0 -> Animal.speak
运行时还是直接访问这个槽位,而不是发现 Dog 没有方法以后再临时向 Animal 查找。
这里还需要注意,并不是一个类里面所有方法都会进入 vtable。
像:
text
static 方法
构造方法
private 方法
本身就不需要参与普通虚方法分派。
真正需要 vtable 的,主要是那些可能被子类覆盖、需要根据对象实际类型进行动态选择的虚方法。
这也是 invokevirtual 和 invokestatic、invokespecial 的本质区别之一。
接口调用为什么又不完全一样
接口调用的前半段其实和普通虚方法非常相似。
比如:
java
interface Animal {
void speak();
}
class Dog implements Animal {
public void speak() {
}
}
Animal animal = new Dog();
animal.speak();
字节码中可能出现:
text
invokeinterface #7
而 #7 对应:
text
InterfaceMethodref Animal.speak:()V
解析阶段依然是先确定:
text
Animal 接口
+
speak
+
()V
所描述的是哪个接口方法。
但这个接口方法本身并没有告诉 JVM 最终要执行 Dog.speak()。
真正执行 invokeinterface 的时候,JVM仍然需要取得 receiver,发现实际对象类型是 Dog,然后再根据 Dog 的类型信息找到:
text
Dog 对 Animal.speak 的具体实现
所以接口调用同样符合:
text
符号引用解析
≠
最终方法分派
只是接口方法的分派结构比普通类方法更加复杂。
Java 的类是单继承,因此父类和子类很容易维护稳定的 vtable 槽位布局。但是一个 Java 类可以同时实现很多接口:
java
class Dog implements Animal, Runnable, Serializable {
}
接口之间没有像类继承一样天然统一的单继承槽位关系,所以不能简单地把接口方法全部按照普通 vtable 的方式理解。
在 HotSpot 中,会使用类似 itable,也就是 interface table 的结构辅助接口方法分派。
可以把两种调用简单区分成:
text
invokevirtual
receiver
↓
实际 Klass
↓
vtable 固定槽位
↓
目标方法
而接口调用更接近:
text
invokeinterface
receiver
↓
实际 Klass
↓
接口分派结构,例如 itable
↓
找到对应接口
↓
找到接口方法对应实现
↓
目标方法
两者解决的本质问题其实一样:根据对象实际类型完成动态分派。
区别主要在于类的单继承关系使 invokevirtual 更容易建立稳定的虚方法槽位,而接口支持多实现,因此接口分派需要额外处理"哪个接口、接口里的哪个方法"这层关系。
invokestatic 和 invokespecial 为什么简单很多
理解了动态分派,再看另外两种调用指令就会简单很多。
比如:
java
Utils.test();
使用:
text
invokestatic
静态方法属于类,本身不存在:
text
Animal animal = new Dog()
这种根据对象实际类型决定目标实现的问题。
因此符号引用解析以后,目标方法基本就能够确定,不需要再根据 receiver 去查 vtable。
invokespecial 也是类似的思路。
构造方法、private 方法以及:
java
super.speak();
这类调用本来就是要求执行一个明确的方法,而不是进行普通的虚方法分派,因此也不会走常规的 invokevirtual + vtable 这套动态选择过程。
所以从方法调用实现角度看,可以先把这些指令分成两类理解。
一类调用的目标基本可以提前确定:
text
invokestatic
invokespecial
另一类则需要根据运行时对象类型进一步选择:
text
invokevirtual
invokeinterface
至于 invokedynamic 则更加特殊,它连"调用规则是什么"都不完全由普通的类方法解析机制决定,而是通过 Bootstrap Method 建立动态调用点。Lambda 就大量使用了这套机制,不过这已经是另外一条链路了。
JVM 真正运行时还会继续优化
到这里我们描述的基本是 JVM 方法调用的基础语义模型:
text
invokevirtual
↓
取得 receiver
↓
取得实际类型
↓
vtable
↓
目标方法
真实的 HotSpot 在程序长期运行以后,还会继续对这套过程进行优化。
假设 JIT 发现某个调用点:
java
animal.speak();
虽然类型写的是 Animal,但运行过程中几乎永远都是:
text
Dog
那么 JIT 就有机会进行去虚拟化,也就是 Devirtualization。
原本可能需要:
text
receiver
↓
Klass
↓
vtable
↓
Dog.speak
经过优化以后,有可能直接生成针对 Dog.speak() 的调用。
如果 Dog.speak() 本身比较简单,还可能进一步发生方法内联,直接把这个方法的机器码合并到调用者里。
所以需要注意:
vtable 是理解 Java 动态分派非常重要的基础机制,但并不代表最终生成的机器码每一次执行 invokevirtual 都真的完整查询一次 vtable。
JIT、Inline Cache、去虚拟化、方法内联等机制,都可能把这一过程进一步优化。
不过这些优化并没有改变 Java 方法调用的语义。
无论 JVM 最终怎么优化,它都必须保证:
java
Animal animal = new Dog();
animal.speak();
最终表现得就像调用了:
text
Dog.speak()
把整个调用过程串起来
回头再看最开始的代码:
java
Animal animal = new Dog();
animal.speak();
整个过程其实可以串成一条非常清晰的链路。
Java 编译器首先根据 animal 的编译期类型生成:
text
invokevirtual #7
#7 指向 class 文件常量池中的:
text
Methodref Animal.speak:()V
类加载以后,JVM 基于 class 文件常量池建立运行时常量池。
第一次真正需要这个方法引用时,JVM 对:
text
Animal.speak:()V
进行解析,找到它所描述的方法,并把解析结果缓存下来。
但解析只能说明:
text
这个调用点描述的是 Animal.speak
真正执行 invokevirtual 时,JVM 还要从操作数栈中取得 receiver。
发现:
text
receiver -> Dog
于是根据 Dog 的实际类型信息,通过虚方法分派找到对应槽位:
text
Dog vtable[slot] -> Dog.speak
最后进入真正的方法。
所以 Java 方法调用底层真正需要分清楚的,其实就是三层:
text
字节码调用指令
↓
描述"以什么方式调用"
常量池符号引用与解析
↓
描述"这个调用点指的是哪个方法"
运行时方法分派
↓
决定"这一次真正执行哪个实现"
invokestatic 和 invokespecial 大多在第二步之后目标就已经比较明确,而 invokevirtual 和 invokeinterface 还必须继续经历第三步。