Skip to content

类加载子系统:加载、链接、初始化

本文是 JVM & GC 系统学习系列的 L1 入门篇。前置:JVM 总览与运行时数据区。 相关文章:双亲委派模型Tomcat 如何打破双亲委派

你写的 .java 编译成 .class 之后,文件还躺在磁盘上,什么都没发生。 JVM 要真正用它,得先把字节码读进内存、检查合法不合法、给静态变量分内存、跑 <clinit>,一整套流程走完,这个类才算"可用"。 这套流程由类加载子系统负责,是 JVM 的进门安检 + 组装车间。

一个最常见的困惑先摆出来:下面的代码输出什么?

java
public class Singleton {
    static Singleton instance = new Singleton();
    static int a;
    static int b = 0;

    private Singleton() {
        a++;
        b++;
    }

    public static void main(String[] args) {
        System.out.println(Singleton.a); // 1
        System.out.println(Singleton.b); // 0  <-- 为什么不是 1?
    }
}

答案是 10。看不懂没关系,读完"准备"和"初始化"两个阶段你就明白了。

类的生命周期:五个阶段各干什么

类从被加载到虚拟机内存开始,到卸载为止,完整生命周期是:加载、验证、准备、解析、初始化、使用、卸载。前五个阶段由类加载子系统负责,其中验证、准备、解析合称链接

mermaid
flowchart LR
    A[加载] --> B[验证]
    B --> C[准备]
    C --> D[解析]
    D --> E[初始化]
    style A fill:#e8f4fd
    style C fill:#fff3e0
    style E fill:#e8f8e8

顺序是确定的,但不是严格"做完一个再做下一个":解析阶段在某些场景可以推迟到初始化之后才逐个进行(运行期绑定,支持 Java 的多态)。

加载做三件事:通过全限定名拿到字节流(从 jar、网络、内存都行)、把静态存储结构转成方法区的运行时数据结构、在堆里生成一个 Class 对象,作为访问方法区数据的入口。注意"加载"是"类加载"大流程里的一小步,别混淆。

验证是安检门。检查字节码格式对不对、元数据合不合法(有没有父类、是否继承了 final 类)、字节码指令会不会执行危险操作(跳转到不存在的指令地址)、符号引用能否解析。为什么要这么严?因为 .class 完全可以手工构造,不检查的话恶意字节码能让 JVM 崩溃或越界读写内存。

准备给静态变量分配内存并设零值。注意是零值,不是你写的值:

java
public static int a = 123;

准备阶段结束后 a = 0,不是 123。123 的赋值发生在初始化阶段。有一个例外:public static final int a = 123 这种编译期常量,准备阶段就直接赋 123,因为它进了常量池,运行时不可能变。

解析把常量池里的符号引用(一串描述目标的字符串,比如 java/lang/Object)替换成直接引用(内存地址或偏移量)。可以理解为把"收件人姓名"翻译成"门牌号"。

初始化执行类构造器 <clinit> 方法。这个方法不是你写的,是编译器把所有静态变量的赋值语句静态代码块按源码顺序合并生成的。开头的谜题答案在这里:<clinit> 等价于:

java
// 按源码顺序:先 new,再执行 b = 0
instance = new Singleton(); // 构造器里 a=1, b=1
a 赋值已在构造器中体现;    // 此时 a=1, b=1
b = 0;                     // b 被覆盖回 0

静态变量按源码顺序执行赋值,b = 0 写在 new 后面,所以构造器里加的 1 被冲掉了。

<clinit> 还有一个特性:JVM 保证它在多线程下只被执行一次,且加锁。这也是"静态内部类单例"线程安全的原因。

什么时候触发初始化

虚拟机规范规定了六种主动引用会触发初始化,高频的四种:

  • new / 读写静态字段 / 调静态方法new Object()System.out.println(Foo.count)。注意读静态常量(编译期常量)不触发,因为它已经进了自己类的常量池,不需要初始化 Foo
  • 反射调用Class.forName("com.foo.Bar")。JDBC 加载驱动就靠这个。
  • 初始化子类时父类先初始化class Son extends Father,new Son 时 Father 的 <clinit> 先跑。
  • 启动类java -cp . Main,main 方法所在类。

对应的被动引用不触发初始化,三种经典情况:

