Java 基础体系 · 第 16/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

JVM 类加载与字节码:生命周期、双亲委派、验证和动态代理

Java 程序执行的对象不是 .java 源文件,而是由类加载器定义、由 JVM 验证和链接、最终由解释器或 JIT 编译器执行的字节码。理解这条链路,需要同时回答四个问题:

  1. 一个类从字节流变成可使用的 Class<?>,经历了什么生命周期?
  2. 为什么同名类可能不是同一个类型,双亲委派解决了什么问题,又没有解决什么问题?
  3. 字节码验证到底验证哪些内容,为什么验证失败通常发生在类初始化之前?
  4. 动态代理如何在运行时生成字节码并参与类加载、方法调用和异常传播?

本文以 Java 25 LTS 和 Java Virtual Machine Specification 为范围。规范保证、JDK 常见实现和工程经验会明确区分。


一、从源代码到执行:类、字节码和 Class 对象

1.1 .java.classClass<?> 不是同一个东西

一个简单类:

public final class UserService {
    private int count;

    public int increment() {
        return ++count;
    }
}

经过编译器后,会生成一个 class file。class file 是一种结构化二进制格式,主要包含:

  • 魔数 0xCAFEBABE
  • 次版本号和主版本号
  • 常量池
  • 类访问标志
  • 当前类和父类索引
  • 接口索引
  • 字段表
  • 方法表
  • 属性表

可以使用 JDK 自带工具查看:

javac --release 25 UserService.java
javap -c -v UserService.class

javac 的职责是把 Java 源代码翻译成 class file;JVM 的职责是加载 class file、验证其结构和类型安全条件,并执行其中的方法。.class 文件本身不是 Class<?> 对象,Class<?> 是 JVM 为某个“类或接口定义”创建的运行时镜像。

更精确地说,一个 Java 类型的身份至少由以下因素决定:

TypeIdentity=(binary name,defining loader)\text{TypeIdentity} = (\text{binary name}, \text{defining loader})

其中:

  • binary name 是二进制名称,例如 com.example.User;
  • defining loader 是最终定义该类的类加载器,而不是调用 loadClass 的任意加载器。

因此,两个加载器分别定义的 com.example.User 即使字节内容完全相同,也不是同一个类型:

Class<?> a = loaderA.loadClass("com.example.User");
Class<?> b = loaderB.loadClass("com.example.User");

System.out.println(a == b); // false

这会影响:

a.isAssignableFrom(b); // false

以及强制类型转换:

Object value = ...;
com.example.User user = (com.example.User) value;

如果 value 实际上来自另一个加载器定义的同名类,会抛出 ClassCastException。这类异常经常表现为“明明类名一样却不能转换”,根本原因是类加载器参与了类型身份。


二、类加载生命周期:加载、链接和初始化

Java 虚拟机规范将类的准备过程拆分为几个逻辑阶段:

flowchart LR
    A[字节来源] --> B[加载 Loading]
    B --> C[验证 Verification]
    C --> D[准备 Preparation]
    D --> E[解析 Resolution]
    E --> F[初始化 Initialization]
    F --> G[可被使用]
    E -.可延迟.-> F

需要注意,规范上的“链接”包括:

Linking=Verification+Preparation+Resolution\text{Linking} = \text{Verification} + \text{Preparation} + \text{Resolution}

但解析可以延迟到第一次使用相关符号时,不要求在验证和准备之后立即完成。因此工程上常见的实际顺序可能是:

加载
→ 验证
→ 准备
→ 初始化
→ 某次执行指令时才解析具体符号

也可能是:

加载
→ 验证
→ 准备
→ 解析
→ 初始化

2.1 加载:从字节流到类定义

加载阶段至少要完成三件事:

  1. 通过类的二进制名称找到或生成字节流;
  2. 根据字节流构造类的内部表示;
  3. 创建该类对应的 Class 对象。

“找到字节流”不等于“从 .class 文件读取”。字节可以来自:

  • 文件系统;
  • JAR 或模块;
  • 网络;
  • 数据库;
  • 加密资源;
  • 动态生成器;
  • 运行时拼接的字节数组。

自定义类加载器通常通过 defineClass 把字节数组定义为类:

protected final Class<?> defineClass(
        String name,
        byte[] b,
        int off,
        int len
)

defineClass 不只是“把数组包装成对象”。JVM 仍会检查 class file 的格式、版本、名称和类型约束。伪造或损坏的字节数组可能导致:

  • ClassFormatError
  • UnsupportedClassVersionError
  • NoClassDefFoundError
  • VerifyError
  • SecurityException 或模块访问相关异常

其中,类加载器返回的二进制名称必须与 class file 中声明的名称一致,否则通常会出现 NoClassDefFoundError 或相关格式错误。

