原创

JVM类加载机制与双亲委派模型

为什么必须理解类加载机制

JVM 并不是在程序启动时一次性把所有 .class 文件都加载进内存,而是采用“按需加载”的方式:当某个类第一次被主动使用时,JVM 才会尝试把它加载到运行时环境中。这个过程看似透明,实际却直接影响以下问题:

  • 为什么同名类在不同类加载器下可以共存
  • 为什么自定义类加载器常常会出现 ClassNotFoundExceptionClassCastException
  • 为什么 JDBC、SPI、Servlet 容器、热部署框架都离不开类加载机制
  • 为什么双亲委派模型能提升安全性,却又不是绝对不能被打破

理解类加载机制,本质上是在理解 JVM 如何把磁盘上的字节码,变成可执行、可验证、可隔离的运行时类对象。

类从字节码到可用对象的完整过程

JVM 中一个类的生命周期,通常可以概括为以下几个阶段:

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)

很多文章只强调“加载”和“双亲委派”,但真正容易混淆的是:类加载器主要参与的是加载阶段,而类真正变得可用,还要经过链接与初始化。

1. 加载:把字节流变成 Class 对象

加载阶段主要完成三件事:

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

这里的“二进制字节流”来源并不一定是本地 .class 文件,也可能来自:

  • JAR 包
  • 网络传输
  • 动态生成(如代理类、字节码增强)
  • 数据库或加密文件

这也是自定义类加载器存在的基础:类并不一定非要从磁盘加载。

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

验证阶段用于保证 Class 文件格式和语义符合 JVM 规范,避免恶意或损坏的字节码破坏虚拟机。

常见验证内容包括:

  • 文件格式是否合法
  • 元数据语义是否正确
  • 字节码指令是否可能危害 JVM
  • 符号引用是否能正确解析

这一步是 JVM 安全体系的重要组成部分。即使拿到了 .class 文件,也不代表一定能成功运行。

3. 准备:为类变量分配内存并设置默认值

准备阶段只处理 类变量(static 变量),不会处理实例变量。

例如:

public class Demo {
    static int value = 10;
    static final int CONST = 100;
}

准备阶段之后:

  • value 会先被赋默认值 0
  • CONST 如果是编译期常量,通常会直接进入常量池,表现上类似直接赋值为 100

注意:value = 10 这个动作并不是准备阶段完成的,而是初始化阶段执行类构造器 <clinit>() 时才真正赋值。

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

Class 文件中很多依赖关系最初都是“符号引用”,例如:

  • 引用某个类
  • 调用某个方法
  • 访问某个字段

解析阶段会把这些符号引用转换为可以直接定位目标的引用方式。

简单理解:

  • 符号引用:字符串描述,例如“某包下某类某方法”
  • 直接引用:内存地址、偏移量或可直接定位目标的信息

5. 初始化:执行类变量赋值和静态代码块

初始化阶段是类加载过程中最关键、最具“副作用”的阶段。它会执行类构造器 <clinit>() 方法,而 <clinit>() 是由以下内容合并产生的:

  • 静态变量显示赋值语句
  • 静态代码块

例如:

public class Demo {
    static int a = 1;

    static {
        a = 2;
        System.out.println("Demo 初始化");
    }
}

初始化时会执行这些逻辑,因此真正的 a 值是 2

什么情况下会触发类初始化

并不是“加载了类”就一定会“初始化类”。JVM 对初始化触发条件是比较严格的。

会触发初始化的典型场景

  1. 使用 new 创建类实例
  2. 访问类的静态变量(非编译期常量)
  3. 调用类的静态方法
  4. 使用反射对类进行主动使用
  5. 初始化子类时,若父类未初始化,则先初始化父类
  6. JVM 启动时指定的主类
  7. 某些动态语言支持场景下的 MethodHandle 解析结果首次调用

不会触发初始化的场景

访问编译期常量

public class ConstDemo {
    public static final int VALUE = 100;
}
System.out.println(ConstDemo.VALUE);

如果 VALUE 是编译期常量,调用方在编译时就可能把值直接写入自己的常量池,因此不会触发 ConstDemo 初始化。

