首页 / Java 学习笔记 / 32

JAVA · Vol.III · DAY 32 · JVM 原理与调优

类加载机制:双亲委派模型与三种破坏场景

高级必问#JVM#类加载#核心
第 1 站

开场:类加载的三个阶段

你在 IDE 里写 public class App { public static void main(String[] args) {...} },编译得到 App.class 字节码文件。从"磁盘上的字节"到"JVM 里可调用的 Class 对象",中间要经历三个阶段:加载 → 链接(验证 / 准备 / 解析)→ 初始化。类加载器(ClassLoader)负责的正是第一阶段"加载":把 .class 的字节读进内存,完成格式检查,然后在方法区生成一个 java.lang.Class 对象,作为这个类在 JVM 里的唯一"身份证"。

注意"方法区生成 Class 对象"这句话——类元数据(字段、方法、常量池)是放在方法区 / Metaspace里的,所以类加载器和 GC 的关系比我们想的深:类加载器本身是堆里的对象,它加载的类元数据在方法区,两边一收一放就牵扯出"类卸载""Metaspace 泄漏"一整串问题(方法区的位置和布局见「JVM 运行时数据区」)。这就是为什么"类加载"和"GC"两卷的内容必须在脑子里串起来。

① 加载 读字节码 → 生成 Class 对象 ② 链接:验证 / 准备 / 解析 校验格式 → 分配静态字段 → 符号引用转直接引用 ③ 初始化 执行 <clinit>,静态字段赋真值 JVM 内存视角 类元数据 / 常量池 → 方法区(Metaspace,JDK8+ 用本地内存) Class 对象、静态字段实例 → 堆(Class 对象本身也是堆对象) 执行:主动引用触发 new / 静态方法 / 静态字段读写
图 1 类加载三阶段,以及类元数据在 JVM 内存里的落点

第三阶段"初始化"的触发时机是高频考点,一句话概括:只有"主动引用"才触发初始化——new 一个对象、调用静态方法、读写静态字段(非 constant_value)、反射调用、初始化子类时先初始化父类、main 所在类。而通过常量池把编译期内联的常量 Const.VALUE 直接内联进代码、通过数组定义 new Sub[10] 只初始化父类,这些都不算。面试被追问细节再展开,本篇的重点在"加载"这一环。

一句话核心

类加载三阶段:加载(读字节码生成 Class 对象)→ 链接(验证 / 准备 / 解析)→ 初始化(执行 <clinit>)。类元数据在方法区,Class 对象本身在堆——类加载与 GC 因此深度耦合。

第 2 站

类加载器家族:从 Bootstrap 到自定义

JVM 里负责加载的"人"有四位,自底向上排成一条链:

加载器对应 Java 类加载内容父加载器
Bootstrap ClassLoader无(C++ 实现,Java 视角里不存在对象)JDK8:$JAVA_HOME/jre/lib/rt.jar 等核心类;JDK9+:java.base 等核心模块(java.*
Platform / Extension ClassLoaderJDK8:sun.misc.Launcher$ExtClassLoader;JDK9+:jdk.internal.loader.ClassLoaders$PlatformClassLoaderJDK8:jre/lib/ext 扩展目录;JDK9+:jdk 等平台模块Bootstrap
Application ClassLoaderjdk.internal.loader.ClassLoaders$AppClassLoader(JDK8 是 sun.misc.Launcher$AppClassLoader,继承 java.net.URLClassLoaderclasspath 上所有目录和 jar(你写的业务代码)Platform(JDK8 是 Extension)
自定义 ClassLoader你继承 ClassLoader 写的子类任意来源的字节码(网络、数据库、内存、加密文件)构造时传入的 parent

最经典的认知坑在第一位:Bootstrap 是 C++ 写的,Java 代码里拿不到它的对象。所以打印核心类的 getClassLoader() 会得到 null——但 null 不代表"没人加载",恰恰是 Bootstrap 干的:

ClassLoaderDemo.java —— null 不等于没有加载器
public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println("String  → " + String.class.getClassLoader());  // null   ← Bootstrap(C++)
        System.out.println("Integer → " + Integer.class.getClassLoader()); // null   ← 同上
        System.out.println("Thread  → " + Thread.class.getClassLoader()); // null   ← 同上
        System.out.println("Demo    → " + ClassLoaderDemo.class.getClassLoader());
        // jdk.internal.loader.ClassLoaders$AppClassLoader@73df3e46  ← 你的 classpath 类
    }
}

