尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

插件热加载后 SPI 还是加载老实现:ServiceLoader 缓存与线程上下文类加载器的 3 个坑

插件热加载后 SPI 还是加载老实现:ServiceLoader 缓存与线程上下文类加载器的 3 个坑 title: 插件热加载后 SPI 还是加载老实现ServiceLoader 缓存与线程上下文类加载器的 3 个坑tags: [Java, SPI, ServiceLoader, ClassLoader, 插件化]那天下午的一个诡异现象我们做的是一个规则引擎平台风控规则以插件包jar的形式上传运行时用ServiceLoader把插件里的RuleProvider实现加载进来。设计上支持热更新运营把新版本 jar 传上来后台调一个/reload接口理论上就能切到新规则不用重启。上线三个月一直没出事因为大家其实都在半夜发版reload 完顺手也重启了。直到某天下午两点风控同学急吼吼地跑来新规则明明传上去了reload 接口也返回 200但线上跑的还是旧逻辑。他改了一个阈值从 100 改到 500日志里打出来的还是 100。我第一反应是缓存没刷新让他再点一次 reload。点了三次没用。重启服务立刻好了。这就把问题范围缩到了「reload 逻辑本身没真正换掉实现」。先用最小代码复现我把线上那段 reload 逻辑抽出来写了个最小复现。核心是每次 reload 都 new 一个URLClassLoader指向新 jar然后ServiceLoader.load(RuleProvider.class)去拿实现。public class RuleEngine { private volatile RuleProvider current; // reload 被 HTTP 接口调用 public void reload(String jarPath) throws Exception { URL url new File(jarPath).toURI().toURL(); URLClassLoader pluginLoader new URLClassLoader(new URL[]{url}); // 关键这里没有传 pluginLoader用的是默认的 ServiceLoaderRuleProvider loader ServiceLoader.load(RuleProvider.class); IteratorRuleProvider it loader.iterator(); if (it.hasNext()) { this.current it.next(); System.out.println(loaded: current.getClass() from current.getClass().getClassLoader()); } } }跑起来一看问题一目了然每次 reload 打出来的 classloader 都是AppClassLoader也就是应用启动时的那个根本不是我新 new 的pluginLoader。新 jar 的类压根没被加载。坑一ServiceLoader.load(Class) 用的是 TCCL不是你以为的那个翻ServiceLoader的源码就明白了。JDK 8 里ServiceLoader.load(Class)只有一个参数的重载它内部是这么写的public static S ServiceLoaderS load(ClassS service) { ClassLoader cl Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); }注意Thread.currentThread().getContextClassLoader()这就是所谓的线程上下文类加载器TCCL。它不是你当前代码所在类的 classloader而是绑定在当前线程上的一个「约定俗成的」加载器。问题就在这我的 reload 方法是被 Tomcat 的 HTTP 工作线程调用的那个线程的 TCCL 是 Web 应用的 classloader在 Spring Boot fat jar 场景下大致等价于 AppClassLoader永远不是我临时 new 出来的pluginLoader。所以ServiceLoader每次都在旧 classloader 的路径下找实现自然找到的是启动时就打进 jar 里的那个旧类。修复第一步显式传 classloader用双参数重载。ServiceLoaderRuleProvider loader ServiceLoader.load(RuleProvider.class, pluginLoader);改完打出来的 classloader 变成了URLClassLoader新 jar 的类确实被加载了。但是——只有第一次 reload 生效第二次传新 jar 又不动了。坑二ServiceLoader 自己带一层实例缓存iterator 复用会骗你第二次不生效是因为我把ServiceLoader对象存成了成员变量想着复用省点开销。但ServiceLoader内部有个providers的LinkedHashMap缓存一旦iterator()遍历过一次后面再遍历会先吐缓存里的旧实例// ServiceLoader 内部简化 private LinkedHashMapString,S providers new LinkedHashMap(); public IteratorS iterator() { return new IteratorS() { IteratorMap.EntryString,S knownProviders providers.entrySet().iterator(); // 先吐缓存 public boolean hasNext() { if (knownProviders.hasNext()) return true; return lookupIterator.hasNext(); // 缓存空了才真正扫 META-INF/services } // ... }; }也就是说ServiceLoader的语义是「懒加载 缓存」它假设服务实现在进程生命周期内不变。要强制它重新扫描必须调reload()注意这是ServiceLoader.reload()和我们业务的 reload 重名了当时看代码看得头晕或者干脆每次都ServiceLoader.load()拿一个全新对象。我选了后者——每次 reload 都创建新的ServiceLoader和新的URLClassLoader不做任何复用public void reload(String jarPath) throws Exception { URL url new File(jarPath).toURI().toURL(); URLClassLoader pluginLoader new URLClassLoader( new URL[]{url}, RuleProvider.class.getClassLoader()); ServiceLoaderRuleProvider loader ServiceLoader.load(RuleProvider.class, pluginLoader); RuleProvider p loader.iterator().next(); RuleProvider old this.current; this.current p; // volatile切换是原子的 closeQuietly(old); // 见坑三 }这里还有个细节newURLClassLoader时我把RuleProvider.class.getClassLoader()作为父加载器传进去。因为接口RuleProvider本身是主程序定义的插件实现要能implements它就必须能从父加载器里看到同一个接口 Class 对象。如果不指定父加载器默认是 AppClassLoader 一般也行但在某些容器里会拿错插件里 load 出来的RuleProvider和主程序的RuleProvider可能是两个不同的 Classinstanceof直接 false然后你会收获一个非常费解的ClassCastException——同名类、不同 classloader就是不认。坑三旧 URLClassLoader 不 closeMetaspace 一路涨前两个坑解决后功能对了但压测同学发现连续 reload 几百次Metaspace 从 80MB 一路涨到 400MB 不回落最后OutOfMemoryError: Metaspace。原因是每次 reload 都 new 一个URLClassLoader加载了一批类到 Metaspace。旧的 classloader 如果还被引用着哪怕只是被一个缓存 Map 间接引用它加载的所有 Class 都没法卸载。类的卸载条件很苛刻这个 classloader、它加载的所有 Class 实例、以及这些 Class 的所有对象实例全部都要不可达GC 才会回收整个 classloader 连同它的 Metaspace 空间。我的错误在于closeQuietly(old)只调了URLClassLoader.close()但close()只是释放打开的 jar 文件句柄并不卸载已加载的类。真正要做的是断开所有对旧 provider 和旧 classloader 的引用。我最后加了一段校验确认切换后旧对象没有被其他缓存持有private void closeQuietly(RuleProvider old) { if (old null) return; try { ClassLoader cl old.getClass().getClassLoader(); // 业务层已无引用old 局部变量出栈后即不可达 if (cl instanceof URLClassLoader) { ((URLClassLoader) cl).close(); // 释放文件句柄 } } catch (IOException ignore) {} // 关键确保没有任何静态 Map / 监听器还引用着 old }真正治本的是排查引用链。我们当时有个MapString, RuleProvider ruleCache忘了在 reload 时清理它一直握着旧实例导致旧 classloader 永远存活。清掉这个缓存后用jcmd pid GC.class_stats观察reload 后旧类确实被卸载了Metaspace 稳定在 90MB 左右不再涨。三种「换实现」方式的取舍方案是否支持热更新Metaspace 风险实现复杂度适用场景重启进程换 jar否要停服无最低发版频率低、能接受停服窗口每次 reload 新建 ClassLoader是高须严格断引用高真正需要不停服热插拔插件Spring Boot RefreshScope / 配置化是但只是换参数无中规则能抽象成配置项不涉及新代码说实话如果规则本质上只是「改几个阈值」我并不建议上 ServiceLoader 热加载这套。它的复杂度主要花在类加载器隔离和引用管理上一旦某处漏了引用就是 Metaspace 泄漏排查成本极高。我们后来把「纯参数类规则」都下沉到配置中心只有「需要写新代码逻辑」的规则才走插件热加载插件数量一下少了八成运维压力小很多。复盘时记下的几个数字首次定位耗时 3 小时其中 2 小时耗在「以为是业务缓存问题」的错误方向上。连续 reload 500 次未 close 且未断引用时 Metaspace 从 80MB 涨到 412MB修复后稳定在 88~92MB。生产环境把「纯参数规则」迁到配置中心后插件包数量从 37 个降到 6 个。一点个人判断ServiceLoader是个好东西但它的设计前提是「服务实现在进程内不变」——TCCL 依赖、内部实例缓存、懒加载全都是围绕这个前提来的。当你想拿它做热插拔就是在跟它的设计假设对着干那三个坑本质上都是这么来的。更适合动态加载的其实是 OSGi、Java 9 的 Layer/Module 或者干脆用独立进程 RPC 做插件隔离只是它们更重。留个思考题如果你的插件里也用了ServiceLoader.load(SomeSpi.class)单参数当这段代码运行在你新建的URLClassLoader里时它拿到的 TCCL 会是谁要让插件内部的 SPI 也能正确加载到插件自己带的实现你会在哪一步用Thread.currentThread().setContextClassLoader()兜一层欢迎在评论区聊聊你踩过的类加载器坑。
返回列表