通过数组定义引用类

ConstDemo[] arr = new ConstDemo[10];

这里只是创建数组类型,不会触发 ConstDemo 初始化。

仅引用父类静态字段

class Parent {
    static int value = 10;
    static {
        System.out.println("Parent init");
    }
}

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

只会初始化 Parent,不会初始化 Child。因为字段实际定义在父类中。

类加载器的层次结构

JVM 并不是只有一个类加载器,而是存在多层加载器协作。

1. 启动类加载器(Bootstrap ClassLoader)

负责加载 JVM 核心类库,例如:

  • java.lang.*
  • java.util.*
  • java.io.*

它由 C/C++ 实现,是 JVM 的一部分,通常无法在 Java 代码中直接拿到其对象引用。

例如:

System.out.println(String.class.getClassLoader());

输出通常是:

null

这里的 null 不代表没有类加载器,而是表示它由启动类加载器加载。

2. 扩展类加载器(Extension ClassLoader)

在较早版本 JDK 中,它负责加载扩展目录中的类库。 在现代 JDK 中,这一层实现名称和职责有调整,但在理解双亲委派时,仍可把它视为“平台类库加载器”这一层。

3. 应用程序类加载器(Application ClassLoader)

也叫系统类加载器,负责加载应用 ClassPath 下的类。 平时编写的大多数业务类,通常都由它加载。

System.out.println(Demo.class.getClassLoader());

一般会看到类似:

jdk.internal.loader.ClassLoaders$AppClassLoader@xxx

4. 自定义类加载器

开发者可以继承 ClassLoader,实现自己的类加载逻辑,例如:

  • 热部署
  • 模块隔离
  • 插件化
  • 脚本执行引擎
  • 字节码加密与解密
  • 从远程仓库加载类

类的唯一性由什么决定

JVM 中判断两个类是否“相同”,并不只看类的全限定名,而是要同时看:

  • 类的全限定名
  • 加载它的类加载器

也就是说:

同一个 .class 文件,被不同的类加载器加载后,在 JVM 看来就是两个不同的类。

这也是很多 ClassCastException 的根源。

例如,假设同一个接口 com.demo.Service 被两个不同类加载器分别加载,那么即使字节码内容完全一致,JVM 也会认为它们不是同一个类型,强制转换时就可能失败。

双亲委派模型到底是什么

双亲委派模型并不是“父类继承关系”,而是类加载请求的委派关系

当一个类加载器收到加载某个类的请求时,它不会先自己尝试加载,而是先把请求交给父加载器处理。父加载器再继续向上委派,直到最顶层的启动类加载器。只有当父加载器无法完成加载时,子加载器才会尝试自己加载。

工作流程

假设应用程序类加载器要加载 com.example.UserService

  1. AppClassLoader 收到加载请求
  2. 先委托给父加载器
  3. 父加载器继续向上委托
  4. Bootstrap ClassLoader 先尝试加载
  5. 如果 Bootstrap 找不到
  6. 再由下层加载器逐层回退处理
  7. 最终由能够找到该类字节码的加载器完成定义

这个过程可以概括为:

先问父亲能不能加载,父亲不行我再自己来。

loadClass() 的核心逻辑

ClassLoader 的典型实现思路如下:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器无法加载时忽略,继续由自己处理
            }

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

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

这里最重要的三个点是:

  • findLoadedClass(name):先检查是否已经加载过,避免重复加载
  • parent.loadClass(...):优先委派给父加载器
  • findClass(name):父加载器失败后,自己再尝试定义类

双亲委派模型的核心价值

1. 避免类重复加载

如果没有双亲委派,同一个类可能被多个加载器反复加载,造成类型混乱和资源浪费。

2. 保证核心类库安全

例如 java.lang.String 必须由 Bootstrap ClassLoader 加载。 如果应用程序自己能随意加载一个同名 java.lang.String,整个 Java 运行环境都会失去安全基础。

双亲委派保证了:越基础、越核心的类,越优先由上层加载器统一加载。

3. 保证类加载结果的一致性

对于 JDK 核心类、公共框架类,通过上层统一加载,可以避免系统中出现多个版本的“逻辑相同但类型不同”的类。

