原创

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

什么是 JVM 类加载机制

JVM 类加载机制,指的是 Java 虚拟机把 .class 字节码加载到内存,并转换为可执行运行时数据结构的整个过程。它不是简单地“把类读进来”,而是一个完整的生命周期,通常包括:

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

面试里常说的“类加载过程”,核心通常指前五步,尤其是加载、链接、初始化这几个阶段。


类从磁盘到内存,经历了什么

加载

加载阶段的目标,是把类的二进制字节流读入 JVM,并在方法区中生成类的运行时数据结构,同时在堆中生成一个对应的 Class 对象,作为访问这个类元数据的入口。

这个字节流来源不一定是本地磁盘文件,还可能来自:

  • JAR 包
  • 网络
  • 动态生成
  • 数据库
  • 加密文件解密后加载

也就是说,Java 类并不要求必须从 .class 文件中加载,这也是自定义类加载器存在的基础。

链接

链接阶段又分为三部分:

1. 验证

验证是为了确保加载进来的字节码符合 JVM 规范,不会破坏虚拟机安全。

主要检查内容包括:

  • 文件格式是否正确
  • 元数据是否合法
  • 字节码指令是否安全
  • 符号引用是否可解析

这一步很重要,因为 JVM 不能信任任何外部传入的字节码。

2. 准备

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

注意两点:

  1. 这里分配的是 类变量,不是实例变量
  2. 这里赋的是 默认值,不是代码里写的赋值结果

例如:

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

在准备阶段:

  • value 会先被赋值为 0
  • CONST 如果是编译期常量,通常会直接进入常量池,表现上像提前有值

真正把 value 设为 10,发生在初始化阶段。

3. 解析

解析阶段会把常量池中的 符号引用 替换为 直接引用

例如:

  • 类名 -> 实际内存地址
  • 方法签名 -> 方法入口地址
  • 字段描述 -> 字段偏移信息

符号引用可以理解为“字符串级别的引用描述”,直接引用则是 JVM 真正能定位目标的方式。


初始化才是真正执行类代码的阶段

初始化阶段会执行类构造器 <clinit>() 方法。

这个方法并不是开发者手写的,而是编译器自动收集以下内容后生成的:

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

例如:

public class Demo {
    static int a = 10;

    static {
        System.out.println("静态代码块执行");
        a = 20;
    }
}

编译后 JVM 在初始化阶段会执行对应的 <clinit>(),最终让 a 变成 20

<clinit>() 的几个关键特征

1. 类只会初始化一次

同一个类加载器加载的同一个类,只会执行一次初始化逻辑。

2. 线程安全

JVM 会保证一个类的初始化过程在多线程环境下是同步的,不会同时执行多次。

3. 父类优先初始化

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


哪些情况会触发类初始化

并不是“用到类”就一定初始化。JVM 对初始化时机有严格规定。

常见的主动使用场景包括:

1. new 对象

new User();

2. 访问类的静态变量(非 final 编译期常量)

System.out.println(User.count);

3. 调用类的静态方法

User.test();

4. 反射

Class.forName("com.example.User");

5. 初始化子类时,若父类未初始化,则先初始化父类

6. JVM 启动时加载主类

也就是包含 main 方法的类。


哪些情况不会触发类初始化

这部分很容易被误解,也是面试高频点。

1. 通过子类引用父类静态字段

class Parent {
    static int num = 10;
}

class Child extends Parent {
}

System.out.println(Child.num);

这里实际只会初始化 Parent,不会初始化 Child

2. 引用编译期常量

class Demo {
    static final int CONST = 100;
}

访问 Demo.CONST 往往不会触发初始化,因为编译阶段常量已经被写入调用方常量池。

3. 创建类数组

Demo[] arr = new Demo[10];

这里只是创建数组对象,不会初始化 Demo

4. 通过 ClassLoader.loadClass() 加载类

loadClass() 默认只完成加载,不一定立即初始化。 而 Class.forName() 默认会触发初始化。


类加载器是什么

类加载器负责把类字节码加载进 JVM。JVM 并不是只靠一个类加载器工作,而是采用分层结构。

Java 中常见的类加载器包括:

启动类加载器(Bootstrap ClassLoader)

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

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

它是由 C/C++ 实现的,不是普通 Java 类,因此在 Java 代码里拿到它时通常显示为 null

扩展类加载器(Extension ClassLoader)

在 JDK 8 中主要负责加载 jre/lib/ext 目录下的扩展类库。 在较新版本 JDK 中,这个概念的目录意义已经弱化,但类加载分层思想仍然存在。

