环境配置:
- JDK: 17
- Spring Boot: 2.6.6
一、 需求场景
在不停止 Spring Boot 服务的情况下,通过上传 JAR 包动态实现(或更新)已有接口。要求:
- 动态性:支持随时加载、卸载与更新。
- 兼容性:插件内部可以正常注入并使用主程序中定义的各类 Bean。
二、 实现思路
- 接口预留:主程序定义业务扩展接口(SPI 思想),内部不作实现,交由独立插件完成。
- 热加载机制 :利用
java.net.URLClassLoader动态加载外部 JAR。加载完成后,手动将实现类注册到 Spring 容器中。 - 资源管理 :每次更新插件时,销毁旧的
ClassLoader并释放资源,防止内存泄漏(OOM)。同时从 Spring 容器中移除旧 Bean。 - 类加载委托 :创建
URLClassLoader时,必须正确指定 父级类加载器。这是插件能直接引用主程序 Spring Bean 的关键。
三、 核心实现步骤
1. 定义插件标准接口
在主程序中定义 IPluginService,规定插件的生命周期(安装、卸载)与业务行为。
java
public interface IPluginService {
void install(); // 安装逻辑
void uninstall(); // 卸载逻辑
boolean installComplete();
// 插件元数据
String getAuthor();
String getPluginName();
String getVersion();
// 业务执行入口
Object process(Map<String, Object> params) throws Exception;
default Object processIfComplete(Map<String, Object> params) throws Exception {
if (!installComplete()) {
throw new RuntimeException(getPluginName() + " 插件尚未就绪!");
}
return process(params);
}
}
2. 插件的加载与注册
上传 JAR 后,通过 URLClassLoader 加载类。(关键点:父类加载器的选择)
- 父类加载器的选择 :通常使用
ClassLoader.getSystemClassLoader()来获取,实际获取的是AppClassLoader,因为 Spring 及其所有依赖均由它加载。 - 注册逻辑:
java
// 加载外部 JAR
URL url = new URL("jar:file:/plugins/send-message.jar!/");
URLClassLoader classLoader = new URLClassLoader(new URL[]{url}, ClassUtils.getDefaultClassLoader());
// 加载实现类并注册为 Bean
Class<?> clazz = classLoader.loadClass("com.zhangsan.plugin.SendMessagePlugin");
springUtil.registerBean(clazz.getName(), clazz);
// 初始化插件
IPluginService pluginService = (IPluginService) springUtil.getBean(clazz.getName());
pluginService.install();
3. 插件的卸载
卸载时需逆向操作:执行插件卸载逻辑 -> 移除 Spring Bean -> 关闭类加载器。
java
pluginService.uninstall();
springUtil.removeBean("com.zhangsan.plugin.SendMessagePlugin");
classLoader.close(); // 关闭当前 ClassLoader 释放资源, 但是已被其加载的 class 不会被这个操作所释放掉,具体可以查阅其源码的注释
更新插件: 卸载插件,再安装。
4. 插件实现示例
java
import java.util.Map;
public class SendMessagePlugin implements IPluginService {
@Override
public void install() {
System.out.println(getVersion() + "版本插件已安装");
}
@Override
public void uninstall() {
System.out.println("插件已卸载");
}
@Override
public boolean installComplete() {
return true;
}
@Override
public String getAuthor() {
return "张三";
}
@Override
public String getPluginName() {
return "消息发送插件";
}
@Override
public String getVersion() {
return "1.0.0";
}
@Override
public Object process(Map<String, Object> params) throws Exception {
System.out.println("版本" + getVersion() + "插件执行处理");
return null;
}
}
测试:
- 传好 jar 包
- 触发主程序的插件安装
- 触发卸载
- 更新 jar 包
- 触发安装 (更新)
四、 线上验证与踩坑
1. ClassNotFoundException
问题现象 :在服务器以 java -jar 运行时,安装插件报错 NoClassDefFoundError: IPluginService。
原因分析 :
Spring Boot 在生产环境下是通过 JarLauncher 启动的,实际加载业务类的是 LaunchedURLClassLoader 。此时 SystemClassLoader 仅加载了极少数核心类,找不到 Spring 的类及我们定义的接口。
服务器上是通过
java -jar方式启动的,通过压缩文件打开 jar 包,找到META-INF/MANIFEST.MF文件,就可以看到:Main-Class: org.springframework.boot.loader.JarLauncher
解决方案 :
使用 Spring 提供的 ClassUtils.getDefaultClassLoader() 获取当前线程上下文或由 Spring 使用的加载器,确保父子加载器链路正确。
打开 JarLauncher 源码找到 main 方法,然后进入 launch 方法,在这里我们可以看到 Spring 自己创建了 ClassLoader,继续跟入之后可以看到,实际上就是 Spring 自己的LaunchedURLClassLoader,他也实现了URLClassLoader,并且重写了 loadClass 方法
2. 插件更新失效(总是是使用旧版本插件)
问题现象 :覆盖同名 JAR 包并更新插件,有时生效,有时执行的是旧代码。
检查 jar 资源加载逻辑: 完整关系应该是这样
自己的URLClassLoader -> LaunchedURLClassLoader -> AppClassLoader -> PlatformClassLoader -> BootClassLoader
-
这里其实父级都不会加载我们的 jar 包,最后还是回到了自身的 URLClassLoader
从 loadClass 的逻辑我们可知,由于父级加载不了,且自己的 URLClassLoader 本地也没有这个 class 时,会执行 findClass 方法
-
而 URLClassLoader 重写了 findClass 方法
-
在 findClass() 中会通过 jdk.internal.loader.URLClassPath 去获取相关的资源,这里调用了
ucp.getResource(path, false);操作 ->loader.getResource(name, check);->url.openConnection();->handle.openConnection(this);->JarURLConnection.get(url, getRootJarFileFromUrl(url));->getRootJarFile(name)->rootFileCache.get()
简单介绍下 Handler: 在 JarLauncher#launch 方法,重点在
JarFile.registerUrlProtocolHandler();这里把org.springframework.boot.loader.jar.Handler注册了进来,Handler 就是 Spring 自己的 URL 处理器 (支持 jar-in-jar 加载)
-
所以到这里, 会根据插件存放的位置进行缓存, 不去加载新的 jar 文件.
-
更新成功的情况是怎么回事呢?因为这个缓存变量的定义是软引用~
小结 :
通过调试 Spring 源码发现,Spring Boot 注册了一个自定义的 URL 处理器:org.springframework.boot.loader.jar.Handler。
该 Handler 内部维护了一个 rootFileCache 变量,其类型为 Map<String, SoftReference<JarFile>>。
- 根源 :如果 JAR 文件路径(Key)不变,Spring 会优先从缓存中获取
JarFile对象。 - 表现 :因为是 软引用(SoftReference),只有在内存压力大时才会被回收。这就解释了为什么更新结果时好时坏。
3. 最终解决方案
- 方案 A(简单) :每次上传插件时,在文件名中加入随机数或版本号(如
plugin-v1.jar),避开路径缓存 Key。 - 方案 B(彻底) :自定义类加载器,绕过 Spring 的
JarFile缓存逻辑,直接读取 JAR 字节码。
自定义 ClassLoader 实现: PluginClassLoader
java
public class PluginClassLoader extends URLClassLoader {
private static final int BUFFER_SIZE = 4096;
private final static String CLASS_SUFFIX = ".class";
private final Map<String, byte[]> classMap = new HashMap<>();
public PluginClassLoader(URL url, ClassLoader parent) throws IOException {
super(new URL[]{url}, parent);
parseJar(url);
}
/**
* 将jar文件里边的class都解析出来缓存到本地
*/
private void parseJar(URL url) throws IOException {
String path = url.getPath();
path = path.substring(5, path.length() - 2);
JarFile jarFile = new JarFile(path);
// 解析jar包每一项
Enumeration<JarEntry> en = jarFile.entries();
InputStream input = null;
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
while (en.hasMoreElements()) {
JarEntry jarEntry = en.nextElement();
String name = jarEntry.getName();
if (name.endsWith(CLASS_SUFFIX)) {
String className = name.replace(CLASS_SUFFIX, "").replaceAll("/", ".");
input = jarFile.getInputStream(jarEntry);
byte[] buffer = new byte[BUFFER_SIZE];
int index = 0;
while ((index = input.read(buffer)) != -1) {
bos.write(buffer, 0, index);
}
classMap.put(className, bos.toByteArray());
bos.reset();
}
}
} catch (IOException e) {
log.error(e.getClass().getSimpleName() + " " + e.getMessage(), e);
} finally {
if (input != null) {
try {
input.close();
} catch (IOException ignore) {
}
}
}
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 改变加载逻辑,这里只要是 com.zhangsan.plugin 开头的,都是我们插件规范里边的类,直接加载本地的class
if (name.startsWith("com.zhangsan.plugin.")) {
try {
return loadClassInLaunchedClassLoader(name);
} catch (ClassNotFoundException ignore) {
// 加载不了的,应该交由父类去加载
}
}
// 其余的类还是交由父类去完成
return super.loadClass(name);
}
private Class<?> loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException {
byte[] classBytes = classMap.get(name);
if (classBytes == null) {
throw new ClassNotFoundException(name);
}
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(classBytes);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead = -1;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
byte[] bytes = outputStream.toByteArray();
return defineClass(name, bytes, 0, bytes.length);
} catch (Exception e) {
throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", e);
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String className = name.replace(CLASS_SUFFIX, "").replaceAll("/", ".");
byte[] bytes = classMap.get(className);
if (bytes == null) {
throw new ClassNotFoundException(name);
}
return this.defineClass(name, bytes, 0, bytes.length);
}
}
六、 关于类卸载与内存溢出
已经加载的类是会被卸载吗?如果无法被卸载是会导致频繁热加载 OOM。
根据 JDK 提供的工具 jconsole,选择 Classes 这一页,是可以看到 unload 类的数量:
实际上,JVM 卸载一个类需满足以下严苛条件:
- 该类在堆中不存在任何实例。
- 加载该类的
ClassLoader已经被回收。 - 该类的
Class对象没有在任何地方被引用(包括反射引用)。
在这个方案中,通过 classLoader.close() 和 Spring Bean 移除操作,配合 GC,可以有效触发类卸载。
七、 总结
动态插件化方案非常适合私有化部署 和定制化需求频繁的项目。
- 核心收益:无需修改主程序源码,通过扩展接口实现业务解耦。
Reference:
qq_30322893. (2022, October). jar插件的热部署、卸载、更新(springboot). CSDN. Retrieved from https://blog.csdn.net/qq_30322893/article/details/127812512. (Adapted under CC 4.0 BY-SA).