"AppClassLoader" 和 "System ClassLoader" 是一回事吗?

规范里有个 ClassLoader.getSystemClassLoader(),它返回的就是 AppClassLoader——但在 Tomcat 这类容器里,启动器会替换掉这个角色,返回容器自己插的 System/Common 加载器。"System ClassLoader" 是个规范概念、每次运行时动态确定的角色;"AppClassLoader" 是具体类名。日常说"应用类加载器"时两者通常指同一个对象,面试里区分开更严谨。

一句话核心

四层加载器:Bootstrap(C++,核心类)→ Platform/Extension(平台/扩展)→ App(classpath)→ 自定义。核心类 getClassLoader() 为 null,因为 Bootstrap 在 Java 视角里不存在。

第 3 站

双亲委派流程:loadClass() 的三步判定

把"委派"拆开看,其实就是 ClassLoader 里一个 20 行不到的方法。简化后的源码(JDK 8,去掉了锁和检查):

OpenJDK ClassLoader.loadClass() 简化源码 + 自定义 findClass
// java.lang.ClassLoader(JDK 8 简化)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    Class<?> c = findLoadedClass(name);          // ① 查过没?加载过直接返回 → "类只加载一次"的根源
    if (c == null) {
        ClassLoader parent = getParent();
        if (parent != null) {
            try { c = parent.loadClass(name, resolve); }  // ② 先问父亲,递归向上直到 Bootstrap
            catch (ClassNotFoundException e) {}
        }
        if (c == null) {
            c = findClass(name);                  // ③ 父亲们都找不到,才轮到自己找
        }
    }
    return c;
}
// findClass 的默认实现直接抛 ClassNotFoundException ——
// 所以自定义加载器必须重写 findClass(或 override 整个 loadClass)

public class MyLoader extends ClassLoader {
    private final String dir;
    MyLoader(String dir) { this.dir = dir; }
    @Override
    protected Class findClass(String name) throws ClassNotFoundException {
        byte[] bytes = readClassFileFromMyDir(name);  // 从指定目录读字节码
        return defineClass(name, bytes, 0, bytes.length);  // 字节码 → Class 对象
    }
}

三个关键动作各管一件事:① findLoadedClass 保证同一个加载器不会对同一个类加载两次(类唯一性的第一道闸);② parent.loadClass() 把请求向上抛,直到 Bootstrap 用 C++ 直接加载或抛异常;③ findClass() 是"自己找"的钩子,默认不干活,重写它才让自定义加载器有存在意义——而把字节码变成 Class 对象的那一步叫 defineClass,它是受保护的,只有 ClassLoader 自己能定义类。

Bootstrap(C++) java.base 核心模块 委派 ↑ ↑ 找不到才回自己 Platform / Extension 平台模块 / 扩展目录 委派 ↑ AppClassLoader classpath 自定义 ClassLoader(可多层) loadClass(name) 执行顺序 ① findLoadedClass:查过没? ② parent.loadClass:问父亲 ③ findClass:父亲找不到,自己找 (找不到 → ClassNotFoundException)
图 2 双亲委派链:请求向上委派,逐级找不到才自己 findClass
易混点 1:"双亲"其实是多级

叫"双亲委派"不是因为只有一层父亲,而是每个加载器只认识它直接的父亲——App 只把请求交给 Platform,Platform 交给 Bootstrap,递归上去而已。自定义加载器下面还可以再挂自定义,链有多长都行。

易混点 2:委派关系 ≠ 继承关系

AppClassLoader 的 Java 继承链是 AppClassLoader → URLClassLoader → SecureClassLoader → ClassLoader,和 Bootstrap 没有半点继承关系(Bootstrap 是 C++)。"委派链"是构造时传入 parent 参数决定的——new ClassLoader(null) 时 parent 默认是 AppClassLoader(ClassLoader.getSystemClassLoader()),但完全可以传任意加载器。两条链在当前 JVM 里恰好重合,但本质是两回事。

一句话核心

loadClass 三步:findLoadedClass(查缓存)→ parent.loadClass(向上委派)→ findClass(自己找)+ defineClass(字节码变 Class)。"类只加载一次"由第①步保证,"核心类不可篡改"由第②步保证。