2.2 链接:验证、准备、解析

验证

验证确保 class file 满足 JVM 的格式和类型安全要求,后文会详细说明。

准备

准备为类变量分配存储,并设置默认值。这里的“类变量”主要指 static 字段。

class Config {
    static int number = 42;
    static final int CONSTANT = 42;
    static final String TEXT = "hello";
}

准备阶段之后:

  • Config.number 的初始值是 0
  • Config.CONSTANTConfig.TEXT 如果是编译期常量,可能已经写入常量值;
  • number = 42 属于初始化阶段执行的 putstatic,不是准备阶段执行。

可以把它抽象为:

准备阶段:
    static int number      = 0

初始化阶段执行 <clinit>:
    number = 42

编译期常量存在一个重要边界:

class Constants {
    static final int N = 42;
    static final String S = "abc";

    static {
        System.out.println("initialized");
    }
}

下面的代码读取常量时,通常不需要触发 Constants 初始化:

System.out.println(Constants.N);
System.out.println(Constants.S);

因为常量值可能已经被编译器内联到使用方字节码中。相反,下面的字段不是编译期常量:

static final Integer BOXED = 42;
static final int RUNTIME = Integer.parseInt("42");

读取它们通常需要初始化 Constants

解析

class file 中的方法调用、字段访问和类型引用通常先保存为常量池中的符号引用,例如:

Methodref  #18
Fieldref   #23
Class      #31

解析阶段将符号引用转换为可以直接或间接定位运行时实体的引用。例如:

service.run();

编译后可能对应:

invokeinterface #某个常量池索引

这个索引不是某个固定内存地址,而是对“类名、方法名、描述符”等符号信息的引用。

解析必须遵守访问控制、继承关系和接口规则。例如,调用方没有权限访问目标方法时,可能出现:

  • IllegalAccessError
  • NoSuchMethodError
  • NoSuchFieldError
  • IncompatibleClassChangeError

这些错误常常不是编译阶段发现的,因为运行时实际链接到的类可能与编译时使用的类不同。

2.3 初始化:执行 <clinit>

初始化阶段执行类初始化方法 <clinit><clinit> 不是 Java 源码中可以直接书写的方法,而是编译器根据以下内容合成的:

  • 静态字段初始化表达式;
  • 静态代码块。

例如:

class Example {
    static int value = create();

    static {
        value += 1;
    }

    static int create() {
        return 41;
    }
}

逻辑上接近:

static void <clinit>() {
    value = create();
    value += 1;
}

最终 value42

初始化通常由以下主动使用触发:

  • 执行 new Example();
  • 读取或写入非编译期常量的静态字段;
  • 调用类的静态方法;
  • 反射 API 主动初始化类;
  • 某些 JVM 启动路径主动初始化类。

以下行为通常不会主动初始化目标类:

  • Example.class;
  • Class.forName("Example", false, loader);
  • 读取编译期常量;
  • 创建数组类型,例如 Example[]
  • 加载但不主动使用该类。

示例:

public class InitDemo {
    static class Target {
        static int value = init();

        static int init() {
            System.out.println("Target initialized");
            return 10;
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("1");
        Class<?> c = Class.forName(
                InitDemo.Target.class.getName(),
                false,
                InitDemo.class.getClassLoader()
        );
        System.out.println("2: " + c.getName());

        System.out.println("3: " + Target.value);
    }
}

典型输出:

1
2: InitDemo$Target
Target initialized
3: 10

Class.forName(name, false, loader) 的第二个参数明确要求“不初始化”。如果改成 true,加载、链接完成后会立即触发初始化。


三、初始化的并发语义和失败路径

类初始化不是简单的无锁函数调用。JVM 必须保证同一个类的初始化具有以下性质:

  • 同一个类不会被多个线程并发执行多次初始化;
  • 一个线程初始化期间,其他需要该初始化结果的线程必须等待;
  • 初始化失败后,后续主动使用通常得到 NoClassDefFoundError,而不是无限重试;
  • 初始化一个类前,必须先初始化其直接父类,但接口的初始化规则不同,不能简单理解为“先初始化所有接口”。

示例:

class Parent {
    static {
        System.out.println("Parent");
    }
}

class Child extends Parent {
    static {
        System.out.println("Child");
    }
}

执行:

new Child();

典型顺序:

Parent
Child

如果 <clinit> 抛出异常:

class Broken {
    static {
        if (true) {
            throw new RuntimeException("boom");
        }
    }
}

第一次主动使用可能得到:

ExceptionInInitializerError

后续再次主动使用同一个失败的类,通常得到:

NoClassDefFoundError: Could not initialize class Broken

这两个异常表达不同阶段:

  • ExceptionInInitializerError:本次初始化执行过程中抛出了异常;
  • NoClassDefFoundError:JVM 记录了该类初始化失败,后续使用无法完成。

3.1 初始化安全发布的边界

类初始化锁可以保证静态初始化动作的一致性,例如:

class Holder {
    static final Service INSTANCE = new Service();
}

当其他线程成功观察到 Holder.INSTANCE 时,通常可以依赖类初始化提供的安全发布语义。

但这不意味着所有静态可变状态都自动线程安全:

class State {
    static int counter;
}

类初始化结束后,多个线程对 counter++ 的并发修改仍然存在数据竞争。类初始化保证的是初始化阶段的可见性和一次性,不是后续业务操作的原子性。


四、双亲委派:规则、动机和真实边界

4.1 双亲委派的典型算法

常见类加载器的 loadClass 逻辑可以抽象为:

Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {

    Class<?> c = findLoadedClass(name);
    if (c == null) {
        if (parent != null) {
            try {
                c = parent.loadClass(name, false);
            } catch (ClassNotFoundException ignored) {
                // 继续尝试当前加载器
            }
        } else {
            c = bootstrapLoadClass(name);
        }

        if (c == null) {
            c = findClass(name);
        }
    }

    if (resolve) {
        resolveClass(c);
    }
    return c;
}

流程是:

  1. 先查当前加载器是否已经定义过;
  2. 有父加载器则委托父加载器;
  3. 父加载器找不到时,当前加载器调用 findClass
  4. resolve=true 时请求解析。

这就是通常所说的“先父后子”或“双亲委派”。

4.2 Java 25 中的内置加载器层次

典型 JDK 运行环境包含:

Bootstrap Class Loader
        ▲
Platform Class Loader
        ▲
System/Application Class Loader
        ▲
业务自定义 ClassLoader

这里的方向表示“子加载器的 parent 指向父加载器”。

  • Bootstrap Class Loader:由 JVM 原生实现,负责加载核心运行时类;在 Java 代码中通常表现为 null
  • Platform Class Loader:负责加载平台模块中的类;
  • System/Application Class Loader:负责应用类路径或模块路径中的应用类;
  • 自定义加载器:可从特殊来源加载类。

不要把 Bootstrap Loader 当作一个普通的 Java ClassLoader 实例。下面的结果通常是:

System.out.println(Object.class.getClassLoader()); // null
System.out.println(String.class.getClassLoader()); // null

null 在这里代表 Bootstrap Loader,而不是“这些类没有加载器”。

可以查看加载器关系:

public class LoaderDemo {
    public static void main(String[] args) {
        ClassLoader app = LoaderDemo.class.getClassLoader();
        System.out.println("app      = " + app);
        System.out.println("platform = " + app.getParent());
        System.out.println("bootstrap= " + Object.class.getClassLoader());
    }
}

具体名称和输出格式属于 JDK 实现细节,但加载层次的职责是稳定的。

4.3 双亲委派保护了什么

假设应用目录中放置了一个伪造的:

java.lang.String

如果应用类加载器可以优先加载自己的版本,应用就可能替换核心类,破坏 JVM 对核心类型的假设。父加载器优先使核心类先由 Bootstrap Loader 定义,从而避免普通应用类路径覆盖核心平台类。

双亲委派还带来一致性:

java.lang.String

通常由同一个上层加载器定义,应用中的所有代码看到的是同一个 String 类型。

4.4 双亲委派不是 JVM 唯一强制的加载算法

需要区分:

  • 规范保证:类身份由二进制名称和定义加载器决定;类加载过程必须满足类型安全和加载约束;
  • JDK 常见实现:内置类加载器通常采用父优先;
  • 工程算法:Tomcat、OSGi、插件系统等可能使用子优先、局部可见性或多层加载器。

自定义加载器可以覆盖 loadClass 改变委派顺序,也可以直接调用 findClass。但这会带来版本冲突风险。

例如,应用有:

api.jar  -> com.example.api.Service
plugin.jar -> com.example.api.Service

如果插件加载器和应用加载器分别定义 Service,插件实现类可能无法转换为应用期待的接口:

ClassCastException:
com.example.PluginImpl cannot be cast to com.example.api.Service

类名相同不够,定义加载器也必须满足类型身份要求。

4.5 类加载器约束:为什么参数类型可能无法传递

假设方法签名包含:

void accept(com.example.Message message)

调用方和被调用方必须对 com.example.Message 达成兼容的加载器约束。若两个加载器分别定义了不同版本的 Message,方法链接可能失败,或者对象在调用边界无法转换。

这也是插件系统常把稳定 API 放在父加载器可见位置的原因:

父加载器:API 接口和共享 DTO
子加载器:插件实现和插件私有依赖

但共享 DTO 也会形成版本耦合。若插件必须携带自己的 DTO 版本,就不能简单依赖父优先,需要明确隔离边界和转换协议。


五、字节码指令、常量池和方法描述符

5.1 JVM 是基于操作数栈的虚拟机

JVM 字节码通常使用局部变量表和操作数栈,而不是直接以 Java 源码中的变量名执行。

static int add(int a, int b) {
    return a + b;
}

可以使用:

javac --release 25 Demo.java
javap -c -v Demo

得到的指令形态通常类似:

0: iload_0
1: iload_1
2: iadd
3: ireturn

逐步解释:

  1. iload_0:把局部变量表第 0 项压入操作数栈;
  2. iload_1:把第 1 项压入栈;
  3. iadd:弹出两个 int,计算后把结果压回栈;
  4. ireturn:弹出结果并返回。

状态可以写成:

初始局部变量: [a, b]
初始操作数栈: []

iload_0
栈: [a]

iload_1
栈: [a, b]

iadd
栈: [a + b]

ireturn
返回: a + b

字节码验证器正是利用这种“每条指令前后的局部变量类型和操作数栈类型”检查控制流是否安全。

5.2 方法描述符比 Java 方法签名更底层

例如:

int add(int a, int b)

对应的方法描述符:

(II)I

含义是:

参数:int、int
返回:int

对象类型使用:

Ljava/lang/String;

数组类型使用:

[I
[Ljava/lang/String;

因此:

String join(String a, String b)

对应:

(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

常量池中的方法引用通常由以下信息定位:

所有者类型 + 方法名 + 描述符

这也是为什么方法重载可以共存:重载方法具有不同的参数描述符。

5.3 常见调用指令

不同方法调用语义对应不同字节码指令:

  • invokestatic:静态方法;
  • invokevirtual:普通类实例方法的虚调用;
  • invokeinterface:接口方法调用;
  • invokespecial:构造器、私有方法和显式父类调用等;
  • invokedynamic:动态调用点,由引导方法决定调用目标。

invokedynamic 不等于“反射”。它是一种字节码级调用机制:常量池提供调用点信息,JVM 执行引导方法建立 CallSite,之后调用点可以指向一个 MethodHandle。Lambda 表达式和字符串拼接等语言特性可能使用它,但具体生成策略属于编译器和 JDK 实现。


六、字节码验证:它验证什么,如何验证

6.1 验证的目标

验证阶段的核心目标是:在执行不可信或未完全信任的 class file 前,保证其不会以非法方式使用 JVM 运行时结构。

验证不是业务正确性检查,也不是检查代码是否没有死循环。它主要检查:

  1. class file 结构是否合法;
  2. 常量池引用是否指向正确类型的常量;
  3. 类是否具有合法的父类、接口和访问标志;
  4. 字段和方法描述符是否合法;
  5. 字节码指令是否满足操作数栈约束;
  6. 控制流合并点的类型是否一致;
  7. 访问权限和继承关系是否满足规则;
  8. 方法返回类型、异常处理表和局部变量使用是否一致。

验证通过不代表业务逻辑正确:

while (true) {
    // 仍然是合法字节码
}

也不代表没有内存泄漏、死锁、SQL 注入或逻辑漏洞。验证只针对 JVM 执行模型的结构和类型安全。

6.2 操作数栈的形式化检查

可以把一条字节码指令抽象为状态转换:

(Locals,Stack)(Locals,Stack)(\mathit{Locals}, \mathit{Stack}) \rightarrow (\mathit{Locals}', \mathit{Stack}')

例如 iadd 要求执行前栈顶至少有两个 int

(Sintint)(Sint)(\mathit{S} \cdot int \cdot int) \rightarrow (\mathit{S} \cdot int)

其中:

  • S 表示栈中更低位置的内容;
  • int 表示 JVM 验证器意义上的整数类型;
  • 指令执行前必须有两个整数;
  • 执行后两个整数被消费,产生一个整数。

如果字节码试图在空栈上执行 iadd,或者栈顶是引用而不是整数,验证器可以拒绝它。

再看返回:

ireturn:要求栈顶是 int,且方法描述符返回 int
areturn:要求栈顶是引用,且引用类型满足返回类型约束
return :要求方法返回 void

因此,下面的 Java 源码虽然无法正常编译,但如果某个字节码生成器错误地产生了等价字节码,验证阶段会拒绝:

int broken() {
    return "text";
}

这类失败通常表现为:

VerifyError

6.3 控制流合并和栈映射帧

考虑:

static int choose(boolean flag) {
    if (flag) {
        return 1;
    }
    return 2;
}

控制流图有两个分支,但两个分支都返回 int,因此每条路径都满足方法返回约束。

再看更复杂的结构:

static Object choose(boolean flag) {
    Object value;
    if (flag) {
        value = "text";
    } else {
        value = new Object();
    }
    return value;
}

两个分支在合并点产生的类型分别是:

java.lang.String
java.lang.Object

验证器需要计算一个合法的合并类型,这里可以是:

java.lang.Object

Java 6 以后,class file 通常包含 StackMapTable 属性,编译器或字节码生成器提供关键控制流位置的局部变量表和操作数栈类型信息。验证器使用这些栈映射帧配合数据流分析,避免对所有路径进行昂贵的重复推导。

对于字节码增强工具,常见失败原因包括:

  • 跳转目标的栈状态不一致;
  • 插入指令后没有更新栈映射帧;
  • try/catch 范围和异常处理表不匹配;
  • 局部变量槽位类型错误;
  • 修改方法描述符但未同步调用方;
  • 把构造器对象在 <init> 前错误地当作普通引用使用。

这些错误不是“业务运行异常”,而是类定义阶段或第一次验证/解析时的 VerifyErrorClassFormatError 等链接错误。

6.4 验证和类初始化的先后关系

JVM 不能先执行 <clinit>,再判断普通方法字节码是否有效。通常必须先完成足以保证执行安全的验证和链接步骤。

因此:

class BadBytecode {
    static {
        System.out.println("side effect");
    }
}

即使静态初始化块很简单,只要 class file 本身不满足验证要求,也不能依靠初始化块“补救”验证失败。

这也解释了一个常见现象:

类能被找到,但在首次使用时突然 VerifyError

可能原因是:

  • 加载阶段只读取并定义了字节流;
  • 某些链接动作延迟;
  • 首次调用或首次访问触发了相关验证、解析或初始化路径。

七、类加载失败、链接失败和初始化失败的区别

不同阶段对应的失败类型不同,诊断时不能只看“找不到类”。

阶段 典型失败 含义
加载 ClassNotFoundException 主动调用 loadClass 时找不到
加载/定义 NoClassDefFoundError 运行时需要某类,但定义无法获得或初始化已失败
格式检查 ClassFormatError class file 结构不合法
版本检查 UnsupportedClassVersionError 当前 JVM 不支持该 class file 版本
验证 VerifyError 字节码未通过类型和控制流验证
解析 NoSuchMethodErrorNoSuchFieldError 符号引用无法链接到目标
访问检查 IllegalAccessError 链接时访问权限不满足
初始化 ExceptionInInitializerError <clinit> 执行抛出异常
后续初始化 NoClassDefFoundError 该类此前初始化失败

ClassNotFoundExceptionNoClassDefFoundError 也不能简单认为是一回事:

Class.forName("missing.Type");

这是调用方主动请求加载,常见结果是受检异常 ClassNotFoundException

而:

SomeType.method();

如果 JVM 在运行时无法找到 SomeType,通常会抛出错误类 NoClassDefFoundError,因为这是已编译程序执行过程中无法满足的类依赖。


八、动态代理:运行时生成的类如何参与调用

8.1 JDK 动态代理的能力边界

JDK 动态代理由:

java.lang.reflect.Proxy
java.lang.reflect.InvocationHandler

组成。它的基本条件是:

  • 代理目标类型是接口;
  • 代理类实现这些接口;
  • 接口方法调用统一进入 InvocationHandler.invoke
  • 代理对象由 Proxy.newProxyInstance 创建。

它不能直接为普通类生成子类代理。需要类代理时,通常要使用其他字节码生成方案,但这属于第三方或框架能力,不应与 JDK Proxy 混同。

8.2 一个可运行的端到端示例

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class ProxyDemo {
    interface Greeting {
        String hello(String name);

        default String language() {
            return "Java";
        }
    }

    public static void main(String[] args) {
        InvocationHandler handler = new LoggingHandler();

        Greeting proxy = (Greeting) Proxy.newProxyInstance(
                ProxyDemo.class.getClassLoader(),
                new Class<?>[]{Greeting.class},
                handler
        );

        System.out.println(proxy.hello("Ada"));
        System.out.println(proxy.language());
        System.out.println(proxy.toString());
    }

    static final class LoggingHandler implements InvocationHandler {
        @Override
        public Object invoke(
                Object proxy,
                Method method,
                Object[] args
        ) throws Throwable {
            System.out.println("invoke: " + method);

            if (method.getName().equals("hello")) {
                String name = (String) args[0];
                return "Hello, " + name;
            }

            if (method.getDeclaringClass() == Object.class
                    && method.getName().equals("toString")) {
                return "GreetingProxy";
            }

            if (method.isDefault()) {
                throw new UnsupportedOperationException(
                        "default method is also dispatched to handler in this example"
                );
            }

            throw new UnsupportedOperationException(method.toString());
        }
    }
}

典型输出类似:

invoke: public abstract java.lang.String ProxyDemo$Greeting.hello(java.lang.String)
Hello, Ada
invoke: public default java.lang.String ProxyDemo$Greeting.language()
Exception in thread "main" java.lang.UnsupportedOperationException: ...

这个例子故意体现一个容易误解的事实:接口的 default 方法并不意味着代理对象一定绕过 InvocationHandler。JDK 动态代理对接口方法调用的分派仍会进入处理器;如果处理器希望执行默认实现,就必须显式设计对应逻辑。不能仅因为方法有 default 实现,就假设代理会自动执行它。

可以先删除 proxy.language(),观察 hellotoString 的调用路径。

8.3 InvocationHandler 的参数含义

对:

proxy.hello("Ada")

处理器通常收到:

invoke(
    proxy对象,
    Greeting.class.getMethod("hello", String.class),
    new Object[]{"Ada"}
)

其中:

  • proxy 是生成的代理对象;
  • method 是接口方法对应的 Method
  • args 是实参数组;无参数方法时可能为 null

基本类型参数会发生装箱:

int getCount()

args 中会表现为 Integer;返回时处理器必须返回可以拆箱为目标基本类型的对象,否则可能出现:

NullPointerException
ClassCastException

例如代理方法返回 int,处理器返回 null,代理生成的字节码需要拆箱时就会失败。

8.4 异常传播规则

接口方法:

interface Repository {
    User find(long id) throws IOException;
}

处理器可以直接抛出 IOException。但如果处理器抛出接口声明之外的受检异常,例如:

throw new SQLException();

代理方法无法按接口契约直接声明该异常,JDK 通常会将其包装为:

UndeclaredThrowableException

因此处理器中的异常策略必须与接口方法声明匹配:

try {
    return target.find(id);
} catch (IOException e) {
    throw e; // 接口声明了,可以直接传播
} catch (RuntimeException | Error e) {
    throw e; // 非受检异常通常直接传播
} catch (Exception e) {
    throw new UndeclaredThrowableException(e);
}

实际代理框架通常还会处理目标异常、反射包装和统一日志,但底层边界仍由接口声明决定。

8.5 代理类的加载器和可见性

Proxy.newProxyInstance 的第一个参数非常重要:

Proxy.newProxyInstance(
    loader,
    interfaces,
    handler
)

生成的代理类必须由该加载器或相关可见范围加载接口。如果接口来自不同的隔离加载器,可能出现:

  • 接口对代理加载器不可见;
  • 两个同名接口不是同一个类型;
  • 代理无法实现接口;
  • 模块未导出或未开放导致访问错误。

代理类的类型身份也遵循:

(proxy binary name,proxy defining loader)(\text{proxy binary name}, \text{proxy defining loader})

代理对象只能被其实现接口的兼容类型接收。不同加载器生成的“看起来相同”的接口,仍然不能互换。

8.6 代理调用的字节码路径

一次典型调用大致经过:

sequenceDiagram
    participant C as 调用方
    participant P as 代理对象
    participant H as InvocationHandler
    participant T as 目标对象

    C->>P: invoke interface method
    P->>H: invoke(proxy, Method, args)
    H->>T: 反射或 MethodHandle 调用目标
    T-->>H: 返回值或异常
    H-->>P: 返回值或异常
    P-->>C: 返回值或包装后的异常

代理类自身也是一个运行时类。它有方法表、接口信息、字段和字节码。代理方法通常负责:

  1. 收集参数;
  2. 找到对应的 Method
  3. 调用 InvocationHandler.invoke
  4. 对返回值拆箱或强制转换;
  5. 按接口声明传播或包装异常。

JDK 实现生成代理类的具体字节码布局、缓存策略和类名格式属于实现细节,不应依赖诸如 $Proxy0 这样的名称。

可以通过系统属性查看生成的代理类文件:

java -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true ProxyDemo

该属性属于 JDK 实现能力,适合调试,不应作为业务逻辑依赖。生成文件的位置和命名可能随实现变化。


九、反射、MethodHandle 与动态代理的区别

动态代理常被误解为“反射调用的另一种写法”。三者的层次不同:

9.1 反射 Method.invoke

Method method = Service.class.getMethod("run", String.class);
Object result = method.invoke(target, "x");

特点:

  • API 直观;
  • 参数以 Object[] 形式传递;
  • 基本类型存在装箱和拆箱;
  • 目标异常通常被包装在 InvocationTargetException 中;
  • 每次调用都携带较多通用检查语义。

9.2 MethodHandle

MethodHandle handle = MethodHandles.lookup()
        .findVirtual(Service.class, "run",
                MethodType.methodType(String.class, String.class));

String result = (String) handle.invokeExact(target, "x");

特点:

  • 具有明确的 MethodType
  • 可以组合、绑定参数和适配类型;
  • invokeExact 要求调用点类型严格匹配;
  • invoke 允许一定程度的运行时适配;
  • JIT 更容易识别某些稳定调用链,但不能据此承诺固定性能。

9.3 JDK 动态代理

动态代理解决的是:

为一组接口生成一个对象,使所有接口调用进入统一处理器

它通常是 AOP、RPC 客户端、事务边界和装饰逻辑的基础。代理本身不是目标调用机制;处理器内部仍可以选择:

  • 反射;
  • MethodHandle
  • 直接调用;
  • RPC 编解码;
  • 缓存或重试。

因此:

动态代理 = 运行时生成的接口实现 + 调用分派
反射     = 运行时元数据调用 API
MethodHandle = JVM 方法句柄调用模型

它们可以组合,但不是同一个概念。


十、类卸载、元空间和类加载器泄漏

类定义的生命周期与对象实例生命周期不同。一个普通对象没有强引用后可能被垃圾回收,但类定义能否卸载,还取决于定义它的类加载器是否可以回收,以及该加载器定义的类是否仍被引用。

典型插件卸载路径:

插件类加载器
    ├── 插件类定义
    ├── 插件线程上下文类加载器
    ├── 静态字段
    ├── 注册表回调
    └── ThreadLocal / 线程池任务

只要还有强引用从 GC Roots 指向其中任意对象,再间接指向插件类加载器,整个加载器及其类元数据就可能无法回收。

常见泄漏路径包括:

  • 容器线程池的线程仍保存插件 ThreadLocal
  • Thread.contextClassLoader 指向插件加载器;
  • 全局单例注册插件对象;
  • JDBC、日志、驱动或 SPI 注册未注销;
  • 定时任务仍捕获插件类;
  • 静态缓存保存插件类、MethodMethodHandle
  • JNI 或 native 代码保存引用。

类元数据通常位于元空间,但“元空间可回收”不能简单理解成“类用完立即释放”。至少需要:

  1. 相关类加载器不可达;
  2. 该加载器定义的类没有被其他活动结构保持;
  3. JVM 的类卸载条件满足;
  4. 垃圾收集周期实际处理了该加载器。

生产上应把“插件卸载”设计为完整生命周期:

停止任务
→ 停止线程
→ 清理 ThreadLocal
→ 注销全局资源
→ 移除监听器
→ 清空缓存
→ 丢弃加载器引用
→ 观察类卸载和元空间变化

只执行最后一步:

pluginLoader = null;

通常不足以卸载插件。


十一、诊断类加载和字节码问题

11.1 查看类加载日志

启动时可以打开类加载日志:

java -Xlog:class+load=info -cp out Main

更详细的调试级别:

java -Xlog:class+load=debug -cp out Main

日志通常能够帮助回答:

  • 哪个类加载器加载了目标类;
  • 类来自哪个 JAR、模块或路径;
  • 同名类是否被重复定义;
  • 代理类或框架生成类何时出现。

输出格式和详细字段属于 JDK 实现,不能把具体日志文本当作规范保证。

11.2 查看运行中的类加载器

对于运行中的进程:

jcmd <pid> VM.classloaders

可以查看类加载器层次和相关信息。若要确认某个类的来源,还可以:

jcmd <pid> VM.classloaders show-classes=true

具体可用选项应以当前 JDK 25 的 jcmd <pid> help 输出为准。诊断命令通常需要同一用户权限,并且目标 JVM 需要允许本地诊断连接。

11.3 使用 JFR 和 JMC

JDK Flight Recorder 可以记录类加载、类定义和运行时行为;JDK Mission Control 用于分析这些记录。典型做法是:

jcmd <pid> JFR.start name=class-debug settings=profile duration=60s filename=class-debug.jfr

然后用 JMC 打开 class-debug.jfr,重点查看:

  • 类加载数量和时间;
  • 类定义是否持续增长;
  • 类加载器数量;
  • 长时间存活的自定义加载器;
  • 类加载是否伴随异常或启动延迟。

JFR 事件启用情况、事件阈值和开销取决于 JDK 版本及录制配置。不要仅凭一次短时录制就断言不存在类加载器泄漏,因为卸载通常需要后续 GC 和足够的生命周期。

11.4 查看代理和字节码

调试代理时,应同时检查三层:

  1. 接口层:代理实现了哪些接口,接口由哪个加载器定义;
  2. 处理器层Method、参数、返回值和异常是否匹配;
  3. 字节码层:生成类的方法描述符、异常表和栈映射是否正确。

可以打印:

System.out.println(proxy.getClass());
System.out.println(proxy.getClass().getClassLoader());

for (Class<?> i : proxy.getClass().getInterfaces()) {
    System.out.println(i + " <- " + i.getClassLoader());
}

如果出现:

ClassCastException
IllegalArgumentException: ... is not an interface
UndeclaredThrowableException
VerifyError

应分别从类型身份、接口约束、异常声明和字节码验证方向排查,而不是统称为“代理失效”。


十二、几个关键反例

12.1 反例:类名相同就是同一个类

错误推理:

两个 Class.getName() 都是 com.example.Plugin,所以可以互转。

正确条件是:

binary name 相同
且 defining loader 相同

加载器不同,类型就可能不同。

12.2 反例:调用 loadClass 就完成了初始化

Class<?> c = loader.loadClass("Example");

这通常只保证类被加载,不能推出静态代码块已经执行。初始化需要主动使用,或使用明确要求初始化的 API。

12.3 反例:验证通过代表程序没有问题

验证器不检查:

  • 业务结果;
  • 锁顺序;
  • 数据库正确性;
  • 资源泄漏;
  • 算法复杂度;
  • 无限循环。

它只保证字节码符合 JVM 执行模型的安全约束。

12.4 反例:双亲委派阻止所有依赖冲突

双亲委派只能解决父优先场景下的一部分类型一致性问题。自定义加载器、模块边界、线程上下文类加载器、服务加载和子优先策略仍可能产生多个版本并存。

12.5 反例:JDK 动态代理可以代理任意类

JDK Proxy 面向接口。下面会失败:

class ConcreteService {
    public void run() {}
}

Proxy.newProxyInstance(
        ConcreteService.class.getClassLoader(),
        new Class<?>[]{ConcreteService.class},
        (p, m, a) -> null
);

因为 ConcreteService 不是接口。普通类代理需要生成子类或修改字节码,这涉及不同的工具链、构造器限制、final 方法限制和模块访问边界。


十三、把完整链路串起来

以 JDK 动态代理为例,一次代理对象的使用可以按以下顺序理解:

1. Proxy.newProxyInstance 接收接口数组和类加载器
2. JDK 检查接口合法性、可见性和重复方法兼容性
3. 生成或复用代理类的字节码
4. 使用指定加载器定义代理类
5. JVM 验证代理 class file
6. JVM 准备代理类的字段和方法元数据
7. 解析实际调用中需要的类型和方法引用
8. 创建代理实例
9. 调用代理方法
10. 代理字节码进入 InvocationHandler.invoke
11. 处理器调用目标、返回结果或抛出异常
12. 代理按接口方法描述符处理返回值和异常

其中任何一层都可能失败:

  • 接口不是接口:代理创建阶段失败;
  • 接口不可见:加载或定义阶段失败;
  • 生成字节码不合法:验证阶段失败;
  • 返回值类型错误:代理方法执行阶段失败;
  • 未声明受检异常:包装为 UndeclaredThrowableException
  • 目标初始化失败:初始化阶段失败;
  • 代理加载器长期被线程或缓存引用:后续无法卸载。

这条链路也解释了为什么类加载、字节码和动态代理不能孤立理解:动态代理是字节码生成和类加载的应用;反射和 MethodHandle 是代理处理器可能采用的调用手段;元空间和垃圾回收则决定大量动态类定义是否会形成运行时压力。


十四、规范保证、实现细节与工程判断

最后需要明确三类结论的边界。

JVM 规范层面保证的内容包括:

  • class file 有规定的结构;
  • 类的身份包含二进制名称和定义加载器;
  • 字节码必须满足验证和链接规则;
  • 类初始化具有规定的触发和并发语义;
  • 方法描述符和调用指令具有明确语义。

OpenJDK 等常见实现提供的内容包括:

  • Bootstrap、Platform、Application 的典型加载器层次;
  • -Xlog:class+load 日志;
  • JFR 类加载事件;
  • JDK 动态代理的具体缓存和生成方式;
  • 代理类保存属性及其输出格式。

工程上需要自行约束的内容包括:

  • 插件类加载器的隔离和卸载协议;
  • 代理接口的异常契约;
  • 生成字节码时的栈映射帧维护;
  • 共享 API 和 DTO 的版本边界;
  • 动态类数量、元空间容量和诊断方案。

掌握这三层边界后,遇到“类明明存在却加载失败”“同名类无法转换”“首次调用才出现 VerifyError”“代理异常被包装”“元空间持续增长”等问题时,就可以先定位生命周期阶段,再根据类加载器、符号解析、验证状态和调用契约逐层排查,而不是把所有故障都归因于“JVM 缓存”或“双亲委派”。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。