用一个例子理解双亲委派的安全性

假设你自己写了一个类:

package java.lang;

public class String {
    public String() {
        System.out.println("fake string");
    }
}

你希望在程序中替换 JDK 自带的 String。理论上如果“谁先加载谁生效”,那 Java 世界会立刻混乱。

但在双亲委派下:

  • 应用类加载器接到 java.lang.String 加载请求
  • 它先委派给父加载器
  • 最顶层 Bootstrap 已经加载了真正的 java.lang.String
  • 所以下层加载器根本没有机会定义这个类

这就是双亲委派最直观的安全意义。

自定义类加载器应该重写哪个方法

很多面试题会问:自定义类加载器时,应该重写 loadClass() 还是 findClass()

通常答案是:

优先重写 findClass(),而不是直接破坏 loadClass() 的双亲委派逻辑。

推荐方式

public class MyClassLoader extends ClassLoader {

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

    private byte[] loadBytesFromFile(String name) {
        // 根据类名读取字节码
        return null;
    }
}

这种做法保留了父类 loadClass() 中的双亲委派流程,只在“父加载器找不到类”时由自己定义。

不推荐直接重写 loadClass()

因为一旦你自己接管 loadClass(),很容易出现:

  • JDK 核心类被错误处理
  • 重复加载
  • 类型隔离异常
  • 类冲突难以排查

除非你非常明确地需要打破双亲委派,否则不要轻易这么做。

双亲委派模型是不是绝对不能打破

不是。

双亲委派是默认推荐模型,但在某些框架和容器场景中,必须有选择地打破。

为什么要打破双亲委派

1. SPI 机制需要“父加载器调用子加载器中的实现类”

以 JDBC 为例:

  • java.sql.Driver 接口由 Bootstrap 或平台相关加载器加载
  • MySQL 驱动实现类通常在应用 ClassPath 中,由 AppClassLoader 加载

问题来了:

  • 按双亲委派,父加载器看不到子加载器中的类
  • 但 Java 需要让核心 API 找到业务侧提供的实现类

这时就引入了 线程上下文类加载器(Thread Context ClassLoader)。 它允许父层逻辑在某些场景下反向借用子加载器完成类加载。

这并不是完全废弃双亲委派,而是在特定机制下绕开其可见性限制。

2. Web 容器需要应用隔离

例如 Tomcat 需要同时运行多个 Web 应用:

  • 每个应用都有自己的 WEB-INF/lib
  • 不同应用可能依赖不同版本的同名类库

如果所有类都由同一个应用类加载器加载,版本冲突几乎不可避免。 因此容器会为每个应用创建独立的类加载器,形成更复杂的加载层次,从而实现:

  • 应用之间隔离
  • 公共类共享
  • 热部署和卸载

3. OSGi、插件化框架需要模块隔离

插件系统要求:

  • 模块之间部分共享
  • 部分隔离
  • 支持动态加载与卸载

这类需求很难用严格的单向双亲委派完全满足,因此常常会采用更灵活的类加载拓扑。

线程上下文类加载器的作用

线程上下文类加载器本质上是一个“补充机制”,用于解决双亲委派下父加载器无法访问子加载器资源的问题。

典型用法:

ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class<?> clazz = cl.loadClass("com.mysql.cj.jdbc.Driver");

常见应用场景:

  • SPI
  • JDBC 驱动发现
  • JNDI
  • 服务发现框架
  • 各类容器中间件

可以把它理解为:

双亲委派保证了核心安全边界,而上下文类加载器提供了必要的运行时灵活性。

Tomcat 为什么要设计多层类加载器

Tomcat 是理解类加载器设计的典型案例。

它既要做到:

  • JDK 类库统一共享
  • Tomcat 自身类库统一共享
  • Web 应用彼此隔离
  • Web 应用可热部署、可卸载

所以它不会只依赖默认的 AppClassLoader,而是会构建自己的多层类加载器体系。常见设计目标包括:

  • common 目录下类库可共享
  • 每个 WebApp 的 WEB-INF/classesWEB-INF/lib 独立加载
  • 避免不同应用之间依赖冲突
  • 卸载应用时尽可能回收相关类加载器和类元数据