应用类加载器(Application ClassLoader)

也叫系统类加载器,负责加载应用程序 classpath 下的类。 大多数业务代码,默认都是由它加载。

自定义类加载器(Custom ClassLoader)

开发者可以继承 ClassLoader 实现自定义加载逻辑,用于:

  • 热部署
  • 插件化架构
  • 脚本执行引擎
  • 字节码加密解密
  • 隔离不同版本依赖

什么是双亲委派模型

双亲委派模型,是 Java 类加载器在加载类时默认遵循的一套委托机制。

它的核心规则不是“先自己加载”,而是:

一个类加载器收到类加载请求后,先把请求委托给父加载器;只有父加载器无法完成加载时,子加载器才会自己尝试加载。

这里的“父”不是继承关系,而是类加载器之间的组合关系、委派关系。


双亲委派的工作流程

以应用类加载器加载 java.lang.String 为例,大致流程如下:

  1. Application ClassLoader 收到加载请求
  2. 它先委托给 Extension ClassLoader
  3. Extension 再委托给 Bootstrap ClassLoader
  4. Bootstrap 发现自己能加载 java.lang.String
  5. 由 Bootstrap 完成加载并返回结果
  6. 子加载器不再重复加载

如果 Bootstrap 无法加载,比如业务类 com.example.User

  1. Bootstrap 尝试失败
  2. 返回给 Extension
  3. Extension 尝试失败
  4. 返回给 Application
  5. Application 自己完成加载

可以把这个过程理解成“先向上问,再向下落”。


双亲委派的源码逻辑

ClassLoader#loadClass() 是理解双亲委派的关键入口。其核心逻辑可以概括为:

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;
    }
}

这段逻辑可以归纳为三步:

1. 先检查类是否已经被加载过

避免同一个类被重复加载。

2. 再委托父加载器加载

父加载器优先。

3. 父加载器加载失败后,自己再调用 findClass()

真正的自定义加载逻辑,通常写在 findClass() 里,而不是直接重写 loadClass()


双亲委派为什么重要

双亲委派不是为了“优雅”,而是为了解决两个非常实际的问题:安全唯一性

1. 防止核心类被篡改

假设没有双亲委派,应用程序自己写了一个:

package java.lang;

public class String {
}

如果 JVM 允许应用类加载器优先加载它,那整个 Java 基础设施都会失去安全性。

有了双亲委派后,java.lang.String 一定优先交给 Bootstrap 加载,业务代码伪造不了核心类。

2. 保证类的唯一性

在 JVM 中,一个类是否“相同”,不仅由类的全限定名决定,还由 加载它的类加载器 决定。

也就是说:

  • 类名相同
  • 字节码相同

如果由不同类加载器加载,JVM 依然认为它们是两个不同的类。

双亲委派让同一路径上的类优先交给上层统一加载,减少重复定义和类型冲突。


JVM 判断两个类是否相等的标准

很多人只记得“类名相同就一样”,这是不准确的。

JVM 中一个类的唯一标识是:

  • 类的全限定名
  • 定义该类的类加载器

例如:

com.example.User

如果分别由两个不同的自定义类加载器加载,那么它们在 JVM 看来就是两个完全不同的类型,强转时甚至可能报:

java.lang.ClassCastException

这也是很多插件化、模块化系统里最容易踩的坑。


破坏双亲委派的典型场景

双亲委派是默认机制,但并不是绝对不能打破。在一些框架和容器中,确实会主动做“反向委托”或“按需隔离”。

1. Tomcat 的 Web 应用隔离

Tomcat 需要同时部署多个应用,而不同应用可能依赖同名但不同版本的类库。

如果严格遵守双亲委派,所有应用都会优先走公共父加载器,版本隔离就做不到。

因此 Tomcat 在 WebAppClassLoader 层面会做有选择的打破,让每个 Web 应用优先加载自己 WEB-INF/lib 下的类,从而实现应用隔离。

2. SPI 机制

典型例子是:

  • JDBC Driver
  • ServiceLoader

问题在于:很多 SPI 接口由 Bootstrap 或平台类加载器加载,而它们的实现类在业务 classpath 中,由应用类加载器加载。

按照纯双亲委派,上层加载器看不到下层实现类。 为了解决这个问题,Java 提供了 线程上下文类加载器(Thread Context ClassLoader),让父级基础框架有机会“反向”使用应用类加载器去加载实现类。

这本质上也是对传统双亲委派的一种补充。

3. OSGi、模块化、热部署框架

这类系统往往需要:

  • 动态装载
  • 版本并存
  • 模块隔离
  • 类热替换

