类加载机制与模块系统(Class Loading & JPMS)
开篇语
哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛
今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。
我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!
概览(快速看点)
- Java 类加载分五步:加载 → 验证 → 准备 → 解析 → 初始化。
- ClassLoader 层次:Bootstrap / Platform(Extension) / Application (System)。默认采用 双亲委派(parent-first)。
- 自定义类加载器常用于插件隔离、热替换、动态卸载,但要小心类冲突与ClassLoader 泄露(内存泄漏)。
- JPMS(Java Platform Module System)提供“模块化封装 + 服务声明/发现”的静态保证,但也可以和自定义类加载器结合用于插件化。
- 热部署通常靠“替换 ClassLoader 并丢弃旧 ClassLoader 的引用”或使用
java.lang.instrument做类重定义(有局限:不能删字段/方法签名改变等)。
1. 类加载流程(加载、验证、准备、解析、初始化)
简要流程与每步要点:
-
加载(Loading)
- JVM 找到类的二进制字节码(通常来自
.class文件或 JAR),并由某个 ClassLoader 定位、读取并创建Class的内部表示(java.lang.Class)。
- JVM 找到类的二进制字节码(通常来自
-
验证(Verification)
- 检查字节码结构、类型安全、控制流约束等,防止非法或恶意字节码。
-
准备(Preparation)
- 为类的静态变量(static fields)分配内存并设置默认初始值(0/null/false),但不执行静态赋值语句。
-
解析(Resolution)
- 把符号引用解析为直接引用(例如方法、字段、类引用),依赖的类会被触发加载(可能递归)。
-
初始化(Initialization)
- 执行静态初始化块和静态字段的显式初始化(即
<clinit>方法),在此阶段类才真正“就绪”。
- 执行静态初始化块和静态字段的显式初始化(即
注意:类的初始化是线程安全且按需触发的(首次主动使用时触发,例如 new、调用静态方法或访问静态字段)。
2. 类加载器层次与双亲委派(Parent Delegation)
层次(典型)
- Bootstrap ClassLoader:用本地代码实现,加载核心 JDK 类(
rt.jar或模块运行时)。 - Platform(以前是 Extension)ClassLoader:加载 JDK 平台类(JDK 9+ 中划分不同),可包含平台库。
- Application/System ClassLoader:加载应用类路径(
-cp/CLASSPATH)下的类。 - 自定义 ClassLoader:通常继承
URLClassLoader或ClassLoader,用于加载第三方 JAR/插件。
双亲委派(默认)
加载某个类时,ClassLoader 会先把请求交给父 ClassLoader,父若找不到再自己查找。好处:避免重复加载核心类,保证一致性;坏处:不利于插件“重载/隔离”同名类。
子优先(child-first)或自定义策略
插件系统常需要“子优先”以隔离插件依赖。实现方法通常是重写 loadClass,先尝试自身查找再委派父加载(见后面代码示例)。但要小心核心 API/共享接口类必须来自父加载器,否则会出现 ClassCastException(因为同名类来自不同 ClassLoader 被认为是不同类型)。
3. 自定义类加载器与资源隔离(示例 + 要点)
简单的插件 ClassLoader(基于 URLClassLoader,child-first)
public class PluginClassLoader extends URLClassLoader {
public PluginClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1) 已加载则直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2) 尝试子类加载(避免对核心包做子优先)
if (!name.startsWith("java.") && !name.startsWith("com.myapp.shared")) {
try {
c = findClass(name);
} catch (ClassNotFoundException ignored) {
// ignore -> delegate to parent
}
}
// 3) 委派给父加载器(系统默认行为)
if (c == null) {
c = super.loadClass(name, resolve);
}
}
if (resolve) resolveClass(c);
return c;
}
}
关键点:
findLoadedClass+findClass+super.loadClass的顺序决定 child-first / parent-first 行为。- 必须 避免 对
java.*或应用共享接口包做子优先(否则会产生类型不兼容)。 URLClassLoader自 Java 9 起实现了close(),加载的 JAR 的文件句柄可在close()后释放(有利于卸载/替换)。
插件资源(Properties、静态单例、Thread)
- 插件应尽量避免创建全局静态单例或非守护线程(daemon=false),否则卸载时这些资源会引用 ClassLoader,阻止卸载。
- 任何注册到 JVM 全局表(例如 JDBC DriverManager、MBeanServer)都应在卸载前显式反注册。
4. 模块系统(JPMS)速览:module-info.java、可见性、服务(uses/provides)
JPMS 在 Java 9 引入,目的是为 Java 提供“编译时与运行时的模块化”。它与 ClassLoader 并不完全替代关系,而是叠加在类加载之上。
module-info.java 示例
module com.example.app {
requires java.logging;
requires com.example.api; // 依赖另一个模块
exports com.example.app.api; // 向外暴露包
uses com.example.spi.Plugin; // 声明使用某个服务接口
}
服务(Service Loader)
模块可以声明 provides ... with ... 来实现服务,调用方使用 ServiceLoader 发现实现:
// provider module
module com.example.plugin {
requires com.example.api;
provides com.example.spi.Plugin with com.example.plugin.impl.PluginImpl;
}
// consumer code
ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);
for (Plugin p : loader) { p.start(); }
模块可见性与封装
exports:包向其它模块可见(但导出是按包的,不按类)。opens:对反射开放(运行时反射/框架如 Jackson、Spring 需要)。- JPMS 会在模块层面限制反射访问(默认更严格),这影响依赖注入/框架需要
opens的情形。
动态模块加载(ModuleLayer)
JPMS 支持创建新的 ModuleLayer 以加载模块(可用于插件),示例代码见下文“JPMS 插件框架”部分。注意:模块层可被丢弃以允许卸载(条件复杂),但通常比基于 ClassLoader 的方案更复杂。
5. 热部署 / 重新加载策略
常见做法(优缺点)
-
ClassLoader 替换(最常用)
- 每次 reload 都创建一个新的 ClassLoader,加载新版本类,切换引用到新实例,丢弃旧 ClassLoader 的强引用并触发 GC。
- 优点:简单、可卸载(理论上),支持完全替换类。
- 缺点:必须清理资源(线程、注册、静态引用),且类实例间不能直接共享非接口类型(要通过 shared 接口或反射/Adapter)。
-
Instrumentation(java.lang.instrument)
- 可以在运行时重定义类(
redefineClasses、retransformClasses)。 - 优点:无需替换 ClassLoader,能修改方法实现。
- 缺点:不能改变类的结构(不能新增/删除字段或方法签名),使用门槛和限制高。
- 可以在运行时重定义类(
-
框架支持(Spring DevTools / JRebel)
- Spring Boot 的 DevTools 使用两个 ClassLoader 机制(重启应用上下文),且能自动检测类文件变动。JRebel 为商业产品,能更细粒度热替换。
推荐策略(生产环境)
- 开发环境:用 DevTools / JRebel 提高效率。
- 生产环境:推荐使用“灰度替换”或蓝绿部署,而不是频繁热替换;若必须热替换,采用 ClassLoader 替换 + 严格的资源清理 + 完整测试。
6. 实战练习:实现一个简单插件框架(可卸载的模块)
我给你两种实现思路与代码片段:A. 传统 ClassLoader 插件(轻量、实战常用)与 B. JPMS 模块化插件(更现代、严格封装)。
A. 传统 ClassLoader 插件框架(核心思路)
- 每个插件打成独立 JAR,必须暴露一个工厂类或实现共享接口
com.myapp.Plugin。 - 插件由
PluginManager管理:加载(创建 PluginClassLoader),激活(newInstance 并调用 start),卸载(stop -> 关闭 classloader -> 清理资源 -> 丢弃引用)。
接口定义
package com.myapp.plugin;
public interface Plugin {
void start();
void stop();
}
PluginDescriptor(元数据)
public class PluginDescriptor {
private final String id;
private final Path jarPath;
private final URL[] urls;
private final String mainClass; // fully-qualified plugin impl class
// constructors + getters
}
PluginManager(关键方法)
public class PluginManager {
private final Map<String, PluginClassLoader> loaders = new ConcurrentHashMap<>();
private final Map<String, Plugin> instances = new ConcurrentHashMap<>();
public void load(PluginDescriptor desc) throws Exception {
URL[] urls = new URL[]{desc.getJarPath().toUri().toURL()};
PluginClassLoader loader = new PluginClassLoader(urls, getClass().getClassLoader());
loaders.put(desc.getId(), loader);
Class<?> clazz = loader.loadClass(desc.getMainClass());
Plugin plugin = (Plugin) clazz.getDeclaredConstructor().newInstance();
instances.put(desc.getId(), plugin);
plugin.start();
}
public void unload(String id) throws Exception {
Plugin plugin = instances.remove(id);
if (plugin != null) {
plugin.stop();
}
PluginClassLoader loader = loaders.remove(id);
if (loader != null) {
// 必须先确保 plugin.stop() 中释放所有全局注册
loader.close(); // Java 7+ URLClassLoader 有 close()
}
// 进一步:清理 Thread locals、关闭线程池、解除 JDBC Driver
System.gc(); // 提示 JVM 回收(不能保证)
}
}
插件实现要点(开发者约束)
- 不要持有对系统类或应用类的可变静态引用(会导致泄露)。
- 在
stop()中释放资源:停止线程、取消定时任务、反注册 JDBC 驱动与 MBeans。 - 使用共享的接口包(例如
com.myapp.plugin.api)由主应用加载(父加载器),插件通过父加载器可见该接口。
B. JPMS(ModuleLayer)方式:模块化插件(示例)
适用于需要模块化封装和服务发现时。基本步骤:
- 每个插件是一个模块 JAR(含
module-info.class)。 - 主应用使用
ModuleFinder找到插件模块并在新的ModuleLayer中解析与定义。 - 通过
ModuleLayer的类加载器或ServiceLoader加载服务实现。 - 卸载时丢弃
ModuleLayer的引用并清理全局注册。
核心代码(加载模块)
Path pluginPath = Paths.get("/path/to/plugin.jar");
ModuleFinder finder = ModuleFinder.of(pluginPath);
ModuleLayer parent = ModuleLayer.boot();
Configuration parentConfig = parent.configuration();
// root module names (插件 module 名称)
Set<String> roots = Set.of("com.example.pluginmodule");
Configuration cf = parentConfig.resolveAndBind(finder, ModuleFinder.of(), roots);
// 使用同一个 ClassLoader(也可以自定义)
ClassLoader scl = ClassLoader.getSystemClassLoader();
ModuleLayer layer = parent.defineModulesWithOneLoader(cf, scl);
// 获取模块类加载器
ClassLoader pluginLoader = layer.findLoader("com.example.pluginmodule");
// 通过 ServiceLoader 从该 module layer 加载服务
ServiceLoader<MyService> loader = ServiceLoader.load(layer, MyService.class);
for (MyService s : loader) {
s.start();
}
注意:要实现真正卸载,需确保丢弃对 ModuleLayer、module 里的静态引用和由 module 注册的全局对象的引用,使之可被 GC。JPMS 的卸载语义比较复杂,不像简单 ClassLoader 那样直观。
7. 常见陷阱与如何避免(实战建议)
-
类冲突 /
ClassCastException- 原因:同一个类名由不同 ClassLoader 加载(类型不兼容)。
- 规避:把共享 API(接口/抽象类)放到主应用的 classpath(由父加载器加载),插件只实现接口,不复制接口类。
-
ClassLoader 泄露(内存泄露)
- 常见原因:线程(非守护线程)未停止;ThreadLocal 未清理;JDBC Driver 未反注册;静态集合持有插件类引用;MBeans 未注销。
- 规避:要求插件在
stop()中释放资源;PluginManager 在卸载时强制检查并清理:中断线程、remove ThreadLocal、DriverManager.deregisterDriver、MBeanServer.unregisterMBean。
-
资源(文件句柄)被占用
- JAR 文件被 ClassLoader 占用导致无法替换。Java 7+ 可
URLClassLoader.close()释放句柄,但需要确实丢弃引用并 GC。
- JAR 文件被 ClassLoader 占用导致无法替换。Java 7+ 可
-
错误的父/子加载策略
- 随意采用 child-first 会导致核心类(例如 logging、共享 API)被插件重复加载,出现各种怪异错误。
- 经验法则:共享 API 放父加载器、插件自身依赖尽量在插件内部;对
java.*与框架包强制 parent-first。
-
JPMS 下的反射失败
- 如果模块没有
opens,反射访问会失败(例如 Jackson)。解决:在module-info.java中使用opens或采用--add-opens启动参数(临时方案)。
- 如果模块没有
8. 调试与诊断工具(列清单)
jmap -histo <pid>:查看堆上类与实例分布(找 ClassLoader 泄露)。jcmd <pid> GC.class_histogram:类似 jmap。jstack:线程栈,定位未结束的线程。jcmd <pid> VM.classloader_stats(在支持的 JDK)或jcmd的其它诊断命令。- VisualVM / JConsole / Java Mission Control (JMC):观察内存、线程、类加载器。
jdeps:分析类/模块依赖,有助于判断哪个 JAR 引入了冲突类。jlink:用于构建精简运行时镜像(与模块系统配合)。
9. 何时用 JPMS,何时用 OSGi,差别一瞥
- JPMS:语言层面的模块化(编译时、运行时更严格的封装),适合模块化应用与封装;但动态性(热替换/复杂插件)不如 OSGi 灵活。
- OSGi:成熟的动态模块/服务平台,强大但复杂,常用于大型应用或需要高度动态管理的场景(如 Eclipse、部分企业中间件)。
- 选择建议:如果你需要动态部署/卸载/版本化的复杂插件,OSGi 很强;如果只需要静态模块化和较轻的插件需求,JPMS + ClassLoader 足够。
10. 延伸阅读与学习路线(建议)
- 官方 JPMS 指南(Java 9+ 模块系统文档)
- 《Java Platform Module System》系列文章 / 教程
- OSGi 官方文档与入门教程(比较动态特性)
- Spring / WildFly / JBoss 在模块化/插件方面的实现实践(学习他们如何清理类加载器、管理模块)
(若你想,我可以把这些资源的权威链接列出来 — 需要我帮你查最新链接吗?)
11. 小结清单(可执行的 checklist)
开发插件化系统时,部署前务必核对:
- 共享 API 放在主应用(父加载器)。
- 设计清晰的插件生命周期(init/start/stop/destroy)并强制插件释放资源。
- 插件尽量不要直接操作全局注册表(使用主应用提供的桥接服务)。
- 使用
URLClassLoader.close()并丢弃引用后触发 GC(但不可完全依赖 GC,需在 stop 中释放资源)。 - 在 JPMS 环境下检查
exports/opens与requires的声明,避免反射问题。 - 充分用 jmap/jstack/VisualVM 做内存与线程泄露检测。
… …
文末
好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。
… …
学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!
wished for you successed !!!
⭐️若喜欢我,就请关注我叭。
⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。
版权声明:本文由作者原创,转载请注明出处,谢谢支持!
- 点赞
- 收藏
- 关注作者
评论(0)