首页 / Java 学习笔记 / 33

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

自定义类加载器:热部署与模块化隔离

高级中高#JVM#类加载
第 1 站

开场:类加载器的三个方法分工

上一篇「类加载机制」讲的是整条委派链和三种破坏场景,这一篇落到"我自己写一个加载器"这层。动手之前,先把 ClassLoader 里三个方法的分工吃透——面试里"这三者区别"是送分题,也是后面所有代码的地基。

ClassLoader 三个关键方法的职责
// ① loadClass:模板方法(委派的载体),继承自 ClassLoader,一般不要重写
protected Class<?> loadClass(String name, boolean resolve) {
    Class<?> c = findLoadedClass(name);   // ① 查缓存
    if (c == null) { c = parent.loadClass(name, resolve); }  // ② 委派父
    if (c == null) { c = findClass(name); }          // ③ 自己找
    return c;
}

// ② findClass:真正的"查找逻辑"——你按自己的路径去把字节码读回来
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    byte[] b = readFromMySource(name);        // 网络 / 磁盘 / 内存 / 加密文件,随你
    return defineClass(name, b, 0, b.length);   // ③ 交给 defineClass
}

// ③ defineClass:protected,把 byte[] 变成真正的 Class 对象——唯一能"制造"类的入口
//    只能在 ClassLoader 内部调用,防止外部代码随意定义类、破坏封装
遵守双亲委派 → 只重写 findClass()(委派交给 loadClass 模板做)
打破双亲委派 → 必须重写 loadClass(),且不能调 super.loadClass()(自己掌控顺序)

理解这三个方法的分工,一个心智模型就够了:loadClass 是"决策者"(先问谁、再问谁),findClass 是"搬运工"(字节码从哪来),defineClass 是"工厂"(把字节码铸造成类)。绝大多数自定义加载器只需要当"搬运工"——重写 findClass,其余交给 loadClass 的模板流程;只有要改变委派顺序(破坏双亲委派)时才动 loadClass。

loadClass 决策者:查缓存 → 委派父 → 自己找 找不到时调用 findClass 搬运工:从网络/磁盘/内存读字节码 读回 byte[] 后调用 defineClass 工厂:byte[] → Class 对象(protected) 返回 Class,登记到当前加载器的已加载列表 → 下次 findLoadedClass 命中 你通常只需要重写 findClass
图 1 loadClass 决定顺序,findClass 找字节码,defineClass 生成类
一句话核心

loadClass = 委派的"决策者"(模板方法);findClass = "搬运工"(你重写它定义字节码来源);defineClass = "工厂"(protected,byte[] 变 Class)。守规矩只重写 findClass,破规矩才重写 loadClass。

第 2 站

第一个自定义加载器(目录版,完整可跑)

照上面的分工,写一个只重写 findClass 的加载器,它从指定目录加载 class 文件:

MyClassLoader.java —— 从目录加载类 + 用两个实例演示类隔离
import java.nio.file.*;

public class MyClassLoader extends ClassLoader {
    private final Path root;   // 类文件的根目录

