JVM 类加载机制详解(生命周期・双亲委派・自定义加载器)

类加载器

类加载器(ClassLoader)说白了只是个"搬运工",负责把磁盘上的 .class 字节码文件拉进内存;而加载、验证、准备、解析、初始化,则是 JVM 拿到文件后,在内部进行的"组装、安检和激活"过程。

类加载的生命周期

加载,验证、准备、解析(连接),初始化

🛡️ 1. 验证(Verification)------ 安全检查

  • 一句话总结: 检查拉进来的 .class 文件是不是合法的,有没有坏人恶意篡改。
  • 为什么要这一步? 字节码文件不一定非要用 Java 编译器(javac)生成,任何人都可以用二进制编辑器手动写一个 .class 文件。如果没有验证,里面写了破坏 JVM 内存、攻击系统的恶意代码,JVM 直接运行就瘫痪了。
  • 具体查什么?
    • 文件格式验证: 检查开头是不是魔数 0xCAFEBABE(咖啡宝贝),版本号是否在当前 JVM 接受范围内。
    • 元数据验证: 检查语法,这个类有没有父类?是不是继承了被 final 修饰的类?
    • 字节码验证: 保证程序语义是合法的,比如不会出现"把一个对象强转成毫无关系的另一个类"这种离谱操作。
    • 符号引用验证: 后面解析阶段会用,确保能根据名字找到对应的类、方法和字段。

储备阶段:2. 准备(Preparation)------ 分配内存,赋零值

  • 一句话总结: 为类的静态变量(static 变量)在方法区分配内存,并设置默认初始值。

  • 核心细节(面试常考点):

    此时赋的值是"零值"(如 00.0nullfalse),而不是你在代码里写的那个值!

  • 举个栗子:

    假设你的类里写了这一行:

    Java 复制代码
    public static int value = 123;

    准备阶段 过完后,value 在内存里的值是 0 ,而不是 123!真正的 123 要等到初始化阶段才会赋值。

  • 特殊情况(常量):

    如果是被 final 修饰的常量:

    Java 复制代码
    public static final int value = 123;

    因为有了 final,它是不可变的。在编译时 javac 就为它生成了 ConstantValue 属性,所以在准备阶段value 就会直接被赋值为 123

🔗 3. 解析(Resolution)------ 符号引用转直接引用

  • 一句话总结: 把常量池内的"名字(字符串标签)"换成真正的"内存地址指针"。

  • 什么叫符号引用(Symbolic References)?

    你在写代码或者写字节码时,调用一个方法 com.user.OrderService.string()。此时 JVM 内部并不知道这个方法具体在内存的哪个地方,它只能用一串字符串(符号)来暂时代替:"喏,我以后要调用这个名字的方法"。

  • 什么叫直接引用(Direct References)?

    解析阶段开始后,JVM 在内存里一查,找到了 OrderService.string() 对应的真实内存起始地址(比如 0x7fff1234)。然后把之前的字符串名字,替换成这个真实的内存指针

  • 解析的对象: 类或接口、字段、类方法、接口方法等。