因此通常不会完全依赖标准双亲委派,而是实现更灵活的类加载拓扑。


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

正确做法通常是重写 findClass(),而不是直接重写 loadClass()

推荐方式

public class MyClassLoader extends ClassLoader {

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

    private byte[] loadClassBytes(String name) {
        // 自定义字节码读取逻辑
        return null;
    }
}

这样做的好处是:

  • 仍然保留双亲委派
  • 只在父加载器找不到时,才走自定义逻辑
  • 更符合 ClassLoader 设计预期

不推荐直接重写 loadClass()

除非你非常清楚自己要做什么,比如实现:

  • child-first 加载
  • 插件隔离
  • 特定包优先级控制

否则直接改 loadClass() 很容易导致:

  • 核心类冲突
  • 重复加载
  • ClassCastException
  • 框架兼容问题

一个最容易混淆的问题:Class.forName()loadClass() 的区别

这两个 API 经常被放在一起问。

ClassLoader.loadClass()

  • 主要完成类加载
  • 默认不会主动触发初始化

Class.forName()

  • 默认会加载并初始化类
  • 常用于反射场景

例如:

ClassLoader.getSystemClassLoader().loadClass("com.example.Demo");
Class.forName("com.example.Demo");

如果 Demo 中有静态代码块,通常第二句会执行,第一句不一定执行。


一个完整例子:观察类初始化顺序

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

    static int value = 10;
}

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

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

输出结果通常是:

Parent init
10

原因是:

  • value 定义在 Parent
  • 通过 Child.value 访问时,只会触发 Parent 初始化
  • Child 不会初始化

这个例子能直接区分“类被引用”和“类被主动使用”的差别。


双亲委派的优缺点

优点

1. 保证核心类库安全

避免业务代码替换 JDK 核心类。

2. 避免类重复加载

提升类管理的一致性。

3. 降低类型冲突概率

减少同名类由多个加载器重复定义带来的问题。

缺点

1. 灵活性受限

某些场景需要子加载器优先,否则无法实现隔离。

2. 不适合复杂模块化系统

插件化、多版本依赖、热更新等需求,往往需要更灵活的加载机制。

所以实际工程里,双亲委派是默认安全基线,而不是所有场景下的唯一方案。


面试中高频追问

为什么要有双亲委派

因为要保证类加载的安全性和一致性,防止核心类被篡改,并避免重复加载。

双亲委派一定不会被打破吗

不是。Tomcat、SPI、OSGi 等场景都会对其进行补充或改造。

自定义类加载器为什么一般重写 findClass()

因为 loadClass() 已经实现了双亲委派流程,重写 findClass() 更安全、更符合设计。

两个类名相同的类,一定是同一个类吗

不一定。还要看是不是由同一个类加载器加载。

ClassNotFoundExceptionNoClassDefFoundError 区别是什么

  • ClassNotFoundException:运行时显式加载类时没找到,属于受检异常
  • NoClassDefFoundError:类在编译时存在,但运行时无法正常定义或加载,属于错误

这类问题经常和类加载器、依赖缺失、初始化失败混在一起考。


实战中真正需要关注的点

1. 排查类冲突时,不要只看包名

要同时看:

  • 谁加载了这个类
  • 类来自哪个 jar
  • 是否存在多个版本
  • 线程上下文类加载器是谁

2. 框架源码里看到类加载器,不要只盯着 new

很多问题不是对象创建逻辑导致的,而是类加载边界导致的。

3. 容器环境最容易出现类隔离问题

例如:

  • Tomcat
  • Spring Boot 外挂容器
  • 插件平台
  • Java Agent
  • 热部署工具

这些环境里,类加载器层次往往比普通单体应用复杂得多。

4. 线上出现 ClassCastException,要怀疑是否是不同类加载器导致

尤其当异常信息里两个类名看起来一模一样时,更要优先检查类加载器。


总结

JVM 类加载机制的本质,是把字节码转成 JVM 可识别、可执行的运行时结构;而双亲委派模型,则是在这个过程中建立了一套“先父后子”的默认加载秩序。

理解这个主题,至少要抓住四个核心点:

  1. 类加载不是一步,而是加载、链接、初始化等多个阶段
  2. 真正执行静态赋值和静态代码块的是初始化阶段
  3. 双亲委派的核心价值是安全性和类唯一性
  4. 类的唯一性由“全限定名 + 类加载器”共同决定

把这几个点理解透,再去看 Tomcat、SPI、热部署、插件化这些场景时,很多“诡异问题”都会变得清晰。

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