    public MyClassLoader(String dir) {
        super();                 // parent 默认 = 系统加载器(App)
        this.root = Paths.get(dir);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // com.foo.Bar → root/com/foo/Bar.class(全限定名里的点换成路径分隔符)
            Path p = root.resolve(name.replace('.', '/') + ".class");
            byte[] bytes = Files.readAllBytes(p);   // 读字节码
            return defineClass(name, bytes, 0, bytes.length);  // 铸成 Class
        } catch (Exception e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

// 演示:两个加载器实例加载"同一个类",得到两个不同的 Class
MyClassLoader l1 = new MyClassLoader("/data/plugins/v1");
MyClassLoader l2 = new MyClassLoader("/data/plugins/v1");  // 同一个目录也没用
Class<?> c1 = l1.loadClass("com.foo.Bar");
Class<?> c2 = l2.loadClass("com.foo.Bar");
System.out.println(c1 == c2);                       // false!两个不同的 Class
System.out.println(c1.getClassLoader() == c2.getClassLoader());  // false

两个细节别忽略:① findClass 里的字节码要显式读出并交给 defineClass——别手滑写成 getResourceAsStream 那一套再拼 URL,两者的"资源定位"和"类定义"是两回事;② defineClassfinal 之外的 protected,你在子类里能调,但类的外部代码调不到——这个"只有 ClassLoader 能定义类"的设计正是安全管理的第一道防线。

为什么两个实例指向同一个目录,loadClass 还是返回两个 Class?

因为"是否已加载"是记录在每个 ClassLoader 实例自己的缓存里的(findLoadedClass 查的是自身的 hash 表)。两个实例的缓存互不相通,各 define 一次,自然产出两个 Class 对象。这也顺势引出下一篇要展开的命题:类的身份由"全限定名 + ClassLoader"共同决定。

一句话核心

自定义加载器最小实现 = 继承 ClassLoader + 重写 findClass:定位字节码 → readAllBytes → defineClass。同一个类用两个加载器实例加载,得到两个不同 Class,隔离由此而来。

第 3 站

类隔离:为什么"同名 ≠ 同一类"

JVM 里一个类的唯一身份是「全限定名 + 定义它的 ClassLoader」。全限定名 com.foo.Bar 只是索引的其中一半,另一半是"谁把它定义出来的"。

高频掉坑:只记"全限定名唯一"

面试问到"类隔离的原理",若你只答"因为类名不同"就露馅了。正确的是:两个类即便字节码一模一样、全限定名一模一样,只要定义它们的 ClassLoader 不同,就是两个独立类型,互不可见,互相强转抛 ClassCastException。全限定名相同 ≠ 同一类。

正是这个"半张身份证在加载器手里"的设计,让类隔离成了插件体系的基石。IDEA 插件、OSGi Bundle、SpringBoot 的 LaunchedURLClassLoader,原理全是同一句话:每个插件/模块用各自的 ClassLoader,各加载各的那份依赖,互不污染

父加载器(Bootstrap / App / 公共库) 可见性:向上(子能看见我) 插件 A 的 ClassLoader fastjson 1.x / 自己的类 插件 B 的 ClassLoader fastjson 2.x / 自己的类 互不可见 隔离的收益:fastjson 1.x 和 2.x 各自共存,互不冲突 代价:跨插件传递对象要用父加载器加载的公共接口做边界(SPI 模式)
图 2 可见性方向:子能见父,父不见子,兄弟加载器互不可见

这套"子见父、兄弟互不可见"的规则,直接决定了第三方库版本冲突的标准解法:让依赖冲突的两个模块各用各的 ClassLoader 加载各自的版本。但隔离不是免费的——插件 A 想把对象交给插件 B,不能直接传(两边类型不同),要绕道到父加载器里那个公共接口:接口定义下沉到公共层,实现分别留在各自插件里,跨插件时只暴露接口类型。这就是 SPI 思想在模块化里的沿用(接口契约见「类加载机制」的 SPI 场景)。

一句话核心

类身份 = 全限定名 + ClassLoader。可见性规则:子能见父、父不见子、兄弟互不可见。插件隔离与依赖冲突解法都建立在这条规则上;跨插件通信靠下沉到公共父加载器的接口。

第 4 站

打破双亲委派:重写 loadClass

要改变委派顺序,就不能只当"搬运工"了,得把 loadClass 这个"决策者"也接管过来。看两种写法的对比:

保守 vs 激进:重写 findClass 还是重写 loadClass
// 保守写法:仍遵守双亲委派,父先找,找不到才轮到自己
class ConservativeLoader extends ClassLoader {
    @Override protected Class<?> findClass(String n) { /* 只在这里找字节码 */ }
    // loadClass 不重写 → 默认顺序:findLoadedClass → parent → findClass
}

// 激进写法:先自己找,找不到才委派(破坏双亲委派)
class ChildFirstLoader extends ClassLoader {
    public ChildFirstLoader(ClassLoader parent) { super(parent); }

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        // ⚠️ 第①步必须先查缓存!漏掉会触发重复 define → LinkageError
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try { c = findClass(name); }             // ① 先自己找
            catch (ClassNotFoundException e) {}
        }
        if (c == null) { c = super.loadClass(name, resolve); }  // ② 找不到才向上委派
        return c;
    }
}

什么时候必须用激进的"子优先"?三个典型场景:① 自定义命名空间——加载不在 JDK 核心路径下的类,反而希望优先用自己那份(比如容器里某个包要覆盖 classpath 里的版本);② 热部署——新版本类必须由新加载器自己加载,不能让父加载器把旧版本甩回来;③ 安全沙箱——先查自己受控的字节码源,防止恶意类顺着委派路径加载。

高频事故:漏了 findLoadedClass → LinkageError

重写 loadClass 时如果第一步不调 findLoadedClass(name),同一个名字就可能被 defineClass 两次,JVM 抛 LinkageError: attempted duplicate class definition——连 catch Exception 都拦不住(它是 Error)。所以任何自定义 loadClass 的开头,第一行必须是查缓存,这是硬规矩。

一句话核心

守双亲委派重写 findClass,破双亲委派重写 loadClass(先己后父)。重写 loadClass 第一步必须 findLoadedClass 查缓存,否则 duplicate class definition。

第 5 站

热部署:ClassLoader 是"一组类"的生命周期单位