🚀 4. 初始化(Initialization)------ 真正执行 Java 代码

  • 一句话总结: 这是类加载的最后一步,JVM 开始真正执行你在类里写的 Java 赋值语句和静态代码块。

  • 核心底层:

    初始化阶段,本质上就是 JVM 自动收集类里所有静态变量的赋值动作静态代码块(static {} ,融合成一个叫做 <clinit>()(Class Initialize)的方法,然后去执行它。

  • 再看刚才的栗子:

    Java 复制代码
    public static int value = 123;

    到了初始化 阶段,JVM 执行 <clinit>() 方法,value 的值才真正从 0 变成了 123

  • 触发初始化的时机(主动引用):

    类不是平白无故初始化的,只有遇到以下情况(部分常见情况)才会触发:

    1. 使用 new 关键字实例化对象。
    2. 读取或设置一个类的静态字段(被 final 修饰的常量除外)。
    3. 调用一个类的静态方法。
    4. 使用 Class.forName("...") 反射加载类。
    5. 虚拟机启动时,包含 main() 方法那个主类会被率先初始化。

💡 总结通关图谱

为了让你在学接下来的"三种类加载器"时完全不晕,记住这个连贯的故事:

  1. 加载: 搬运工(ClassLoader)把 .class 二进制流读进内存。
  2. 验证: 安检员上场,看看文件有没有被下毒(防篡改)。
  3. 准备: 木匠在内存方法区盖好静态变量的"毛坯房",里面先刷上白墙(赋零值 0)。
  4. 解析: 导航仪把代码里的"路标字符串"换成精细的"GPS 经纬度(内存直接引用)"。
  5. 初始化: 装修队进场,把精装家具搬进去,按照你的代码意图给静态变量正式赋值(执行 <clinit>())。

这五步走完,这个类在 JVM 里就彻底"活了",接下来你就可以愉快地 new 它的对象实例了。把这套流程焊死在脑子里,再去学双亲委派三大类加载器,逻辑就会顺畅得不可思议!

三大类加载器

名称 加载哪的类 说明
Bootstrap ClassLoader(启动类加载器) JAVA_HOME/jre/lib 目录下的核心类库(如 rt.jarcharsets.jar 等 JDK 核心类) JVM 底层 C++ 实现,无 Java 类实例,Java 代码中无法直接访问getClassLoader() 会返回 null
Extension ClassLoader(扩展类加载器) JAVA_HOME/jre/lib/ext 目录下的扩展 jar 包 父加载器为 Bootstrap ClassLoader,Java 代码中获取其上级加载器时会显示为 null
Application ClassLoader(应用 / 系统类加载器) 项目 classpath 路径下的自定义类、第三方依赖 jar 包 父加载器为 Extension ClassLoader,是 Java 代码中默认的类加载器
自定义类加载器 自定义路径(如网络、加密文件、特殊目录等)下的类 父加载器默认指定为 Application ClassLoader,可实现自定义类加载逻辑

1. 启动类加载器 (Bootstrap ClassLoader) ------ "始祖级大佬"

  • 它在哪、加载啥: 负责加载 Java 核心类库,也就是你配置的 JAVA_HOME/jre/lib 目录下的核心 jar 包(比如最关键的 rt.jar,里面躺着 java.lang.Objectjava.lang.Stringjava.util.HashMap 等)。
  • 底层硬核秘密(面试爱考):
    • 它不是用 Java 语言写的! 它是用 C/C++ 写的,嵌套在 JVM 内核里面。
    • 它没有实例: 因为它不是一个 Java 对象,所以你在 Java 代码里如果尝试去获取它,返回的结果永远是 null
    • 举个栗子: 如果你执行 String.class.getClassLoader(),你会发现打印出来的是 null。表里说明写的"显示为 null"就是这个意思。

2. 扩展类加载器 (Extension ClassLoader) ------ "皇家护卫"

  • 它在哪、加载啥: 负责加载 Java 的扩展类库,对应的目录是 JAVA_HOME/jre/lib/ext。这里面放的是一些官方自带但不是最核心的扩展工具 jar 包。
  • 底层秘密: 它是由 Java 语言编写的(具体类名是 sun.misc.Launcher$ExtClassLoader)。表里写着"上级为 Bootstrap",意思是它的逻辑父加载器是 Bootstrap。

3. 应用程序类加载器 (Application ClassLoader) ------ "搬砖主力军"

  • 它在哪、加载啥: 负责加载 classpath(类路径) 下的所有类。说白了,你在项目里自己写的代码、引入的第三方 Maven 依赖(如 Spring、MyBatis、或者是各类 jar 包),全部都是由它来负责加载进内存的
  • 底层秘密: 它也是 Java 写的(sun.misc.Launcher$AppClassLoader)。因为平时我们绝大多数类都是它加载的,所以它也叫系统类加载器(System ClassLoader)。如果你写一个自己的类 User.class.getClassLoader(),打印出来的就是它。

4. 自定义类加载器 (Custom ClassLoader) ------ "特种兵"

  • 为什么需要它: 官方的前三个加载器只能去本地固定的磁盘目录或环境变量 里加载明文的 .class 文件。如果我的业务场景很特殊呢?
    • 比如:我的字节码文件是加密过的,防止别人反编译,需要加载时在内存里先解密
    • 比如:我的 .class 文件不在本地,而是存在远端服务器或数据库里,需要通过 网络网络请求 读进来。想加载非classpath随意路径中的类文件。
  • 怎么做: 继承 java.lang.ClassLoader 类,重写 findClass() 方法,你就能自己手写一个属于你的类加载器。

⚠️ 盯紧图里最右侧的"说明":这是在给双亲委派埋伏笔!

注意看图里写的:

  • Extension 的上级是 Bootstrap
  • Application 的上级是 Extension
  • 自定义类加载器的上级是 Application

🚨 纠错警示灯:这里的"上级"绝对不是面向对象里的"继承(extends)"关系!

在 JVM 源码里,它们之间既没有 AppClassLoader extends ExtClassLoader,也没有 ExtClassLoader extends Bootstrap。 它们是通过组合(Combination)关系来维持组合的。

也就是说,每个 ClassLoader 实例里面都有一个成员变量叫 parentAppClassLoaderparent 属性指向了 ExtClassLoader 实例。

这种"逐级引向上级"的链条,就是接下来你要学的 双亲委派模型(Parent Delegation Model) 的核心骨架!

双亲委派模式

就是调用类加载器的loadClass方法时, 查找类的规则。
java 复制代码
protect Class<?> loadClass(Stirng name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
		// 1.检查该类是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
			long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 2.有上级的话, 委派上级 loadClass
                    c = parent.loadClass(name, false);
                } else {
                    // 3.没有上级了(ExtClassLoader), 则委派 BootstrapClassLoader
                    c = findBootstrapClassOrNull(name);
                }
            }catch (){}
            if (c == null) {
                long t1 = System.nanoTime();
                // 4.每一层找不到,调用findClass 方法(每个类加载器自己扩展)来加载
                c  = findClass(anme);
            }
        }
    
    }
}

