5.2 加载、链接与调用:从符号引用到 invokedynamic
字节码调用通常指向常量池里的符号引用。JVM 需要解析名字和描述符、检查访问权限,并在需要时按接收者类型选择实现。名字只是线索,还得查清它属于哪个加载器、该由哪个方法接手。
加载、链接、初始化不是一件事
类或接口的生命周期可以先按三段理解:
加载 Loading
↓ 读取二进制表示,创建 Class 对象
链接 Linking
├─ 验证 Verification
├─ 准备 Preparation
└─ 解析 Resolution(可按规范允许延后)
初始化 Initialization
↓ 执行类或接口初始化方法 <clinit>准备阶段为静态字段创建存储并设置规范定义的初始值,不等于执行源码里的所有静态初始化表达式。源码中的静态字段赋值和静态块通常被编译进 <clinit>,在初始化阶段执行;常量变量还可能通过 ConstantValue 属性赋值,不必出现在 <clinit> 中。
保存为 InitializationDemo.java,用 javac --release 17 InitializationDemo.java 编译,再运行 java InitializationDemo:
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 的类,它们是不同运行时类型,即使字节完全相同。
这解释了插件系统和应用服务器里常见的异常:
com.example.Plugin cannot be cast to com.example.Plugin错误信息中的两个名字一样,但定义加载器不同。诊断时要同时查看类名、加载来源和加载器层次。
类加载器还决定命名空间隔离和依赖可见性。若宿主的静态缓存长期持有插件对象,或者长寿命线程的上下文类加载器仍指向插件加载器,就可能让插件及其加载器持续可达,阻碍卸载。插件引用宿主本身不是同一个方向的保留关系。诊断卸载问题要沿 GC 根到插件的引用链查,而不是只数加载器个数。
五条调用指令各自表达什么
| 指令 | 典型用途 | 是否由调用点动态选择目标 |
|---|---|---|
invokestatic | 静态方法 | 否 |
invokespecial | 实例初始化、父类调用等特殊分派 | 按特殊规则 |
invokevirtual | 类实例方法 | 是 |
invokeinterface | 接口方法 | 是 |
invokedynamic | 由引导方法建立调用点 | 由调用点协议决定 |
源码语法与指令并非永远一对一。编译器版本、目标 class 版本以及访问方式都可能影响生成结果,所以应以 javap 输出验证具体代码。
invokevirtual:按接收者运行时类型分派
下面两段是调用点片段:假定 ConsolePrinter 已实现 Printer,两个 run 方法放在使用它们的类中。
interface Printer {
void print(String text);
}
void run(ConsolePrinter printer) {
printer.print("ready");
}若调用点的符号引用是类方法,通常使用 invokevirtual。解析先确定符号引用是否合法,真正调用时再按接收者实际类选择覆盖实现。
invokeinterface:从接口引用调用
void run(Printer printer) {
printer.print("ready");
}调用点以接口方法引用表示时通常使用 invokeinterface。接口默认方法、冲突选择等语义由规范定义,不能简化成“总是查一张固定接口表”。具体 JVM 可采用多种缓存与优化策略。
invokespecial:绕开普通虚分派规则
在直接调用实例初始化方法 <init> 的字节码指令中,必须使用 invokespecial。这不排除反射或构造器方法句柄等更高层的对象创建 API。显式父类方法调用等场景也使用特殊选择规则:
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 决定后续调用行为。
invokedynamic 指令
↓ 首次链接
bootstrap method + 名称 + 方法类型 + 静态参数
↓
CallSite(target MethodHandle)
↓
后续调用目标建立调用点后,后续调用使用其目标句柄,JVM 可以按 CallSite 类型优化。有些调用点允许更新目标;并发首次链接时,引导方法也可能执行多次,最终只采用一个链接结果。因此不要把引导方法当作业务操作“恰好执行一次”的入口。
Lambda 与字符串拼接只是常见用法
把下面的完整示例保存为 LambdaDemo.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 和非常量字符串拼接。常量表达式如:
String value = "hello" + "world";会在编译期折叠为 "helloworld",不需要运行时拼接。
这些是编译器生成策略,不是说 Java 语言规范要求所有 Lambda 必须以某个固定引导方法实现。其他 JVM 语言还可用 invokedynamic 实现动态方法分派、运行时适配等不同语义。
用下面的命令寻找调用点和 BootstrapMethods 属性:
javac --release 17 LambdaDemo.java
javap -c -v -p LambdaDemo运行 java LambdaDemo 应输出 user=ada, count=3。反汇编时重点观察:
InvokeDynamic常量池项的名称和方法描述符;BootstrapMethods属性;- 引导方法句柄及静态参数;
- 编译器是否生成了额外的合成方法。
完成检查
写一个类,包含静态方法、实例覆盖、接口调用、super 调用、Lambda 和字符串拼接。然后:
- 标出五种调用指令实际出现了哪些;
- 区分哪些调用需要接收者,以及它如何进入操作数栈;静态调用和
invokedynamic没有隐式接收者; - 找到一个
invokedynamic的引导方法; - 修改变量的静态类型,观察调用指令是否变化;
- 不运行程序,先预测哪些操作会触发类初始化,再用日志验证。