热部署(不重启 JVM 换代码)的本质,不是"重新加载单个类",而是换掉整组类。原理链:换代码 → 旧类必须可卸载(类卸载三条件:所有实例已回收 + 加载它的 ClassLoader 已回收 + 无 Class 对象引用,详见「对象存活判定」)→ 每次部署 new 一个 ClassLoader → 旧加载器失去引用 → 旧类整批变垃圾被 GC。ClassLoader 在这里不是一个"工具",而是一个生命周期容器:一个类的死期,和加载它的那个 ClassLoader 的死期绑在一起。

简易热加载 demo:监听目录 → 新加载器 → 反射执行 → 弃旧
// 骨架:每次检测到 class 文件变化,就用全新 ClassLoader 重新加载并执行
MyClassLoader currentLoader = null;   // 持有"当前"加载器的引用

while (true) {
    if (dirChanged()) {                       // 目录里 class 文件变了
        MyClassLoader newLoader = new MyClassLoader("/data/app");  // 全新加载器
        Class<?> c = newLoader.loadClass("com.foo.Worker");   // 加载新版本
        Object worker = c.getDeclaredConstructor().newInstance();  // 反射实例化
        c.getMethod("run").invoke(worker);             // 反射执行
        currentLoader = null;               // 丢弃旧加载器引用 → 旧类整批可回收
        currentLoader = newLoader;           // 换上新加载器
        // (真实框架会用版本号/守护线程池管理,这里只展示核心思想)
    }
    Thread.sleep(3000);
}

关键认知:JVM 本身没有"热替换单个类"的标准 APIInstrumentation.redefineClasses 能做方法体替换,但字段/方法签名的增删受限于 HotSwap 约束,主流生产热部署也不靠它)。真正工程上可靠的热部署,是"类加载器 + GC"配合出来的技巧——让旧加载器整体不可达,交给 GC 收尸。说"热部署 = 换 ClassLoader",不是比喻,是字面事实。

一句话核心

热部署 = 每次部署换一个新 ClassLoader 加载新版本,让旧加载器连同旧类一起被 GC。类卸载三条件是前提;ClassLoader 是"一组类"的生命周期单位。

第 6 站

热部署三大泄漏坑

热部署代码写对了,往往还是"换不起来"——原因几乎总是:旧 ClassLoader 并没有真正不可达,有东西还拽着它(或拽着它加载的旧类),旧类元数据于是永远留在 Metaspace,越热更越涨。三条最常见的"锁链":

坑一:静态字段持有旧类实例

一个 static 字段的生命周期,等于定义它的那个 Class 的生命周期,而那个 Class 又等于它所属 ClassLoader 的生命周期——静态变量不清,旧类就死不了。解法:全局注册表/单例用 WeakReference 持有,或每次部署前显式清空 static 状态(reset() 钩子)。

坑二:ThreadLocal 泄漏

线程池里常驻线程的 ThreadLocalMap,Entry 的 key 是弱引用但 value 是强引用。业务代码若在 ThreadLocal 里塞了旧类的实例(或 value 的对象图里间接引用旧类),这条"线程 → ThreadLocalMap → value → 旧类实例 → 旧 Class → 旧 ClassLoader"的链就把旧加载器焊死了。解法:用完 remove()(完整分析见并发卷 ThreadLocal 篇)。

坑三:注册表泄漏

凡是"全局登记"的动作,都要配对 unregisterDriverManager.registerDriver、JMX MBean、Timer 定时任务、反射的 Method 缓存(Method 对象持有声明它的 Class,Class 又持有 ClassLoader)。漏一个,旧加载器就多一条活路。排查手段见「内存泄漏排查」的 Metaspace 节。

旧 ClassLoader (本该回收,却被拽住) ① 静态注册表 / 单例 static field → 旧类实例 ② 线程池 ThreadLocalMap value → 旧类实例 ③ 注册表 / 方法缓存 DriverManager / JMX / Method 后果:旧类不卸载 → Metaspace 只涨不跌 → 终将 OutOfMemoryError: Metaspace
图 3 三条把旧 ClassLoader 焊死的锁链
一句话核心

热部署换不掉的罪魁:静态字段(旧类实例)、ThreadLocal(常驻线程持有 value)、注册表/方法缓存(DriverManager、JMX、Method)。每处都要配"清理/解注册/WeakReference",让旧加载器真正不可达。

第 7 站

实战形态对比

框架 / 场景加载器形态隔离粒度要点
Tomcat每个 webapp 一个 WebAppClassLoader按应用先己后父;公共库放 Common 层共享(呼应「类加载机制」)
SpringBoot 2.3+JarLauncher + LaunchedURLClassLoader按应用支持嵌套 jar 内定位资源,可执行 fat jar 能直接 java -jar 运行
OSGi(Equinox/Felix)每个 bundle 一个 BundleClassLoader按包(package)Import/Export-Package 声明依赖,比 Tomcat 更细的"兄弟可见"控制,可运行中启停 bundle
DubboModuleClassLoader(每模块独立)按模块服务定义与依赖各自隔离,部署时按模块加载
Kubernetes / 容器JVM 进程级隔离按进程不在 JVM 内做类隔离,直接隔离整张 JVM——本质是"更大粒度的热部署"

