👋 你好,欢迎来到我的博客!我是【菜鸟不学编程】
   我是一个正在奋斗中的职场码农,步入职场多年,正在从“小码农”慢慢成长为有深度、有思考的技术人。在这条不断进阶的路上,我决定记录下自己的学习与成长过程,也希望通过博客结识更多志同道合的朋友。
  
  🛠️ 主要方向包括 Java 基础、Spring 全家桶、数据库优化、项目实战等,也会分享一些踩坑经历与面试复盘,希望能为还在迷茫中的你提供一些参考。
  💡 我相信:写作是一种思考的过程,分享是一种进步的方式。
  
   如果你和我一样热爱技术、热爱成长,欢迎关注我,一起交流进步!

前言 😏

讲真,Java 里最让人心态爆炸的报错之一,永远有它一席之地:ClassNotFoundException / NoClassDefFoundError
  它们像啥?像你明明把钥匙放桌上了,结果出门时发现门锁换了、钥匙孔还变形了,然后你开始怀疑人生:“我不是放这儿了吗?!”😵‍💫
  别慌,这篇我按你给的大纲,从类加载五阶段讲到三大类加载器,再聊双亲委派为啥能保命,最后落到实战:自定义类加载器 + 热部署思路 + 插件系统的类隔离。我会尽量用“人话 + 代码 + 真实坑位”把这套东西讲透,读完你至少能做到:线上遇到类加载问题不再只会“清缓存重启”。😅

I. 类加载过程:加载、验证、准备、解析、初始化(这五步谁在干活?)

Java 的“类加载”并不是一次性把 class 文件塞进内存就完事了,它有一套严谨流程。你可以把它想成“办身份证”:

  1. 加载(Loading):把 .class 的字节流拿过来,生成 Class<?> 对象
  2. 验证(Verification):检查字节码是否合法、不会搞破坏(安全关卡)
  3. 准备(Preparation):给静态变量分配内存并赋默认值(注意:是默认值,不是你写的初始化值)
  4. 解析(Resolution):把常量池里符号引用替换成直接引用(比如把“类名字符串”变成真实指针/引用)
  5. 初始化(Initialization):执行 <clinit>(静态变量赋值 + 静态代码块)

1.1 一个最容易记混的点:准备 vs 初始化

看代码:

public class Demo {
    static int a = 10;
    static final int B = 20;
    static final Integer C = 30;

    static {
        System.out.println("static block");
    }
}
  • 准备阶段a 会先被分配内存并设为 0(默认值)
  • 初始化阶段a 才会变成 10,并执行 static block

static final int B = 20 这种编译期常量可能会被内联到使用它的类里,甚至不触发 Demo 初始化(这坑很经典:你以为加载了,其实没加载)。
static final Integer C = 30 通常不是编译期常量(对象包装类型),更可能触发初始化。

1.2 初始化到底什么时候发生?(别靠猜)

会触发类初始化的典型情况(常见“主动使用”):

  • new Demo()
  • 访问/赋值类的非编译期常量静态字段
  • 调用类的静态方法
  • Class.forName("Demo")(默认会初始化,除非指定不初始化)
  • 初始化子类时会先初始化父类

II. 类加载器:Bootstrap、Extension 和 Application(它们谁管谁?)

JVM 里类不是“随便加载”,是由不同类加载器分层负责。传统说法有三类(你大纲里也是这个):

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

    • 加载 JVM 核心类库,比如 java.lang.*
    • 通常由 JVM 实现(不是 Java 对象),你在 Java 里看到它常常是 null
  2. Extension ClassLoader(扩展类加载器)

    • 老版本主要加载 jre/lib/ext 下扩展库
    • 在更现代的 JDK 里,这块机制弱化了(模块系统上来后玩法变了些)
  3. Application ClassLoader(应用类加载器)

    • 加载 classpath 上的应用类
    • 也是你最常接触的那个

小提醒:现代 JDK(9+)引入模块系统后,Extension 的角色和实现细节有变化,但“分层加载”和“委派”思想依旧成立。工程上你理解这三层已经够用。

2.1 打印类加载器(你别光背,跑一下就记住了)

public class LoaderPrint {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader()); // null -> Bootstrap
        System.out.println(LoaderPrint.class.getClassLoader()); // AppClassLoader
        System.out.println(ClassLoader.getSystemClassLoader()); // 系统类加载器(通常就是 App)
    }
}

III. 双亲委派模型:为什么它能避免重复加载,还能保安全?🛡️

双亲委派(Parent Delegation)简单讲就是:

我收到一个类加载请求,先不急着自己加载,先问我爹;爹不行我再自己上。

好处有两个,特别实在:

3.1 避免重复加载(类的唯一性更稳)

同一个类名如果被不同加载器加载,会产生“同名不同类”的现象。
JVM 判断两个类是不是同一个,不只看类名,还看:
类的全限定名 + 定义它的类加载器

所以双亲委派能在很大程度上保证核心类由上层统一加载,减少“你加载一份、我加载一份”的混乱。

3.2 安全:防止你自己写个 java.lang.String 来钓鱼

如果没有委派机制,你完全可以在 classpath 里放一个自定义的 java.lang.String,然后让它先被应用类加载器加载——那 JVM 世界直接乱套。
有了双亲委派:

  • 先委派给 Bootstrap
  • Bootstrap 已经加载了真正的 java.lang.String
  • 你那个“山寨 String”根本没机会

所以双亲委派本质上是一条安全边界:
核心类库由更“可信”的加载器负责

IV. 自定义类加载器:从 URLClassLoader 扩展(插件系统的第一块积木)

