脚本引擎 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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消