基于 Spring Boot 的插件化 JAR 包热加载方案

环境配置:

  • JDK: 17
  • Spring Boot: 2.6.6

一、 需求场景

在不停止 Spring Boot 服务的情况下,通过上传 JAR 包动态实现(或更新)已有接口。要求:

  1. 动态性:支持随时加载、卸载与更新。
  2. 兼容性:插件内部可以正常注入并使用主程序中定义的各类 Bean。

二、 实现思路

  1. 接口预留:主程序定义业务扩展接口(SPI 思想),内部不作实现,交由独立插件完成。
  2. 热加载机制 :利用 java.net.URLClassLoader 动态加载外部 JAR。加载完成后,手动将实现类注册到 Spring 容器中。
  3. 资源管理 :每次更新插件时,销毁旧的 ClassLoader 并释放资源,防止内存泄漏(OOM)。同时从 Spring 容器中移除旧 Bean。
  4. 类加载委托 :创建 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;
    }
}

测试:

  1. 传好 jar 包
  2. 触发主程序的插件安装
  3. 触发卸载
  4. 更新 jar 包
  5. 触发安装 (更新)

四、 线上验证与踩坑

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

  1. 这里其实父级都不会加载我们的 jar 包,最后还是回到了自身的 URLClassLoader

    从 loadClass 的逻辑我们可知,由于父级加载不了,且自己的 URLClassLoader 本地也没有这个 class 时,会执行 findClass 方法

  2. 而 URLClassLoader 重写了 findClass 方法

  3. 在 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 加载)

  1. 所以到这里, 会根据插件存放的位置进行缓存, 不去加载新的 jar 文件.

  2. 更新成功的情况是怎么回事呢?因为这个缓存变量的定义是软引用~

小结

通过调试 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 卸载一个类需满足以下严苛条件:

  1. 该类在堆中不存在任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类的 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).


相关推荐
LaLaLa_OvO5 分钟前
spring boot2.0 里的 javax.validation.Constraint 加入 service
java·数据库·spring boot
齐 飞31 分钟前
Spring Data JPA快速入门
spring boot
计算机学姐33 分钟前
基于SpringBoot的高校体育场馆预约系统【个性化推荐算法+数据可视化统计】
java·vue.js·spring boot·后端·mysql·信息可视化·推荐算法
Coder_Boy_36 分钟前
基于SpringAI的在线考试系统设计-用户管理模块设计
java·大数据·人工智能·spring boot·spring cloud
奋进的芋圆1 小时前
SerialCommManager 详解:从嵌入式通信管理器到 Spring Boot 后端服务
java·spring boot·接口隔离原则
奋进的芋圆1 小时前
Spring Boot + RAG 项目中集成 MCP 接口技术文档
java·spring boot·ai
苏小瀚1 小时前
[JavaEE] SpringBoot 配置文件
数据库·spring boot·java-ee
无心水1 小时前
【分布式利器:腾讯TSF】2、腾讯微服务框架TSF实战指南:Spring Boot零侵入接入与容器化部署全流程
java·spring boot·分布式·微服务·springcloud·分布式利器·腾讯tsf
苏小瀚2 小时前
[JavaEE] Spring Boot 日志
java·spring boot·后端
木子江L2 小时前
SpringBoot集成RabbitMQ消息中间件
java·spring boot·rabbitmq·java-rabbitmq