实现外部 jar 的动态加载与卸载
boot-pkg
├── boot-pkg-core 核心功能
├── boot-pkg-lite 包含核心功能的最小实现
├── boot-pkg-spi SPI 接口模块。暴露 BootSpi#load 方法给子类实现。类似 jdbc。
├── boot-pkg-spi-impl SPI 实现模块 1,依赖 SPI 接口模块。类似 mysql-connector-java,是 jdbc 的 mysql 实现。
├── boot-pkg-spi-impl-a SPI 接口模块 2,依赖 SPI 接口模块。类似 postgresql-connector-java,是 jdbc 的 pg 实现。
...
在开发中如果我们想要使用 jdbc 只需要引入 mysql-connect-java 或者 postgresql-connector-java 即可,不需要关心 jdbc 的实现细节。同样的,在此处我们也不需要关心 SPI 的实现细节,只需要引入 boot-pkg-spi-impl-xxx 即可。
...
扯远了,回到正题。本项目主要实现的是 jar 包的动态加载功能,可自定义选择指定的 jar 包进行动态加载。
...
1、将 boot-pkg-core 打包安装到本地仓库
2、启动 boot-pkg-lite,访问 http://localhost:8080/lite
查看 lite 的状态;访问 http://localhost:8080/info
查看运行平台相关信息。
3、启动成功后,将 boot-pkg-spi-impl 和 boot-pkg-spi-impl-a 打包,并分别从 target 文件夹中将 jar 包拷贝到 boot-pkg 的根目录下 此时项目结构大概如下:
boot-pkg
├── boot-pkg-core
├── boot-pkg-lite
├── boot-pkg-spi-1.0-SNAPSHOT.jar
├── boot-pkg-spi-a-1.0-SNAPSHOT.jar
4、访问 http://localhost:8080/rootJars
查看根目录 jar 包列表,可以看到之前拷贝的两个 jar 包
{
"boot-pkg-spi-impl-a-1.0-SNAPSHOT": ".\boot-pkg-spi-impl-a-1.0-SNAPSHOT.jar",
"boot-pkg-spi-1.0-SNAPSHOT": ".\boot-pkg-spi-1.0-SNAPSHOT.jar"
}
5、访问 http://localhost:8080/loadFromRoot?jarName=
加载 jar 包。
注意:这一步创建了一个新的 ClassLoader 来加载外部 jar 包,想要加载 Jar 包中的类必须使用加载了该 Jar 的 ClassLoader。
6、访问 http://localhost:8080/execute?pluginName=boot-pkg-spi-impl-a-1.0-SNAPSHOT
将自定义的 ClassLoader 加载到 VM 中,并执行 SPI 接口的实现方法。
可以从控制台看到两个输出:
BootSpiImpl #1
BootSpiImplAAA load #2
第一行输出是因为 boot-pkg-lite 项目中引入了 boot-pkg-spi-impl 的依赖,所以在启动的时候就已经加载了 BootSpiImpl 类,自定义的 DynamicClassLoader 也能访问到它。 第二行输出是因为第 5 步使用自定义的 DynamicClassLoader 来加载了 boot-pkg-spi-impl-a 的 jar 包,所以能够访问到 BootSpiImplAAA 类。
...
至此,就实现了外部 jar 的动态加载。
...
7、如果此时访问 localhost:8080/classpathLoad
,会发现只有第一行输出,第二行输出消失了。这是因为此时使用的是 AppClassLoader,而外部的 jar 使用的是自定义的 DynamicClassLoader 加载。所以 AppClassLoader 无法访问到该动态加载的 jar。
...
要实现 jar 包的卸载,需要看一下
1、动态加载的时候 ClassLoader 都做了什么事情
看到 java.net.URLClassLoader#addURL
public synchronized void addURL(URL url) {
if (closed || url == null)
return;
synchronized (unopenedUrls) {
if (! path.contains(url)) {
unopenedUrls.addLast(url);
path.add(url);
}
}
}
将 URL 添加到 unopenedUrls 和 path 字段。
很好解决,只要将 Jar 包 URL 从自定义 ClassLoader 中删除。
…
2、执行 jar 包的时候做了什么事情
执行的时候比较能藏,需要 debug 才能发现执行的时候涉及到 URLClassPath#loaders
和URLClassPath#lmap
这两个字段。
又要再进行一次反射获取到 URLClassPath.Loader 类
…
1、反射获取 URLClassLoader 的 ucp 字段;
2、然后再反射获取 URLClassPath 的 unopenedUrls 或者 paths 进行操作。
但由于使用的是 Java 17,受到模块化系统的限制,反射获取 URLClassLoader 时报错 module java.base does not "opens java.net" to unnamed module
。
需要在 VM 添加参数
--add-opens=java.base/java.net=ALL-UNNAMED
...
获取 URLClassPath 报错 module java.base does not export jdk.internal.loader to unnamed module
需要在 VM 添加
--add-exports java.base/jdk.internal.loader=ALL-UNNAMED
...
Unsafe 和实际上也是利用反射机制。
…
上面这两个方法不太行。因为要进行太多次反射了,很麻烦。
...
只要没有执行 ServiceLoader.load
,自定义的 ClassLoader 中的 URLClassLoader 的 loaders 和 lmap 字段就不会存在值。
此时想要卸载就只要从 unopenedUrls 和 path 集合中删除对应的 URL 即可。
所以只要在执行的时候使用代理的 ClassLoader 来执行,卸载的时候从原来的 Class Loader 移除 URL 即可。
经过一番修改,卸载功能如下:
public static boolean executeByName(String pluginName) {
if (Objects.isNull(dynamicLoader)) {
initLoader();
}
PluginMetadata plugin = installedPlugins.get(pluginName);
if (Objects.isNull(plugin)) {
log.error("plugin not found");
return false;
}
// create a proxy classloader
DynamicClassloader classLoader = plugin.getClassLoader();
DynamicClassloader pcl = getProxyClassLoader(classLoader);
dynamicLoader.load(pcl);
pcl = null; // release resource
return true;
}
private static DynamicClassloader getProxyClassLoader(DynamicClassloader cl) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(cl.getClass());
enhancer.setCallback(NoOp.INSTANCE); // 设置 Callback 为 NoOp.INSTANCE,表示不对代理对象执行的方法进行任何处理
return (DynamicClassloader) enhancer.create(new Class[]{URL[].class}, new Object[]{cl.getURLs()});
}
利用动态代理 ClassLoader 来执行目标方法,执行完成之后将代理对象释放。
不错,现在基本上完成了想要的功能。
…
此外还可以考虑为每个插件自定义一个 ClassLoader,可以采用“强软弱虚”引用中的“弱引用”。在不需要该 ClassLoader 的之后就设置为 null,JVM 垃圾回收器一发现该对象不可达便立即回收。
…
该方法的缺点是:如果有很多个插件,那就需要创建很多个 ClassLoader。和使用动态代理相比哪一个方法更加适合?
如果你的需求是对代理对象的方法进行拦截、增强或修改,并且不需要频繁地加载和卸载插件,那么使用动态代理可能更适合。它提供了更灵活的控制和修改代理对象的能力。
如果你的需求是动态加载和卸载插件,并及时释放插件所占用的资源,那么自定义 ClassLoader 可能更适合。它可以实现更精确的资源管理,但需要考虑额外的插件管理和 ClassLoader 生命周期管理的复杂性。
而且使用自定义多个 ClassLoader 的方法不需要进行额外的反射操作来获取 URLClassLoader 和 URLClassPath。
…
目前采用此方法
…
卸载流程
1、访问 http://localhost:8080/plugins
查看已安装的插件列表
2、选择插件进行卸载 http://localhost:8080/unload?pluginName=boot-pkg-spi-impl-a-1.0-SNAPSHOT
3、再次执行 http://localhost:8080/execute?pluginName=boot-pkg-spi-impl-a-1.0-SNAPSHOT
,会发现显示插件执行失败。
4、访问 http://localhost:8080/plugins
可以看到该插件已经被删除。
…
# linux/unix
# 加载多个 JAR 文件
java -cp abc.jar:edf.jar:123.jar <main-class>
java -cp "./lib/*" org.springframework.boot.loader.JarLauncher
...
Unsafe