你以为“类找不到”只是少了个依赖?那你敢不敢先把 Java 类加载机制掰开看看?
👋 你好,欢迎来到我的博客!我是【菜鸟不学编程】
我是一个正在奋斗中的职场码农,步入职场多年,正在从“小码农”慢慢成长为有深度、有思考的技术人。在这条不断进阶的路上,我决定记录下自己的学习与成长过程,也希望通过博客结识更多志同道合的朋友。
🛠️ 主要方向包括 Java 基础、Spring 全家桶、数据库优化、项目实战等,也会分享一些踩坑经历与面试复盘,希望能为还在迷茫中的你提供一些参考。
💡 我相信:写作是一种思考的过程,分享是一种进步的方式。
如果你和我一样热爱技术、热爱成长,欢迎关注我,一起交流进步!
全文目录:
前言 😏
讲真,Java 里最让人心态爆炸的报错之一,永远有它一席之地:ClassNotFoundException / NoClassDefFoundError。
它们像啥?像你明明把钥匙放桌上了,结果出门时发现门锁换了、钥匙孔还变形了,然后你开始怀疑人生:“我不是放这儿了吗?!”😵💫
别慌,这篇我按你给的大纲,从类加载五阶段讲到三大类加载器,再聊双亲委派为啥能保命,最后落到实战:自定义类加载器 + 热部署思路 + 插件系统的类隔离。我会尽量用“人话 + 代码 + 真实坑位”把这套东西讲透,读完你至少能做到:线上遇到类加载问题不再只会“清缓存重启”。😅
I. 类加载过程:加载、验证、准备、解析、初始化(这五步谁在干活?)
Java 的“类加载”并不是一次性把 class 文件塞进内存就完事了,它有一套严谨流程。你可以把它想成“办身份证”:
- 加载(Loading):把
.class的字节流拿过来,生成Class<?>对象 - 验证(Verification):检查字节码是否合法、不会搞破坏(安全关卡)
- 准备(Preparation):给静态变量分配内存并赋默认值(注意:是默认值,不是你写的初始化值)
- 解析(Resolution):把常量池里符号引用替换成直接引用(比如把“类名字符串”变成真实指针/引用)
- 初始化(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 里类不是“随便加载”,是由不同类加载器分层负责。传统说法有三类(你大纲里也是这个):
-
Bootstrap ClassLoader(启动类加载器)
- 加载 JVM 核心类库,比如
java.lang.* - 通常由 JVM 实现(不是 Java 对象),你在 Java 里看到它常常是
null
- 加载 JVM 核心类库,比如
-
Extension ClassLoader(扩展类加载器)
- 老版本主要加载
jre/lib/ext下扩展库 - 在更现代的 JDK 里,这块机制弱化了(模块系统上来后玩法变了些)
- 老版本主要加载
-
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
🧵 本文原创,转载请注明出处。
更多推荐


所有评论(0)