从源码到流程解析 JVM 类加载机制

在 Java 里,.java 文件会先被编译成 .class 字节码文件,但 JVM 不能直接执行磁盘上的字节码。类加载机制要做的事,就是把字节码从外部世界带入 JVM:读取字节流、校验安全性、转换成运行时数据结构,并在需要时完成静态变量和静态代码块的初始化。

如果用一句话概括:类加载机制负责把 .class 文件变成 JVM 可以使用的 Class 对象和运行时类型信息。

这里要注意,“类加载”有广义和狭义两种说法:

  • 狭义加载:只指 Loading 阶段,也就是读取字节码并生成类的运行时结构。
  • 广义类加载:通常指加载、链接、初始化这一整套流程。

本文从宏观流程讲到 ClassLoader 源码调用链,再讲双亲委派以及如何打破双亲委派。

一、类加载的整体流程

JVM 中一个类从被读取到可以使用,通常会经历下面几个阶段:

加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载

其中,验证、准备、解析统称为链接

加载 -> 链接(验证、准备、解析) -> 初始化

1. 加载:把字节码带进 JVM

加载阶段主要做三件事:

  1. 通过类的全限定名获取这个类的二进制字节流。
  2. 将字节流代表的静态存储结构转换为方法区的运行时数据结构。
  3. 在堆中生成一个 java.lang.Class 对象,作为访问这个类元数据的入口。

也就是说,我们平时反射时拿到的 Class<?> clazz,本质上就是 JVM 暴露给 Java 层的类型入口。

Class<?> clazz = User.class;

这里的 clazz 并不是类的全部数据本身,而是一个“镜像对象”。真正的类型元数据主要存放在方法区,JDK 8 之后具体实现通常是元空间。

2. 验证:确保字节码合法、安全

验证阶段是 JVM 的安全防线之一。它会检查字节码是否符合 JVM 规范,避免恶意或错误的字节码破坏虚拟机。

常见检查包括:

  • 文件格式校验:例如魔数 0xCAFEBABE、版本号、常量池格式。
  • 元数据校验:类是否有父类、是否继承了 final 类、方法签名是否合法。
  • 字节码校验:操作数栈和局部变量表使用是否正确,跳转指令是否合法。
  • 符号引用校验:引用的类、字段、方法是否存在,访问权限是否满足。

3. 准备:给静态变量分配内存并设置默认值

准备阶段会为类变量,也就是 static 变量分配内存,并设置 JVM 默认初始值。

例如:

public class User {
    public static int count = 10;
}

在准备阶段,count 的值不是 10,而是 0。真正赋值为 10 要等到初始化阶段执行 <clinit> 方法。

但如果变量被 static final 修饰,并且是编译期常量,情况会不同:

public static final int MAX = 100;

这类常量在准备阶段就可能直接被赋值为 100,因为它的值已经写入了 ConstantValue 属性。

4. 解析:把符号引用转换为直接引用

.class 文件里保存的大多不是直接内存地址,而是符号引用。

例如一个类调用另一个类的方法,字节码里保存的可能是类似下面的信息:

com/example/UserService.login:()V

解析阶段会把这些符号引用转换为 JVM 可以直接使用的引用,比如方法表中的入口、字段偏移量、类元数据指针等。

解析不一定一次性全部完成,JVM 可以选择懒解析。也就是说,有些符号引用可能在真正使用时才解析。

5. 初始化:执行类构造器 <clinit>

初始化阶段才真正执行 Java 代码层面的静态赋值和静态代码块。

public class User {
    static int count = 10;

    static {
        count = 20;
    }
}

编译器会把静态变量赋值语句和静态代码块按照源码顺序合并成类构造器 <clinit>

初始化有几个关键特点:

  • 父类会先于子类初始化。
  • 一个类的 <clinit> 在多线程环境下会被 JVM 加锁保证只执行一次。
  • 接口初始化规则和类略有不同,接口不会因为子接口或实现类初始化而必然初始化。

常见触发初始化的场景包括:

  • new 一个对象。
  • 读取或设置类的静态字段,编译期常量除外。
  • 调用类的静态方法。
  • 使用反射访问类。
  • 初始化子类时,父类需要先初始化。
  • JVM 启动时初始化包含 main 方法的主类。

二、从源码看类是怎么被加载的

Java 层类加载的核心入口是 ClassLoader#loadClass。以 OpenJDK 8 的逻辑简化后,大致如下:

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

    synchronized (getClassLoadingLock(name)) {
        // 1. 先检查这个类是否已经被当前类加载器加载过
        Class<?> c = findLoadedClass(name);

        if (c == null) {
            try {
                // 2. 优先交给父加载器加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器找不到,继续让当前加载器尝试
            }

            // 3. 父加载器加载失败,当前加载器才调用 findClass
            if (c == null) {
                c = findClass(name);
            }
        }

        // 4. 如果需要解析,则执行链接中的解析动作
        if (resolve) {
            resolveClass(c);
        }

        return c;
    }
}