第 4 站

为什么要双亲委派:两大收益

委派模式不是随便设计的,它解决两个真实问题:

收益一:安全——核心 API 不可篡改

假设你(或某个恶意 jar)在自己的 classpath 里放了一个 java/lang/String.class。没有双亲委派的话,AppClassLoader 会直接把它加载进来,从此 String 的行为由你定义——整个 JVM 的世界观被改写。有了双亲委派:业务代码引用 java.lang.String 时,请求从 App 一路委派到 Bootstrap,Bootstrap 从核心类库加载了真正的 String 并返回;你的那份字节码永远轮不到被加载。核心 API 的安全性,靠的就是这条"向上不可逆"的链。

收益二:唯一性——同一个类只加载一次

同一个全限定名 com.foo.Util,无论从哪个 classpath 目录、哪个 jar 里找,最终都由同一个加载器加载一次,全 JVM 共享同一个 Class 对象。这保证了 == 比较、instanceof 判断、强制类型转换都有确定的语义。反例最能说明问题:

高频事故:两个加载器各加载一份"同名类"

让两个不同的自定义加载器各自 defineClass 一份 com.foo.Util,会得到两个不同的 Class 对象——即便字节码一模一样。此时 loaderA.load("com.foo.Util") == loaderB.load("com.foo.Util") 为 false,互相强转直接 ClassCastException。这就是"jar 冲突 / 类重复 / 模块化隔离失败"一类事故的根因:在 JVM 里,类的身份 = 全限定名 + 定义它的 ClassLoader,两者缺一不可。

一句话核心

双亲委派给 JVM 两样东西:核心 API 的防篡改(安全)+ 类的唯一身份(同一加载器同一类只有一份 Class)。类身份 = 全限定名 + ClassLoader,这是理解后面三种破坏场景的钥匙。

第 5 站

破坏场景一:SPI 与线程上下文类加载器

先看一个真实矛盾:JDBC 的 DriverManagerjava.sql 包里,属于 Bootstrap 层(JDK8 的 rt.jar);而驱动实现 com.mysql.cj.jdbc.Driver 在 mysql-connector jar 里,属于 classpath(App 层)。DriverManager 初始化时要扫描并加载所有驱动——可 Bootstrap 的委派方向是向上的,它根本"够不到"下层的驱动类。怎么办?

JDK 的答案是线程上下文类加载器(Thread Context ClassLoader,TCCL):每个 Thread 上挂一个 ClassLoader 引用,默认是 AppClassLoader,可用 Thread.setContextClassLoader() 修改。核心层的代码拿 TCCL,就相当于借了子加载器的手向下找——这是双亲委派第一次被"温柔地"打破。

DriverManager 如何加载驱动(简化)+ ServiceLoader 用法
// java.sql.DriverManager(JDK 8 简化)
private static final DriverManager INSTANCE = new DriverManager();
static {
    loadInitialDrivers();
}
private static void loadInitialDrivers() {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();  // 借 TCCL(默认 App)
    ServiceLoader<Driver> loaded = ServiceLoader.load(Driver.class, cl);
    for (Driver a : loaded) {          // 逐个实例化并注册
        a;  // 触发 <clinit>,驱动自己在 DriverManager.registerDriver() 登记
    }
}

// 服务提供者侧:mysql-connector.jar/META-INF/services/java.sql.Driver 里写一行
//   com.mysql.cj.jdbc.Driver

// 你的代码里一行就完成"自动装配":
ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);

把这套模式抽出来就是 Java 的 SPI(Service Provider Interface)机制:接口在核心/上层 jar(如 java.sql.Driverorg.slf4j.Logger),实现在业务 jar;实现 jar 在 META-INF/services/ 下放一个与接口全限定名同名的文件,内容是实现类名;调用方用 ServiceLoader.load() 扫描加载。SLF4J/Logback、Dubbo 的扩展点、JDK 的 FileSystem、JAXB 全是这套路。本质都是同一招:用 TCCL 把"向下查找"的能力借给上层

追问:"ServiceLoader 为什么能绕过双亲委派?"

它并没有"绕过"规则本身,而是利用了规则留下的接口:ServiceLoader 的 load(service) 内部调 Thread.currentThread().getContextClassLoader()。TCCL 指向 App,等于"借 App 的手"加载下层类——委派链没被改,只是请求的发起者从"自己委派上去"换成了"找当前线程借一个下层的加载器"。记住这个区分,面试就不会说错。