java
// 1. 通过子类引用父类静态字段:只初始化 Father
System.out.println(Son.FATHER_STATIC); // Son 不初始化

// 2. 定义数组:JVM 生成数组类,元素类不初始化
Son[] sons = new Son[10];

// 3. 引用编译期常量:直接进常量池,谁都不初始化
System.out.println(Son.VERSION); // Son 里是 static final int VERSION = 1

接口的初始化规则类似,但有个差别:初始化实现类不会触发父接口初始化,只有在真正使用接口的静态变量时才初始化接口本身。

双亲委派模型:三层结构与设计动机

类加载器层级(JDK 8 及之前):

mermaid
flowchart TD
    B[Bootstrap ClassLoader<br/>C++ 实现, 加载 JAVA_HOME/lib]
    E[Extension ClassLoader<br/>加载 ext 目录]
    A[Application ClassLoader<br/>加载 classpath]
    C[自定义 ClassLoader]
    B --> E --> A --> C

工作流程一句话:收到加载请求,先委派给父加载器,父加载器反馈自己无法完成(没找到类)才自己去加载。所以 java.lang.String 永远是 Bootstrap 加载的,你自己写一个同名类放到 classpath 上也轮不到它被加载。

为什么这么设计?两个理由:

  • 安全。用户自定义的 java.lang.String 永远不会被加载成系统的 String,核心类库不被篡改。想象一下如果可以替换 String,在里面塞个后门,整个 JVM 全线失守。
  • 避免重复加载。同一个类只加载一次,程序里引用的 String 全是同一个 Class 对象。没有委派的话,各级加载器各加载一份,instanceof 判断全是 false,类型体系直接乱套。

注意一个细节:类加载器的相等性由"类 + 定义它的加载器"共同决定。同一个 .class 文件被两个不同加载器加载,得到的是两个不同的 Class,互相 instanceof 都是 false。这是很多"类转换异常"的根源。

怎么打破双亲委派

正确姿势:继承 ClassLoader重写 findClass 而不是 loadClassloadClass 里实现委派逻辑,findClass 只在委派链全部失败后才被调用。重写 findClass 不影响委派流程,只是让"自己加载"这一步多了个自定义来源。

什么时候需要打破?典型场景:

  • 热部署:新版本的类要重新加载,必须用新的加载器实例(加载器 + 类名共同决定身份,同一个加载器无法加载同名类两次)。
  • 隔离:Tomcat 每个 webapp 一个 WebappClassLoader,两个应用各依赖不同版本的同名类,互不可见。Tomcat 违反了"委派给父"的约定,优先加载自己应用里的类(JDK 9 前是先自己找,找到用自己;核心类库除外)。
  • SPI:父加载器要用子加载器加载的类,见下一节。

也有真的重写 loadClass 彻底改变委派顺序的(比如优先从网络加载实现类),但风险高:你得自己处理核心类库的委派,否则加载出来的 JVM 就是残废的。

SPI 与线程上下文类加载器

双亲委派有个先天矛盾:核心类库由 Bootstrap 加载,但 JDBC 的 DriverManager 在核心库 rt.jar 里,具体的 com.mysql.cj.jdbc.Driver 在应用的 classpath 上,Bootstrap 根本看不见它。Class.forName("com.mysql...") 这句代码在 DriverManager 里执行时,用的加载器是 Bootstrap,找不到类。

Java 的解法是线程上下文类加载器(Thread Context ClassLoader,TCCL):每个线程携带一个加载器引用,默认是 Application ClassLoader。Bootstrap 里的代码需要加载应用层类时,通过 Thread.currentThread().getContextClassLoader() 拿到它:

java
// ServiceLoader 内部简化逻辑
ClassLoader cl = Thread.currentThread().getContextClassLoader();
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, cl);

JDBC 4.0 之后甚至不用 Class.forName 了,DriverManager 的静态块里通过 SPI 机制扫描 META-INF/services/java.sql.Driver 文件,自动注册所有驱动。这就是"父加载器借助子加载器加载类",属于双亲委派的补丁而非否定。

JDK 9 模块化下的变化

