跳到内容

5.2 加载、链接与调用:从符号引用到 invokedynamic

一条字节码调用指令走进运行时后,究竟落到哪个类、哪个方法和哪段机器码,取决于加载与分派过程。

字节码里的方法调用通常不是某个机器地址,而是常量池中的符号引用。JVM 要把名字和描述符解析为可调用目标,同时遵守类加载器、访问控制和动态分派规则。

加载、链接、初始化不是一件事

类或接口的生命周期可以先按三段理解:

text
加载 Loading
  ↓ 读取二进制表示,创建 Class 对象
链接 Linking
  ├─ 验证 Verification
  ├─ 准备 Preparation
  └─ 解析 Resolution(可按规范允许延后)
初始化 Initialization
  ↓ 执行类或接口初始化方法 <clinit>

准备阶段为静态字段创建存储并设置规范定义的初始值,不等于执行源码里的所有静态初始化表达式。源码中的静态字段赋值和静态块通常被编译进 <clinit>,在初始化阶段执行。

java
final class Settings {
    static int retries = loadRetries();

    static int loadRetries() {
        System.out.println("initialize");
        return 3;
    }
}

仅仅在 classpath 上发现 Settings.class 不会必然打印;满足主动使用条件触发初始化时才执行 <clinit>。数组类、接口初始化和编译期常量还有更细规则,应以 JLS/JVMS 为准,而不是用“一引用就初始化”概括。

类的身份包含定义它的加载器

JVM 中运行时类型身份不只由二进制类名决定,还与定义它的类加载器有关。两个加载器分别定义名为 com.example.Plugin 的类,它们是不同运行时类型,即使字节完全相同。

这解释了插件系统和应用服务器里常见的异常:

text
com.example.Plugin cannot be cast to com.example.Plugin

错误信息中的两个名字一样,但定义加载器不同。诊断时要同时查看类名、加载来源和加载器层次。

类加载器还决定命名空间隔离和依赖可见性。随意改变线程上下文类加载器,或让插件持有宿主加载器中的静态引用,可能造成类无法卸载和元数据长期占用。

五条调用指令各自表达什么

指令典型用途是否由调用点动态选择目标
invokestatic静态方法
invokespecial实例初始化、父类调用等特殊分派按特殊规则
invokevirtual类实例方法
invokeinterface接口方法
invokedynamic由引导方法建立调用点由调用点协议决定

源码语法与指令并非永远一对一。编译器版本、目标 class 版本以及访问方式都可能影响生成结果,所以应以 javap 输出验证具体代码。

invokevirtual:按接收者运行时类型分派

java
interface Printer {
    void print(String text);
}

void run(ConsolePrinter printer) {
    printer.print("ready");
}

若调用点的符号引用是类方法,通常使用 invokevirtual。解析先确定符号引用是否合法,真正调用时再按接收者实际类选择覆盖实现。

invokeinterface:从接口引用调用

java
void run(Printer printer) {
    printer.print("ready");
}

调用点以接口方法引用表示时通常使用 invokeinterface。接口默认方法、冲突选择等语义由规范定义,不能简化成“总是查一张固定接口表”。具体 JVM 可采用多种缓存与优化策略。

invokespecial:绕开普通虚分派规则

构造器 <init> 只能由 invokespecial 调用。显式父类方法调用等场景也使用特殊选择规则:

java
class Child extends Parent {
    Child() {
        super();
    }

    void refresh() {
        super.refresh();
    }
}

不要记成“所有 private 方法必然对应 invokespecial”。源码访问级别和最终指令的关系经历过演进,也受具体调用形态影响;判断某段编译产物仍应查看字节码。

invokedynamic 把链接策略交给引导方法

前四条指令的链接语义主要由 JVM 预先规定。invokedynamic 的常量池项还指向一个引导方法及静态参数。调用点第一次链接时,引导方法返回一个 CallSite,其中的目标 MethodHandle 决定后续调用行为。

text
invokedynamic 指令
      ↓ 首次链接
bootstrap method + 名称 + 方法类型 + 静态参数

CallSite(target MethodHandle)

后续调用目标

它不是“每次调用都重新反射查找”。建立调用点后,JVM 可以按 CallSite 类型和实现策略优化。

Lambda 与字符串拼接只是常见用法

java
Function<String, Integer> length = String::length;
String message = "user=" + userId + ", count=" + count;

现代 javac 常用 invokedynamic 配合引导方法实现 Lambda 和非常量字符串拼接。常量表达式如:

java
String value = "hello" + "world";

可能在编译期直接折叠为常量。

这些是编译器生成策略,不是说 Java 语言规范要求所有 Lambda 必须以某个固定引导方法实现。其他 JVM 语言还可用 invokedynamic 实现动态方法分派、运行时适配等不同语义。

用下面的命令寻找调用点和 BootstrapMethods 属性:

bash
javac LambdaDemo.java
javap -c -v -p LambdaDemo

重点观察:

  • InvokeDynamic 常量池项的名称和方法描述符;
  • BootstrapMethods 属性;
  • 引导方法句柄及静态参数;
  • 编译器是否生成了额外的合成方法。

完成检查

写一个类,包含静态方法、实例覆盖、接口调用、super 调用、Lambda 和字符串拼接。然后:

  1. 标出五种调用指令实际出现了哪些;
  2. 对每个调用点解释接收者从哪里进入操作数栈;
  3. 找到一个 invokedynamic 的引导方法;
  4. 修改变量的静态类型,观察调用指令是否变化;
  5. 不运行程序,先预测哪些操作会触发类初始化,再用日志验证。

参考资料

Built with VitePress | Software Systems Atlas