一句话核心

SPI 打破的是委派的"方向":核心层借 TCCL(默认 App)向下加载驱动/实现类。JDBC、SLF4J、Dubbo 扩展点全部基于这个模式。

第 6 站

破坏场景二:Tomcat 的 WebAppClassLoader

第二个打破场景来自真实需求:一个 Tomcat 里要部署几十个 webapp,每个都可能依赖不同版本的同一个 jar(A 应用要 fastjson 1.2.70,B 应用要 1.2.83)。如果按双亲委派"一律向上",所有 webapp 的类都会落到共享的 App/Common 层加载,版本冲突直接爆炸。所以 Tomcat 自己造了一棵加载器树:

Tomcat 的类加载器层级(缩进表示父子委派关系)
Bootstrap                      # JVM 核心(C++)
└─ System / Common             # catalina 目录,容器公共库(所有 webapp 共享)
   ├─ Catalina                 # Tomcat 自身(server)
   ├─ Shared                   # 可选的公共共享区
   └─ WebAppClassLoader ×N     # 每个 webapp 一个!加载 WEB-INF/classes + WEB-INF/lib
      └─ JASPIC               # JDK 7+ 为访问 jdk.naming 等平台模块插的一层

// WebAppClassLoader 的关键:loadClass 顺序被"反着"写(简化)
protected Class<?> loadClass(String name, boolean resolve) {
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            c = findClass(name);    // ① 先查自己 WEB-INF/classes + WEB-INF/lib(顺序反了!)
        } catch (ClassNotFoundException e) {}
        if (c == null) {
            c = super.loadClass(name, resolve);  // ② 找不到才走标准双亲委派向上
        }
    }
    return c;
}

注意顺序的关键:它先查自己的 WEB-INF,找不到才委派给父。对 java.* / javax.* 这些核心名,Tomcat 会强制继续委派向上(否则又会篡改核心类),但对业务类,它优先自给自足——于是每个 webapp 得到了自己的类空间,同名的第三方 jar 各用各的,互不干扰。

隔离的代价:跨 webapp 传对象会 ClassCastException

A 应用的 com.foo.Entity 由 A 的 WebAppClassLoader 加载,B 应用由 B 的加载器加载同名类——这是两个不同的 Class。若把 A 的对象塞进 request/共享容器里给 B 用,B 一 cast 就抛 CCE。所以跨 webapp 要么传字节流/序列化(各自重建对象),要么把共享对象下沉到 Common 层,让两边用同一个加载器。

一句话核心

Tomcat 破坏的是"方向":每个 webapp 一个 WebAppClassLoader,先查自己、再委派父,换来"同为 classpath、彼此隔离"。代价是跨应用同名类是两个 Class,直接传对象会 CCE。

第 7 站

破坏场景三:OSGi 与热部署

第三个打破场景针对的是另一条约束——"类只加载一次"。热部署(hot swap)要求:不重启 JVM,把某个类的旧版本卸载掉、加载新版本。可 JVM 的 findLoadedClass 会永远返回那份旧 Class,怎么办?答案是:让旧类的加载器整个变成垃圾——每次热部署 new 一个全新的自定义 ClassLoader 去加载新版本类,旧加载器连同它加载的所有旧类就一起失去引用,等着 GC 回收。

这里牵出"类卸载"的三条件(细节见「对象存活判定」的方法区回收节):① 该类的所有实例已回收;② 加载它的 ClassLoader 已回收;③ 该类的 Class 对象不再被引用(无反射、无静态注册表引用)。三者同时满足,旧类元数据才会从 Metaspace 释放。所以热部署的"开关"其实在类加载器上——换 ClassLoader = 换一类身份 = 老身份被 GC

OSGi 和 Tomcat 的隔离有什么不同层次?

Tomcat 是"每个应用一层隔离",加载方向还是相对单一的"先自己后父";OSGi(如 Eclipse Equinox、Apache Felix)把每个 bundle 的类彻底模块化,bundle 可以通过 Import-Package / Export-Package 声明"我依赖谁、我暴露谁",加载器之间是网状、可动态增删的关系,粒度比 webapp 更细,能做到运行中启停某个 bundle。Jigsaw(JDK 9 模块系统)则是把这种模块化收编进了语言和平台,parent 委派和模块解析结合。