从这个角度看,类加载器不仅仅是“加载类”的工具,更是 Java 平台中的模块隔离边界

常见面试误区

误区一:双亲委派就是继承关系

不是。 它描述的是加载请求的委派顺序,不是 Java 继承语义。

误区二:类加载和类初始化是同一件事

不是。 加载只是把类字节码读进来,初始化才会执行静态变量赋值和静态代码块。

误区三:Class.forName()ClassLoader.loadClass() 没区别

区别很大:

  • Class.forName("xxx") 默认会触发类初始化
  • ClassLoader.loadClass("xxx") 默认只是加载,不一定初始化

例如:

Class.forName("com.demo.Test"); // 通常会初始化
ClassLoader.getSystemClassLoader().loadClass("com.demo.Test"); // 通常不会立即初始化

误区四:同名类一定是同一个类

不是。 同一个全限定名,如果由不同类加载器加载,JVM 会认为它们是不同类型。

误区五:自定义类加载器一定要重写 loadClass()

通常不需要。 大多数情况下重写 findClass() 就够了。

一段代码看懂初始化顺序

class Parent {
    static int a = 1;

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

class Child extends Parent {
    static int b = 2;

    static {
        System.out.println("Child init");
    }
}

public class Test {
    public static void main(String[] args) {
        System.out.println(Child.a);
    }
}

输出结果:

Parent init
1

原因是:

  • 访问的是 a
  • a 定义在 Parent
  • 因此只会触发 Parent 初始化
  • Child 不会初始化

如果改成:

System.out.println(Child.b);

则输出大概率是:

Parent init
Child init
2

因为初始化子类前,必须先初始化父类。

如何排查类加载问题

线上排查类加载问题时,最有价值的思路通常不是“类文件有没有”,而是以下四件事:

1. 看是谁加载了这个类

System.out.println(SomeClass.class.getClassLoader());

2. 看类是从哪里加载的

System.out.println(
    SomeClass.class.getProtectionDomain().getCodeSource().getLocation()
);

3. 看是否存在多个版本同名类

尤其在以下场景中常见:

  • fat jar
  • 容器部署
  • 插件系统
  • 依赖冲突
  • shading 处理不当

4. 看线程上下文类加载器是否正确

System.out.println(Thread.currentThread().getContextClassLoader());

很多 SPI 失效、驱动找不到、框架扩展点加载失败,本质上都和上下文类加载器有关。

双亲委派与现代 Java 开发的关系

在日常 CRUD 项目里,开发者很少直接写类加载器代码,但并不意味着类加载机制不重要。以下技术都和它密切相关:

  • Spring Boot 可执行 Jar 的运行方式
  • Tomcat / Jetty 容器部署
  • JDBC 驱动自动加载
  • Dubbo / SPI 扩展机制
  • 热部署工具
  • 字节码增强框架,如 ASM、CGLIB、ByteBuddy
  • APM 探针、Java Agent、类重定义

当你理解了类加载机制,会更容易看懂:

  • 框架为什么这样设计
  • 某些类冲突为什么如此隐蔽
  • 为什么“明明类名一样”却无法转换
  • 为什么模块隔离和热更新总是绕不开 ClassLoader

总结

JVM 类加载机制可以概括为两条主线:

第一条是类生命周期主线

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

第二条是类加载器协作主线

  • Bootstrap ClassLoader 负责核心类库
  • AppClassLoader 负责业务类
  • 自定义类加载器负责特殊场景
  • 双亲委派负责统一、稳定、安全的加载秩序

而双亲委派模型的本质,不是“死板的规则”,而是 JVM 为了避免重复加载、保护核心类库、维持类型一致性而建立的一套默认加载策略。

掌握它之后,再去理解 SPI、Tomcat、插件化、热部署、自定义类加载器,很多原本零散的知识点就会自然串起来。真正难的从来不是背出“先委派父加载器”,而是明白:

类是如何被加载的,为什么要这样加载,以及在什么场景下必须改变这种加载方式。

正文到此结束
评论插件初始化中...
Loading...