JDK 9 引入模块系统后有三点主要变化:

  • Extension ClassLoader 没有了,取而代之的是 Platform ClassLoader,加载部分平台模块(java.sql 等)。
  • 类加载器不再从 jar 文件树加载,而是按模块加载,委派关系基本保留。
  • ClassLoader.getSystemClassLoader() 仍返回应用类加载器,绝大多数业务代码无感。

对使用者的实际影响很小,但"三层加载器是哪三层"要按版本答:JDK 8 是 Bootstrap / Extension / Application,JDK 9+ 是 Bootstrap / Platform / Application。

动手实操:自定义类加载器加载外部 .class

需求:磁盘上有个 Hello.class(不在 classpath 上),写一个自定义加载器加载它并调用方法,走 findClass 的正确姿势。

先准备被加载的类:

java
// /tmp/classes/Hello.java
public class Hello {
    public void say() {
        System.out.println("Hello from custom loader: " + getClass().getClassLoader());
    }
}

自定义加载器:

java
import java.nio.file.*;

public class DiskClassLoader extends ClassLoader {
    private final String classDir;

    public DiskClassLoader(String classDir) {
        // 不指定 parent 时默认委派给 Application ClassLoader,保留双亲委派
        this.classDir = classDir;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // loadClass 委派链失败后才会走到这里,只负责"从磁盘读字节码"
            byte[] bytes = Files.readAllBytes(Paths.get(classDir, name + ".class"));
            // defineClass:把字节码解析成 Class 对象,写入方法区
            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            throw new ClassNotFoundException(name, e);
        }
    }

    public static void main(String[] args) throws Exception {
        DiskClassLoader loader = new DiskClassLoader("/tmp/classes");
        // loadClass -> 委派给父 -> 父找不到 -> 回调我们的 findClass
        Class<?> clazz = loader.loadClass("Hello");
        Object obj = clazz.getDeclaredConstructor().newInstance();
        clazz.getMethod("say").invoke(obj);
        // 输出: Hello from custom loader: DiskClassLoader@xxx
    }
}

关键点:

  • loadClass 不重写,委派逻辑保留。Hello 在 classpath 上没有,委派链走完找不到,才轮到 findClass/tmp/classes 读。
  • defineClass 是最终把字节码变成 Class 的地方,它是 final 的,自定义加载器只能通过它注册类。
  • 验证一下身份:clazz.getClassLoader() 打印出来是 DiskClassLoader;如果 Hello 同时也在 classpath 上,委派链会先命中,打印的会是 AppClassLoader——这正好演示了委派优先级。

编译运行:

bash
cd /tmp/classes && javac Hello.java
cd ~/workspace && javac DiskClassLoader.java && java DiskClassLoader

想体验热替换,可以再 new 一个 DiskClassLoader 实例加载修改后的 Hello(新实例 + 新 Class,旧实例持有的旧 Class 不受影响),这就是热部署插件的基本原理。

常见误区与小结

  • 准备阶段就给静态变量赋了代码里的值——错,准备只设零值,赋值在初始化(static final 编译期常量除外)。开头谜题的 b=0 就是证据。
  • 重写 loadClass 才是自定义加载器——反了,默认重写 findClass 就够。重写 loadClass 是要彻底改委派顺序,多数场景不需要且容易出错。
  • 被动引用也触发初始化——通过子类引用父类静态字段、new 数组、读编译期常量,都不触发。拿这个判断加不加载,容易掉坑。
  • 同一个类被两个加载器加载后还能 instanceof 通过——不能,加载器身份参与类的唯一性判定。 Tomcat 隔离、OSGi 分版本都基于这一点。
  • 双亲委派被 JDK 9 废除了——没有,模块化只是调整了加载器层次(Extension 换成 Platform),委派语义保留。

小结:类加载子系统是字节码进入 JVM 的第一道关口,五阶段生命周期、初始化触发条件、双亲委派及其打破方式,构成了一条从"类怎么进来"到"怎么安全可控地进来"的完整线索。JVM 系列的内存布局讲完了"数据放哪",本篇讲"类怎么就位",下一篇讲对象就位之后如何走向死亡:可达性分析与 GC Roots 判活。

参考

参考:《深入理解 Java 虚拟机(第 3 版)》第 7 章;JLS §12.4 (Initialization of Classes and Interfaces);JDK 源码 java.lang.ClassLoaderjava.util.ServiceLoader

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1