这段逻辑就是双亲委派机制的源码基础。

核心顺序是:

先查缓存 -> 委派父加载器 -> 父加载失败 -> 当前加载器 findClass -> 可选 resolve

loadClass、findClass、defineClass 的关系

这三个方法经常一起出现,但职责不一样:

方法 主要职责
loadClass 控制类加载的整体流程,默认实现包含双亲委派
findClass 当前类加载器真正查找字节码的位置,通常由自定义加载器重写
defineClass byte[] 字节码交给 JVM,转换成 Class<?> 对象

如果只想自定义“类从哪里来”,一般重写 findClass

例如从磁盘、网络、数据库或某个特殊目录读取字节码:

public class MyClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = readClassBytes(name);
        return defineClass(name, bytes, 0, bytes.length);
    }

    private byte[] readClassBytes(String name) {
        // 读取 .class 文件,转换为 byte[]
        throw new UnsupportedOperationException();
    }
}

真正让字节码进入 JVM 的方法是 defineClass

从 Java 层看,它接收一段 byte[],返回一个 Class<?>

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

从 JVM 层看,defineClass 会触发虚拟机进行类定义:校验字节码、创建内部类元数据,并生成 Java 层可见的 Class 镜像对象。

所以可以这样理解:

findClass 负责找到字节码
defineClass 负责把字节码定义成 JVM 里的类
loadClass 负责控制加载顺序和委派规则

三、双亲委派机制是什么

双亲委派机制指的是:一个类加载器收到类加载请求时,不会先自己加载,而是先把请求委派给父加载器。只有父加载器无法加载时,子加载器才会尝试自己加载。

在 Java 8 中,常见加载器层级如下:

Bootstrap ClassLoader
        ^
Extension ClassLoader
        ^
Application ClassLoader
        ^
Custom ClassLoader

对应职责如下:

加载器 实现 加载范围
Bootstrap ClassLoader C/C++ 加载 JVM 核心类库,如 Java 8 中的 rt.jar
Extension ClassLoader Java 加载 JAVA_HOME/lib/ext 下的扩展类
Application ClassLoader Java 加载应用 classpath 下的类
Custom ClassLoader Java 加载用户自定义来源的类

JDK 9 之后模块化机制引入,Extension ClassLoader 的位置逐渐被 Platform ClassLoader 替代,但“父优先”的思想仍然存在。

为什么需要双亲委派

双亲委派主要解决三个问题。

第一,避免核心类被篡改

假设我们自己写一个 java.lang.String,如果没有双亲委派,应用类加载器可能会优先加载这个伪造类,JVM 核心类型体系就会出问题。

有了双亲委派,java.lang.String 会优先交给 Bootstrap ClassLoader 加载,用户自定义版本不会覆盖核心类。

第二,避免重复加载

同一个类如果被多个加载器随意加载,会导致 JVM 里出现多份类型定义。双亲委派让基础类尽可能由上层加载器统一加载,减少重复。

第三,保证类型体系稳定

在 JVM 中,判断两个类是否相等,不只看全限定类名,还要看定义它们的类加载器。

类的唯一性 = 全限定类名 + 定义类加载器

即使两个类的包名、类名完全相同,只要由不同类加载器加载,JVM 也会认为它们是两个不同的类。

四、如何打破双亲委派机制

双亲委派的关键逻辑在 loadClass 中:

先 parent.loadClass()
再 this.findClass()

所以,如果要打破双亲委派,核心就是:重写 loadClass,改变父优先的加载顺序。

1. 遵循双亲委派:重写 findClass

推荐做法是只重写 findClass

这样不会破坏 loadClass 中的父优先逻辑,只是告诉当前类加载器:当父加载器找不到时,你应该怎么读取字节码。

public class NormalClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = readClassBytes(name);
        return defineClass(name, bytes, 0, bytes.length);
    }

    private byte[] readClassBytes(String name) {
        throw new UnsupportedOperationException();
    }
}

调用顺序仍然是:

loadClass -> parent.loadClass -> findClass -> defineClass

2. 打破双亲委派:重写 loadClass

如果希望某些类由当前加载器优先加载,就需要重写 loadClass

下面是一个简化的 child-first 类加载器:

public class ChildFirstClassLoader extends ClassLoader {

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

        synchronized (getClassLoadingLock(name)) {
            Class<?> c = findLoadedClass(name);

            if (c == null) {
                // 核心类仍然交给父加载器,避免破坏 JDK 基础类型
                if (name.startsWith("java.")) {
                    c = super.loadClass(name, false);
                } else {
                    try {
                        // 先让当前类加载器自己加载
                        c = findClass(name);
                    } catch (ClassNotFoundException e) {
                        // 当前加载器找不到,再交给父加载器
                        c = super.loadClass(name, false);
                    }
                }
            }

            if (resolve) {
                resolveClass(c);
            }

            return c;
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = readClassBytes(name);
        return defineClass(name, bytes, 0, bytes.length);
    }