当你想实现“插件化”“隔离依赖”“热加载”等能力时,自定义类加载器几乎绕不开。最常见的思路是:用 URLClassLoader 去加载某个目录或 jar。

4.1 一个简单的 PluginClassLoader(基于 URLClassLoader)

import java.net.URL;
import java.net.URLClassLoader;

public class PluginClassLoader extends URLClassLoader {
    public PluginClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
    }
}

加载 jar 并实例化类:

import java.net.URL;
import java.nio.file.Path;

public class PluginLoadDemo {
    public static void main(String[] args) throws Exception {
        Path jar = Path.of("plugins", "demo-plugin.jar");
        URL url = jar.toUri().toURL();

        try (PluginClassLoader pcl = new PluginClassLoader(new URL[]{url},
                ClassLoader.getSystemClassLoader())) {

            Class<?> clazz = pcl.loadClass("com.example.plugin.HelloPlugin");
            Object instance = clazz.getDeclaredConstructor().newInstance();

            System.out.println("Loaded by: " + clazz.getClassLoader());
            System.out.println("Instance: " + instance);
        }
    }
}

注意:

  • URLClassLoader 可关闭(close()),这对释放 jar 文件句柄很关键(尤其 Windows 上不关就删不掉 jar,很烦😑)。
  • 关闭不等于“卸载类”,类卸载取决于类加载器是否可被 GC(后面热部署会讲)。

4.2 “打破双亲委派”的需求:Child-First(谨慎!)

插件系统常见痛点:插件依赖的某个库版本与主应用不同。
如果完全用 parent-first,主应用的版本会先被加载,插件就可能炸(版本冲突)。
所以有的插件加载器会使用“child-first”策略:

  • 先尝试自己加载
  • 再委派给父加载器

这能解决冲突,但也会引入安全与一致性风险。真要这么干,最好把“哪些包走 child-first”做白名单控制(例如仅对 com.plugin.* 或特定三方包)。

V. 热部署:类重载、OSGi(以及你以为的热更新,可能只是“换皮重启”😅)

先泼一盆冷水:
JVM 里“同一个类加载器”加载过的类,通常不能被真正替换。
你改了 class 文件,老类还在那儿,除非:

  • 使用 JVM 的 Instrumentation(HotSwap)能力(但限制很多,改方法体还行,改结构/字段不一定行)
  • 或者更常见的工程做法:新建一个类加载器加载新版本类,旧的类加载器丢掉,等 GC 回收(间接实现“重载”)

5.1 “类重载”的核心:不是重载类,是重建类加载器

热部署常用套路:

  • 每次加载插件:创建一个新的 PluginClassLoader
  • 旧插件停止使用(解除引用)
  • PluginClassLoader 变得不可达
  • GC 回收它后,相关类也可以被卸载

最关键的是:必须解除对旧 ClassLoader 和旧 Class 的所有强引用
否则你会得到一个经典灾难:插件更新了,但老版本类一直在内存里不走,最后变成“内存漏水”。😵‍💫

5.2 OSGi:插件系统“老牌硬核方案”

OSGi 是一个成熟的模块化框架,它把“bundle(模块)”的生命周期、依赖关系、类隔离做得很系统。
但它的学习和运维成本也不低:

  • 概念多、配置多
  • 对团队要求高
    如果你只是需要轻量插件系统,用自定义 ClassLoader + 约定式 SPI 往往更快落地。

VI. 应用:插件系统的类隔离(真正难的是“边界”不是“加载”)

你要做插件系统,最容易掉进的坑不是“怎么 loadClass”,而是:
插件和主应用到底共享什么?隔离什么?如何通信?

一个更稳的设计方式是:

6.1 约定一个“公共 API 层”(由主应用加载)

比如定义一个接口 Plugin,放在主应用的 classpath 里:

public interface Plugin {
    String name();
    void start();
    void stop();
}

插件 jar 里实现它:

public class HelloPlugin implements Plugin {
    public String name() { return "hello"; }
    public void start() { System.out.println("HelloPlugin start"); }
    public void stop() { System.out.println("HelloPlugin stop"); }
}

主应用用 PluginClassLoader 加载实现类后,向上转型为主应用的 Plugin 接口

Class<?> c = pcl.loadClass("com.example.plugin.HelloPlugin");
Plugin p = (Plugin) c.getDeclaredConstructor().newInstance();
p.start();

这里有个非常关键的原则:

接口(共享类型)必须由主应用类加载器加载,插件只负责实现。

否则你会遇到一个特别诡异的错误:
同名接口 Plugin,主应用和插件各加载一份,导致 (Plugin) obj 转型失败,报 ClassCastException
你会怀疑:我明明实现了接口啊?
JVM 回你一句:对不起,你实现的是“另一个世界的 Plugin”。🙃

6.2 类隔离策略(建议)

  • 主应用:加载公共 API、框架、核心依赖
  • 插件:尽量打包自己的三方依赖(shade/relocate),避免和主应用版本冲突
  • 若必须共享某些库:明确“哪些包走 parent-first,哪些包走 child-first”,别放任混乱

小结:类加载机制这东西,学会了是真的能救命✅

很多线上问题表面是“依赖没引入”,本质可能是:

  • 类被不同加载器加载了(同名不同类)
  • 委派顺序导致版本冲突
  • 热部署没解除引用导致旧类不卸载
  • 插件系统边界没设计好导致转型失败

你把类加载机制理解透了之后,再看这些报错就不会只剩一句“重启试试”。
你会知道:问题出在哪一层,应该从哪个 ClassLoader 查起。🙂

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:某个被流“治愈”过的 Java 老兵
📅 日期:2026-01-07
🧵 本文原创,转载请注明出处。

更多推荐