线程上下文类加载器

线程上下文类加载器(Thread Context ClassLoader) ,以及它如何用来打破双亲委派模型

一个很好的引子:在老版本的 JDBC 中,我们需要手动写 Class.forName("com.mysql.jdbc.Driver") 来加载驱动;但在 JDBC 4.0 之后,即使不写这行代码,MySQL 驱动也能被正确加载。

这背后其实隐藏着一个著名的设计矛盾,也就是 SPI(Service Provider Interface)机制

1. 痛点:双亲委派模型的"死穴"

在正常情况下,双亲委派模型要求:如果一个类由某个类加载器加载,那么它里面引用的其他类,默认也会用同一个类加载器去加载。

  • DriverManager 的身份 :它是 JDK 核心类库的一部分(位于 java.sql 包下),因此它是由最顶层的 Bootstrap ClassLoader(启动类加载器) 加载的。
  • MySQL 驱动的身份 :它是第三方厂商提供的 Jar 包(位于 classpath 下),原本应该由底层的 App ClassLoader(系统类加载器) 来加载。

DriverManager 初始化并尝试去加载各个厂商实现的驱动时,矛盾就来了:

DriverManager(由 Bootstrap 加载)想要调用底层的 com.mysql.jdbc.Driver(在 classpath 中,Bootstrap 根本找不到、也管不着)。

这就是双亲委派模型的局限性:核心类库(顶层加载器)无法直接访问用户代码(底层加载器)。

2. 破局者:线程上下文类加载器(Thread Context ClassLoader)

为了解决这个"套娃"死结,Java 引入了线程上下文类加载器。它相当于给顶层加载器开了一个"后门",允许顶层代码"逆向"调用底层的加载器。

虽然图片在 loadInitialDrivers() 这里戛然而止,如果我们追进这个方法的源码,就会发现它的核心实现逻辑如下:

Java 复制代码
private static void loadInitialDrivers() {
    // 1. 使用 ServiceLoader 机制加载驱动
    ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
    Iterator<Driver> driversIterator = loadedDrivers.iterator();
    try{
        while(driversIterator.hasNext()) {
            driversIterator.next(); // 这里会触发驱动类的加载和注册
        }
    } catch(Throwable t) {
        // Do nothing
    }
}

当我们再进一步点进 ServiceLoader.load(Driver.class) 的源码时,狐狸尾巴就露出来了:

Java 复制代码
public static <S> ServiceLoader<S> load(Class<S> service) {
    // 核心:获取了当前线程的上下文类加载器!(是当前线程使用的类加载器,默认就是Application classloader)
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

3. 核心总结

  1. 默认委派失败DriverManager 是被 BootstrapClassLoader 加载的,它在自己的管辖范围内(JDK 核心库)找不到引入的 MySQL 驱动包。
  2. 借刀杀人(打破委派) :每个 Java 线程在创建时,默认都会把 AppClassLoader 设置为自己的 ContextClassLoader(上下文类加载器)。
  3. 成功加载DriverManager 巧妙地通过 Thread.currentThread().getContextClassLoader() 拿到了这个 AppClassLoader,并强行用它去加载了 classpath 下的 MySQL 驱动。

这种由高层加载器委托底层加载器 去加载类的行为,本质上打破了双亲委派模型那种只能"自底向上委派"的固有规则。

自定义类加载器

一、 为什么要自定义类加载器?

图片中列举了三个核心场景,它们在实际开发(尤其是中间件和服务器开发)中非常经典:

  1. 加载非 classpath 随意路径中的类文件
    • 大白话 :默认的 AppClassLoader 只能加载项目环境变量 classpath 路径下的类。如果你想从网络上(比如 RPC 远程传输的字节码)、数据库里、或者服务器的某个特定目录(如图片中的 E:\myclasspath)动态加载 .class 文件,默认的加载器就无能为力了。
  2. 通过接口解耦(常用于框架设计、插件化)
    • 大白话 :比如做插件化开发(OSGi 架构)。主程序只定义接口,具体的实现类由不同的自定义类加载器动态从插件包里加载,实现热插拔和完美解耦。
  3. 类隔离(不同应用的同名类都可以加载,不冲突)
    • 大白话 :这是 Tomcat 等 Web 容器 的看家本领。假设一个 Tomcat 里面同时运行了两个 Web 应用,应用 A 用的是 Spring 4.0,应用 B 用的是 Spring 5.0。如果不做隔离,由于全限定名完全一样,JVM 只会加载其中一个,另一个应用直接崩溃。Tomcat 通过为每个 Web 应用分配一个独立的自定义类加载器,完美实现了同名类的物理隔离

二、 核心步骤与底层源码的闭环

写自定义类加载器的"黄金法则":

1.继承ClassLoader 父类

2.要遵从双亲委派机制,重写findClass方法

■ 注意不是重写loadClass方法,否则不会走双亲委派机制

3.读取类文件的字节码

4.调用父类的defineClass方法来加载类

5.使用者调用该类加载器的loadClass 方法

特别是第 2 步,点出了无数初学者最容易踩的坑。

为什么是重写 findClass,而不是 loadClass

还记得你在双亲委派模式看到的 loadClass 源码吗?它的逻辑是:

  1. 检查是否加载过。
  2. 委派给父类加载器(实现双亲委派机制)
  3. 如果父类加载器找不到,才调用 findClass

关键点 :JDK 的设计者已经把双亲委派的模板流程在 loadClass 方法里写死了(这就是模板方法模式)。

  • 如果你重写了 loadClass :你就把双亲委派机制给无意间破坏了。
  • 如果你重写 findClass :当父类加载器找不到这个类时,JVM 才会乖乖调用你重写的 findClass 去你指定的路径(比如 E:\myclasspath)读入字节码。这样既实现了自定义加载,又完美保留了双亲委派机制

三、 自定义类加载器标准代码模板

下面是一个严格按照图片 5 个步骤实现的标准代码模板:

Java 复制代码
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;

// 步骤 1:继承 ClassLoader 父类
public class MyClassLoader extends ClassLoader {
    
    private String classPath;

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    // 步骤 2:重写 findClass 方法(遵循双亲委派)
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 步骤 3:读取类文件的字节码
        byte[] data = loadClassData(name);
        if (data == null) {
            throw new ClassNotFoundException();
        }
        
        // 步骤 4:调用父类的 defineClass 方法,将字节数组转化为 Class 对象
        return defineClass(name, data, 0, data.length);
    }

    // 步骤 3 的具体实现:从物理磁盘读取 .class 文件为字节数组
    private byte[] loadClassData(String className) {
        // 将包名 com.example.User 转换为路径 com/example/User.class
        String fileName = classPath + className.replace('.', '/') + ".class";
        
        try (FileInputStream ins = new FileInputStream(fileName);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            int b;
            while ((b = ins.read()) != -1) {
                baos.write(b);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

步骤 5:使用者如何调用?

Java 复制代码
public class Test {
    public static void main(String[] args) throws Exception {
        // 1. 创建自定义类加载器,指定去 E:\myclasspath 找类
        MyClassLoader loader = new MyClassLoader("E:/myclasspath/");
        
        // 2. 调用 loadClass 方法(它内部会遵从双亲委派,最终触发我们重写的 findClass)
        Class<?> clazz = loader.loadClass("com.example.MyMapImplementation");
        
        // 3. 实例化对象并使用
        Object instance = clazz.getDeclaredConstructor().newInstance();
        System.out.println("类加载器是:" + clazz.getClassLoader()); 
        // 输出将是:MyClassLoader@xxxxx
    }
}
相关推荐
Lei活在当下5 小时前
先用起来,再理解,关于协程Coroutine应该知道的事
android·java·jvm
KobeSacre14 小时前
JVM 总结
jvm
墨雪不会编程18 小时前
C++ 进阶:虚函数与多态原理
java·jvm·c++
顺风尿一寸18 小时前
从 FileOutputStream.write(byte) 到磁盘扇区:一次 Java 写入操作的完整内核穿越之旅
jvm
橙淮19 小时前
并发编程(三)
开发语言·jvm
Devin~Y20 小时前
大厂Java面试实录:Spring Boot/Cloud、Redis+Kafka、JVM调优与RAG/Agent(Spring AI)三轮递进问答
java·jvm·spring boot·redis·spring cloud·kafka·rag
存在的五月雨20 小时前
JVM线程泄漏 问题记录
jvm
驭渊的小故事2 天前
多线程01(线程状态和线程的sleep,线程终止(Interrupt)的小关联)
java·jvm·算法
深蓝轨迹2 天前
深入解析JVM方法区与StringTable机制
jvm·jdk·方法区·java八股
Dicky-_-zhang2 天前
分布式锁实战:Redis与ZooKeeper对比选型与实现方案
java·jvm