    private byte[] readClassBytes(String name) {
        throw new UnsupportedOperationException();
    }
}

这时调用顺序变成:

loadClass -> findLoadedClass -> findClass -> defineClass -> 失败后再 super.loadClass

这就是典型的“子加载器优先”模型。

不过在实际工程中,不建议对所有类都 child-first。常见做法是按包名区分:

  • java.*javax.*jdk.* 等核心类仍然父优先。
  • 应用自身的业务类可以子优先。
  • 公共 API 或容器 API 通常交给父加载器统一加载。

3. 典型应用场景

打破双亲委派不是为了“绕过安全”,而是为了解决隔离和扩展问题。

典型场景包括:

  • Tomcat:不同 Web 应用之间需要类隔离,同名业务类可以在不同应用中共存。
  • OSGi / 插件系统:每个模块或插件有独立的类加载空间。
  • 热部署:通过丢弃旧的类加载器,让旧类随加载器一起被卸载,再用新加载器加载新版本。
  • SPI 机制:例如 JDBC 驱动加载中会用到线程上下文类加载器,让父加载器可以反向访问子加载器可见的实现类。

五、打破双亲委派就一定不安全吗

不一定。

打破双亲委派只是改变了“类加载请求的查找顺序”,例如从父加载器优先变成当前加载器优先。它会削弱双亲委派带来的统一性和稳定性,但并不等于 JVM 的安全机制全部失效。

原因是:类加载器只负责把字节码交给 JVM,字节码能不能真正成为一个可执行的类,还要经过 JVM 后续的安全检查。

1. 类链接时 JVM 会检查该类是否有害

即使一个类是通过自定义类加载器加载的,它也必须经过链接阶段。

链接阶段中的验证会检查字节码是否合法,例如:

  • .class 文件格式是否正确。
  • 常量池、字段、方法结构是否合法。
  • 操作数栈和局部变量表使用是否符合规范。
  • 类型转换、方法调用、字段访问是否安全。

所以,defineClass 并不是“无条件把字节数组变成类”。它只是把字节码交给 JVM,JVM 仍然会做校验。如果字节码不符合规范,类加载会失败。

2. 沙箱安全机制仍然可以限制代码能力

双亲委派解决的是“类由谁加载”的问题,而沙箱安全机制解决的是“代码能做什么”的问题。

在传统 Java 安全模型中,可以通过 SecurityManagerAccessController、权限策略等机制限制代码行为,例如:

  • 是否允许读写本地文件。
  • 是否允许访问网络。
  • 是否允许反射访问敏感成员。
  • 是否允许创建类加载器。

虽然新版 JDK 中 SecurityManager 已经逐步废弃,但这个点在面试中仍然可以表达为:类加载顺序被改变,不代表运行权限没有边界。

也就是说,自定义类加载器可以加载类,但加载进来的类仍然可能受到运行时权限控制。

3. 包名限制:不能随便伪造核心包

JVM 对某些包名有保护限制,最典型的是 java.*

例如你自己写一个类:

package java.lang;

public class String {
}

即使你试图通过自定义类加载器调用 defineClass 去加载它,JVM 也不会允许普通用户代码随意定义这类受保护包名下的类,通常会抛出安全异常。

这说明:打破双亲委派并不等于可以任意替换 JDK 核心类。核心包名本身还有额外防护。

4. 密封类与签名验证

对于来自 JAR 包的类,JVM 和类库还会处理包密封、证书签名等信息。

如果一个包被声明为 sealed,那么同一个包下的类必须来自同一个代码源。否则即使类名合法,也可能因为来源不一致而加载失败。

签名验证也是类似的思路:如果 JAR 中的类带有签名信息,加载时需要保证签名一致,防止同一个包中混入来源不可信的类。

所以,从工程安全角度看,类加载不只看“谁先加载”,还要看:

  • 字节码是否合法。
  • 包名是否被允许。
  • 代码来源是否一致。
  • 签名和密封约束是否满足。

5. 真正的风险在哪里

打破双亲委派不是绝对不安全,但它确实会带来风险。

最大的问题是:同名类可能被不同类加载器加载,导致类型隔离、类型冲突或强转失败。

在 JVM 中,一个类的唯一性由两部分决定:

类的唯一性 = 全限定类名 + 定义类加载器

例如:

loader1 加载 com.example.User
loader2 加载 com.example.User

这两个 User 在 JVM 看来不是同一个类,强转时可能抛出:

ClassCastException
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