脚本引擎 Metaspace OOM 防护:动态规则频繁加载导致内存泄漏?ClassLoader 隔离 + 定时回收!
公司有个规则引擎服务,每天运营要更新几百条风控规则。QLExpress 每次执行规则都会编译生成一个匿名类,然后装进 JVM 的 Metaspace。运行了两周之后,服务开始频繁 Full GC,再后来直接 Metaspace OOM 崩了。重启能续命两周,但规则数量只增不减,两周变成十天,十天变成一周,最后每天都得重启。
这个问题在脚本引擎场景下几乎必现。GroovyShell、QLExpress、Aviator,甚至 Nashorn,只要是"动态编译 → 生成类 → 装载到 JVM"的模式,都会往 Metaspace 里塞东西。而且 Metaspace 默认没有上限——它只会一直涨,直到物理内存耗尽。
今天聊聊怎么用 ClassLoader 隔离 + 定时回收,让 Metaspace 不炸。
Metaspace 里到底装了什么
Java 8 以前叫 PermGen,Java 8 以后改名为 Metaspace。换了个名字,但干的活一样——存类的元数据。
你写的每一个类,编译后的字节码,字段名、方法名、注解信息、常量池,全部放在这里。普通的 Java 类在应用启动时加载一次,之后不再变化,所以 Metaspace 基本稳定。
但脚本引擎不一样。QLExpress 每次执行 runner.execute(rule, context, ...) 的时候,内部其实是:
把规则字符串 → 编译成 Java 代码 → 编译成字节码 → 生成 Class → 加载到 JVM
每一步都会在 Metaspace 里留下一份数据。同一个规则表达式 a + b > 100,如果你执行了 1000 次,QLExpress 内部有缓存,不会生成 1000 个类。但如果你有 1000 条不同的规则,那就是 1000 个类。
更关键的是,即使某条规则不再使用了、被运营删掉了,它对应的类还留在 Metaspace 里。JVM 不会主动卸载类——除非这个类的 ClassLoader 被回收了。
为什么默认的 ClassLoader 卸载不了
JVM 卸载类有一个硬条件:加载这个类的 ClassLoader 必须变成垃圾,被 GC 回收。
QLExpress 默认使用的是 ExpressRunner 内部的 ClassLoader,而这个 ClassLoader 的生命周期跟 ExpressRunner 实例一样长。只要 ExpressRunner 还活着,ClassLoader 就活着,加载过的类就永远在 Metaspace 里。
这就是为什么规则引擎跑久了 Metaspace 会满:类加载了,ClassLoader 不释放,类就永远留在那里。
解决办法很直白:每次执行规则,用一个独立的 ClassLoader。规则执行完,没有引用了,ClassLoader 和它加载的类一起被 GC 回收。
ClassLoader 隔离:每个规则一个独立装载器
把上面的思路落地的伪代码:
自定义 ClassLoader:
class IsolatedClassLoader extends ClassLoader:
// 重写 loadClass,让它不委托给父类
// 这样规则相关的类由这个 ClassLoader 独立加载
规则执行时的隔离:
loader = new IsolatedClassLoader()
try:
// 用独立的 ClassLoader 创建 ExpressRunner
runner = new ExpressRunner()
// 设置 runner 使用自定义 ClassLoader
execute(rule, context)
finally:
// 关键:释放所有引用
runner = null
loader = null
// 下一次 GC 时,loader 和它加载的类一起回收
这里面有个容易被忽略的细节:不要让隔离的 ClassLoader 被任何长生命周期对象引用。 如果把 runner 存到了一个 static 的 Map 里,那 ClassLoader 就永远有引用,永远不会被 GC。每次用完立刻把引用置 null,或者用 try-with-resources 的模式确保释放。
定时回收:主动触发 GC 清理
光靠 ClassLoader 被 GC 回收还不够。Metaspace 的 GC 触发条件跟堆内存不一样——堆内存满了会触发 GC,但 Metaspace 满了直接 OOM,不一定给你 GC 的机会。
所以需要主动做两件事:
设定 Metaspace 上限
-XX:MaxMetaspaceSize=256m
-XX:MetaspaceSize=128m
不设上限的话,Metaspace 会撑到物理内存耗尽才报 OOM,那时候系统已经不稳定了。设了一个合理的上限,至少能在接近上限时触发 GC 尝试回收。
定时触发 Full GC
// 定时任务:每 30 分钟主动触发一次 Full GC
@Scheduled(fixedDelay = 30 * 60 * 1000)
public void scheduledRecycle() {
// 先清理过期的隔离 ClassLoader 引用
cleanupStaleLoaders();
// 建议 JVM 做 GC(不是强制,但通常会执行)
System.gc();
}
这里 System.gc() 只是建议,JVM 不一定立即执行。但在 Metaspace 接近上限的时候,JVM 一般会认真考虑这个建议。
更好的做法是结合 MXBean 监控 Metaspace 使用量:
MemoryPoolMXBean metaspaceBean = getMetaspaceMXBean();
long used = metaspaceBean.getUsage().getUsed();
long max = metaspaceBean.getUsage().getMax();
if (used > max * 0.8) { // 超过 80%
log.warn("Metaspace 使用率 {}/{},触发清理", used, max);
cleanupStaleLoaders();
System.gc();
}
完整方案:QLExpress + 隔离 ClassLoader + 定时回收
把上面所有的内容串起来:
规则执行层:
execute(rule, params):
loader = new IsolatedClassLoader()
try:
runner = createRunnerWithLoader(loader)
return runner.execute(rule, context)
finally:
runner = null
loader = null
// 不存到任何缓存里
Metaspace 监控层:
@Scheduled(fixedDelay = 30 * 60 * 1000)
monitorMetaspace():
used = metaspaceBean.usage.used / max
if used > 0.8:
log.warn("Metaspace 使用率 {:.0f}%", used * 100)
System.gc()
if used > 0.95:
log.error("Metaspace 濒临 OOM,建议重启")
JVM 启动参数:
-XX:MaxMetaspaceSize=256m
-XX:MetaspaceSize=128m
-XX:+PrintGCDetails
-Xlog:gc+metaspace=trace # JDK 11+ 打印 Metaspace GC 日志
几个常见的"你以为解决了但其实没有"的场景
场景一:以为 LRU 淘汰就够了
有人做了一个规则缓存,用 LRU 淘汰旧的规则。规则对象确实被移除了,但它加载过的类还在 Metaspace 里。LRU 淘汰的是业务对象,不是 JVM 的类。 ClassLoader 只要不被 GC,类就一直在。
场景二:用了弱引用但姿势不对
WeakHashMap<ClassLoader, Runner> cache = new WeakHashMap<>();
cache.put(loader, runner);
WeakHashMap 的 key 是弱引用,确实允许 ClassLoader 被 GC。但如果 value(runner)强引用了 loader,loader 就仍然有强引用,不会被 GC。弱引用的链条上不能有环。
场景三:Groovy 的 GroovyClassLoader 也没法自动卸载
很多人以为 Groovy 用了 GroovyClassLoader 就没事了。但实际上如果不手动清理 GroovyClassLoader,它加载过的类一样不会卸载。Groovy 只比 QLExpress 多了一个 clearCache() 方法,但这个方法要你自己去调,不是自动的。
总结
Metaspace OOM 的根因特别简单:类被加载了,ClassLoader 没释放,类永远在。
解法分两步:
执行时隔离——每次执行用独立 ClassLoader,用完即弃,不缓存、不留引用。这能保证不再产生新的泄露。
定时回收——设好 MaxMetaspaceSize 上限,超过 80% 主动触发 GC。JVM 参数里加上 Metaspace GC 日志,方便追踪。
两件事都做了,脚本引擎跑多久 Metaspace 都不会炸。规则再多,内存曲线也是锯齿形的——涨上去、GC 回收、降下来、再涨上去——而不是一路往右上角冲。
有用的话转给还在每天重启规则引擎服务的同事。
标题:脚本引擎 Metaspace OOM 防护:动态规则频繁加载导致内存泄漏?ClassLoader 隔离 + 定时回收!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/09/1780754790877.html
公众号:服务端技术精选
评论