高频事故:热部署后 Metaspace 只涨不跌

热部署很多次后 Metaspace 一直涨、最后 OutOfMemoryError: Metaspace,九成是旧类没卸载成:某个静态变量 / ThreadLocal / 全局注册表(JMX MBean、DriverManager.registerDriver、反射缓存 Method 对象)还拽着旧 Class 或旧 ClassLoader。排查和修复思路见「内存泄漏排查」的 Metaspace 节,核心就一句:让旧加载器真正不可达

一句话核心

热部署打破"只加载一次":每次换一个新 ClassLoader 加载新版本,旧加载器连同旧类一起变垃圾被回收。类卸载三条件(实例/加载器/Class 引用)缺一不可。

第 8 站

总结:一张地图 + 面试模板

知识地图:类加载三阶段(加载 → 链接[验证/准备/解析] → 初始化)→ 四层加载器(Bootstrap → Platform → App → 自定义)→ loadClass 三步(findLoadedClass → parent.loadClass → findClass/defineClass)→ 两大收益(安全 + 唯一性)→ 三种破坏场景(SPI/TCCL 破坏方向、Tomcat 破坏隔离、热部署破坏"只加载一次")。

  • 核心机制:类身份 = 全限定名 + 定义它的 ClassLoader;委派关系由构造时 parent 决定,与 Java 继承链是两回事。
  • 两大收益:核心 API 防篡改(安全)、同一类唯一 Class 对象(唯一性)。
  • 三种破坏:SPI 借 TCCL 向下找实现;Tomcat 每 webapp 独立加载器、先己后父;热部署换加载器让旧类整体回收。

面试回答模板

30 秒版

"类加载分加载、链接、初始化三阶段。JVM 用四层加载器加双亲委派:接到加载请求先查缓存,再逐级委派给父加载器,都找不到才自己 findClass。好处是两个——核心 API 不被篡改、同一个类全局唯一。生产里被打破三次:SPI 用线程上下文加载器向下加载实现、Tomcat 每个 webapp 一个加载器先查自己实现隔离、热部署靠换加载器让旧类整体卸载。"

3 分钟版(展开)

把三阶段说全,再把 loadClass 三步打开讲:① findLoadedClass 是"类唯一性"的第一道闸;② parent.loadClass 向上委派,保证了 java.lang.String 只能由 Bootstrap 加载、永远轮不到 classpath 里的假 String;③ findClass+defineClass 是自定义加载器的意义所在。然后强调"委派链不是继承链"——委派关系由构造时 parent 决定,Bootstrap 是 C++ 写的、Java 里 getClassLoader 返回 null。最后落到三个破坏场景,每个用一句讲清它破坏了哪条规则、代价是什么:SPI/TCCL(向下借力,JDBC/SLF4J/Dubbo 都在用)、Tomcat(先己后父换隔离,代价是跨应用传对象 CCE)、热部署(换加载器触发类卸载,代价是 Metaspace 管理要小心)。

高频追问表

追问答案要点
双亲委派是强制的吗?不是,是 JVM 规范建议的实现方式;想打破就自己重写 loadClass(Tomcat 就是这么干的)
为什么 String 的 getClassLoader() 是 null?String 由 Bootstrap 加载,而 Bootstrap 是 C++ 实现,Java 里不存在对应对象,只能返回 null
两个加载器加载同名类会怎样?得到两个不同的 Class 对象,互相不可见,强转抛 ClassCastException
DriverManager 怎么加载到驱动?用 ServiceLoader + 线程上下文类加载器(TCCL)向下借力,核心层才够得到 classpath 里的驱动实现
热部署为什么必须换 ClassLoader?类卸载条件之一是"加载它的 ClassLoader 已回收",换新加载器才能让旧类整体失去引用被 GC
怎么查某个类被谁加载?xxx.getClass().getClassLoader() 看加载器;生产里 jcmd <pid> VM.classloader_stats 看各加载器的类统计
离场前记一句

类加载是"名词 + 动词":名词是那条四层委派链,动词是 loadClass 三步。三种破坏场景共同说明一个道理——双亲委派是默认解,不是唯一解,理解它才能判断什么时候该变、变了要付什么代价。

Comments · 评论