跳到内容

第 9 卷 / 第 5 章 / 第 2 课

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

字节码调用通常指向常量池里的符号引用。JVM 需要解析名字和描述符、检查访问权限,并在需要时按接收者类型选择实现。名字只是线索,还得查清它属于哪个加载器、该由哪个方法接手。

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

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

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

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

保存为 InitializationDemo.java,用 javac --release 17 InitializationDemo.java 编译,再运行 java InitializationDemo:

java
public final class InitializationDemo {
    static final class Settings {
        static final int DEFAULT_RETRIES = 3;
        static int retries = loadRetries();

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

    public static void main(String[] args) throws Exception {
        Class.forName(Settings.class.getName(), false,
                      InitializationDemo.class.getClassLoader());
        System.out.println(Settings.DEFAULT_RETRIES);
        System.out.println("before active use");
        System.out.println(Settings.retries);
        System.out.println(Settings.retries);
    }
}

输出依次是 3、before active use、initialize、3、3。类字面量和 Class.forName(..., false, ...) 没有请求初始化,读取编译期常量也不触发它;第一次读取非常量静态字段 retries 才触发初始化。成功初始化后,同一运行时类不会因为再读一次字段就重做初始化。数组类和接口还有各自的规则,不能用“一引用就初始化”概括。

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

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

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

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

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

类加载器还决定命名空间隔离和依赖可见性。若宿主的静态缓存长期持有插件对象,或者长寿命线程的上下文类加载器仍指向插件加载器,就可能让插件及其加载器持续可达,阻碍卸载。插件引用宿主本身不是同一个方向的保留关系。诊断卸载问题要沿 GC 根到插件的引用链查,而不是只数加载器个数。

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

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

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

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

下面两段是调用点片段:假定 ConsolePrinter 已实现 Printer,两个 run 方法放在使用它们的类中。

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。这不排除反射或构造器方法句柄等更高层的对象创建 API。显式父类方法调用等场景也使用特殊选择规则:

java
class Parent {
    void refresh() { System.out.println("parent"); }
}

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 与字符串拼接只是常见用法 ​

把下面的完整示例保存为 LambdaDemo.java:

java
import java.util.function.Function;

public final class LambdaDemo {
    public static void main(String[] args) {
        Function<String, Integer> length = String::length;
        String userId = args.length == 0 ? "ada" : args[0];
        int count = length.apply(userId);
        String message = "user=" + userId + ", count=" + count;
        System.out.println(message);
    }
}

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

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

会在编译期折叠为 "helloworld",不需要运行时拼接。

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

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

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

运行 java LambdaDemo 应输出 user=ada, count=3。反汇编时重点观察:

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

完成检查 ​

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

  1. 标出五种调用指令实际出现了哪些;
  2. 区分哪些调用需要接收者,以及它如何进入操作数栈;静态调用和 invokedynamic 没有隐式接收者;
  3. 找到一个 invokedynamic 的引导方法;
  4. 修改变量的静态类型,观察调用指令是否变化;
  5. 不运行程序,先预测哪些操作会触发类初始化,再用日志验证。

参考资料 ​

Built with VitePress | Software Systems Atlas