从这张表能看出一个规律:隔离粒度越细(Tomcat 按应用 → OSGi 按包),灵活性越高,但复杂度与出错面也越大。选择时先问自己"冲突发生在哪一层"——依赖冲突多在应用层,Tomcat 式按应用隔离就够;要做插件市场、细粒度共享,才需要 OSGi 这种包级隔离;而到了云原生,隔离干脆交给 K8s 的进程边界,JVM 内部的类隔离退居其次。它们共享的底层原理没有变:ClassLoader 是隔离的最小单元

"真正的热部署,在 JVM 里是不是只有换 ClassLoader 这一条正路?"

工程上基本如此。Instrumentation.redefineClasses 能做方法体替换,但受 HotSwap 限制(不能增删字段、不能改签名),只能覆盖"改逻辑"这一小类场景;JPDA 调试器是在另一个维度(调试态)工作,不适合生产。生产热部署的可靠做法就是"换 ClassLoader":要么在 JVM 内换(Tomcat/OSGi),要么干脆换整张 JVM(蓝绿发布、K8s 滚动)。

一句话核心

Tomcat 按应用、SpringBoot 支持嵌套 jar、OSGi 按包、Dubbo 按模块、K8s 按进程——粒度不同,但都围绕"ClassLoader 是隔离的最小单元"这一条。

第 8 站

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

知识地图:三方法分工(loadClass 决策 / findClass 搬运 / defineClass 工厂)→ 类身份 = 全限定名 + ClassLoader → 类隔离的可见性规则(子见父、父不见子、兄弟互不可见)→ 打破委派(重写 loadClass,先己后父)→ 热部署原理(换 ClassLoader 让旧类整体回收)→ 三大泄漏坑(静态字段 / ThreadLocal / 注册表)。

  • 动手:自定义加载器最小实现 = 继承 ClassLoader + 重写 findClass(readAllBytes + defineClass)。
  • 隔离:插件体系靠"各插件各 ClassLoader"隔离依赖;跨插件通信靠父加载器的公共接口 + SPI。
  • 热部署:每次部署新加载器,旧加载器不可达 → 旧类整批 GC;三条件缺一不可,三类泄漏逐个排查。

面试回答模板

30 秒版

"自定义类加载器只要读懂三个方法:loadClass 负责委派顺序,findClass 负责找字节码(一般重写它),defineClass 把字节码铸成类(protected)。类的唯一身份是全限定名加 ClassLoader,所以同一个类用两个加载器各加载一份就是两个独立类型——这是插件隔离的基石。热部署的本质是每次部署换一个新 ClassLoader,让旧加载器连同旧类一起变垃圾被 GC。"

3 分钟版(展开)

先讲三方法分工和"守住双亲委派只重写 findClass、打破才重写 loadClass"这条判断线;再讲类身份二元组和可见性规则,举 fastjson 版本冲突被"各加载各的"解决的例子;热部署展开成一条链:换代码 → 旧类卸载三条件 → 新加载器 → 旧加载器失引用 → 旧类整体回收;最后主动抛出三大泄漏坑(静态字段、ThreadLocal、注册表),配一句"Metaspace 只涨不跌就查这三条链,用 jcmd VM.classloader_stats 看存活加载器数量,用 MAT 找谁持有旧加载器"。这一套下来,既展示了理解深度,又展示了排障经验。

高频追问表

追问答案要点
重写 findClass 和重写 loadClass 的区别?前者不改委派顺序(只是"从哪找字节码");后者掌握委派顺序(先己后父 = 打破双亲委派)
defineClass 为什么是 protected?防止外部代码随意"造类"破坏封装与安全,只有 ClassLoader 内部(findClass 里)能调用
热部署后 Metaspace 不降怎么排查?jcmd <pid> VM.classloader_stats 看存活加载器数是否持续涨;MAT 查 GC Roots 谁持有旧加载器;按三泄漏坑逐个排查
类隔离下跨插件传对象怎么办?把接口下沉到公共父加载器,跨插件只暴露接口类型,走 SPI 模式
SpringBoot 为什么能嵌套 jar 启动?LaunchedURLClassLoader 支持在 jar 内部定位资源,JarLauncher 把依赖嵌套组织起来,实现 java -jar 直接运行 fat jar
离场前记一句

自定义类加载器不是炫技,是插件化、热部署、依赖隔离这些工程能力的共同地基。记住三方法的分工、类身份二元组、换加载器即热部署这三板斧,这一站就能在面试里打穿到底。

Comments · 评论