规则引擎每改一次就加载一次类,Metaspace默默涨到1G把服务打挂了

凌晨四点,一台订单服务的 Pod 重启了三次。

不是 OOM killer,是老年代也正常,JVM 挂的时候堆才用了 2G(最大 4G)。诡异的是 Metaspace 占了 1.2G,而且每次重启后不到两小时又回到 1.2G。

翻 Metaspace 的内存分布,Class Count 已经飙到 15 万。正常一个 Spring Boot 服务也就 1-2 万个类。这多出来的十几万个类哪来的?

顺着加载的类名找过去——全部是 com.alibaba.qlexpress.ExpressionRunner 加上不同的数字后缀:

com.alibaba.qlexpress.ExpressionRunner$1
com.alibaba.qlexpress.ExpressionRunner$2
com.alibaba.qlexpress.ExpressionRunner$3
...
com.alibaba.qlexpress.ExpressionRunner$154327

QLExpress 每次执行 QLExpressRunner.execute() 时,会把规则脚本编译成字节码,然后通过一个新的类加载器加载到 JVM。运营批量改了 500 条规则,每条规则触发一次编译加载——500 个类。一天改几批,一个月下来十几万个类。

类加载了,但从没卸载过。


一、Metaspace 泄漏的本质

先理清几个概念:

  • Metaspace 存的是类的元数据——类名、方法签名、字段定义、常量池。不在堆上,在直接内存里。
  • 类什么时候卸载? 只有当加载这个类的 ClassLoader 本身被 GC 回收时,类才会被卸载。
  • ClassLoader 什么时候被回收? 当没有任何对象引用它,也没有它加载的任何类的实例存活时。

这三个条件凑在一起,解释了为什么 QLExpress 的类只加不减:

QLExpressRunner.execute(script)
    → ExpressionRunner.compile(script)          // 编译成字节码
    → new ClassLoader().defineClass(bytes)       // 新 ClassLoader 加载
    → 缓存这个编译结果(QlExpress 内部持有引用)
    → ClassLoader 永远不会被 GC
    → 类永远不会被卸载

QLExpress 内部有一个编译缓存,key=脚本hash, value=编译结果。这个缓存里的每条记录都持有编译后的类实例,而类实例又引用着它的 ClassLoader。只要缓存不清,ClassLoader 就永远可达——GC 收不掉。

每改一次规则脚本,缓存里就多一条新记录,对应一个新的 ClassLoader 和一组新的类。 一天改 500 条规则,就是 500 组。这 500 组永远不释放。


二、临时止痛:清缓存 + 限制 Metaspace

快速止血分两步:

第一步:清理编译缓存。 QLExpress 提供 API 清除缓存:

@Scheduled(cron = "0 0 4 * * ?")  // 每天凌晨 4 点
public void clearCompileCache() {
    qlExpressRunner.getEngine().clear();          // 清编译缓存
    System.gc();                                   // 触发 GC 回收 ClassLoader
    log.info("QLExpress 编译缓存已清理,Metaspace: {}MB",
        ManagementFactory.getMemoryPoolMXBeans().stream()
            .filter(b -> "Metaspace".equals(b.getName()))
            .findFirst().get().getUsage().getUsed() / 1024 / 1024
    );
}

但这只是创可贴——凌晨清完,白天运营改规则继续累积。要根除得从 ClassLoader 隔离下手。

第二步:给 Metaspace 设上限,OOM 也优雅:

-XX:MaxMetaspaceSize=512m
-XX:MetaspaceSize=256m

至少比 Metaspace 无限制撑爆直接内存导致整个机器挂要好。


三、根除方案:ClassLoader 生命周期隔离

思路很简单:不让 QLExpress 自己管理 ClassLoader,而是我们给它一个"一次性"的 ClassLoader,用完就扔。

@Component
public class IsolatedExpressionEngine {
    
    /**
     * 每个规则引擎实例绑定一个独立的 ClassLoader
     * 引擎过期后 ClassLoader 跟随回收
     */
    private static class EngineInstance {
        final QLExpressRunner runner;
        final ClassLoader classLoader;
        final Instant createdAt;
        
        EngineInstance(QLExpressRunner runner, ClassLoader cl) {
            this.runner = runner;
            this.classLoader = cl;
            this.createdAt = Instant.now();
        }
        
        boolean isExpired(Duration ttl) {
            return Duration.between(createdAt, Instant.now()).compareTo(ttl) > 0;
        }
    }
    
    // 当前活跃引擎
    private volatile EngineInstance current;
    // 已过期的旧引擎引用(弱引用,不阻止 GC)
    private final Set<WeakReference<EngineInstance>> retired = ConcurrentHashMap.newKeySet();
    
    public Object execute(String script, Map<String, Object> context) {
        EngineInstance engine = getOrCreateEngine();
        return engine.runner.execute(script, context, 
            errorList -> log.warn("规则执行异常: {}", errorList));
    }
    
    private EngineInstance getOrCreateEngine() {
        if (current == null || current.isExpired(Duration.ofHours(1))) {
            rotateEngine();
        }
        return current;
    }
    
    /**
     * 切换引擎:创建新的 ClassLoader 和引擎实例
     * 旧的变成弱引用,等待 GC 回收
     */
    private synchronized void rotateEngine() {
        if (current != null && !current.isExpired(Duration.ofHours(1))) {
            return;  // 双重检查
        }
        
        // 旧的标记为退休,保留弱引用用于追踪
        if (current != null) {
            retired.add(new WeakReference<>(current));
        }
        
        // 新的 ClassLoader——隔离旧的类的世界
        URLClassLoader newLoader = new URLClassLoader(
            new URL[0], 
            ClassLoader.getSystemClassLoader()
        );
        
        QLExpressRunner newRunner = new QLExpressRunner();
        current = new EngineInstance(newRunner, newLoader);
        
        log.info("QLExpress 引擎已轮换,旧引擎等待 GC 回收");
    }
    
    /**
     * 定时清理已被 GC 回收的弱引用
     */
    @Scheduled(fixedRate = 300_000)
    public void cleanRetired() {
        retired.removeIf(ref -> ref.get() == null);
        log.debug("退休引擎追踪数: {}", retired.size());
    }
}

核心逻辑:每 1 小时创建一个新的 URLClassLoader,旧的 ClassLoader 失去强引用后变成弱引用。GC 在下一次运行时发现没有强引用指向它,就回收 ClassLoader 以及它加载的所有类。Metaspace 空间随之释放。


四、用弱引用追踪卸载效果

怎么确认 ClassLoader 真的被回收了?在退休引擎上加监控:

@Scheduled(fixedRate = 60_000)
public void monitorMetaspace() {
    MemoryPoolMXBean metaspaceBean = ManagementFactory.getMemoryPoolMXBeans()
        .stream()
        .filter(b -> "Metaspace".equals(b.getName()))
        .findFirst().get();
    
    long used = metaspaceBean.getUsage().getUsed() / 1024 / 1024;
    long max = metaspaceBean.getUsage().getMax() / 1024 / 1024;
    double usageRate = (double) used / max;
    
    // 追踪退休引擎数量
    long aliveRetired = retired.stream()
        .filter(ref -> ref.get() != null)
        .count();
    
    gaugeService.report("metaspace.used_mb", used);
    gaugeService.report("metaspace.usage_rate", usageRate);
    gaugeService.report("qlexpress.retired_engines", aliveRetired);
    
    if (usageRate > 0.8) {
        log.warn("Metaspace 使用率 {}%, 退休引擎残留: {}", 
            (int)(usageRate * 100), aliveRetired);
    }
}

正常情况下,retired 里的弱引用在一轮 GC 后变成 null,aliveRetired 接近 0。如果 aliveRetired 持续 > 10,说明有东西还在引用旧引擎——检查是否存在 ThreadLocal、静态变量或者缓存没有清理干净。


五、更好的方案:扔掉 QLExpress 的编译缓存,用 Guava Cache

QLExpress 自带的缓存是不限大小、不限时间的 HashMap。改成 Guava Cache,让缓存自己淘汰:

@Component
public class CachedExpressionEngine {
    
    // 最大 1000 条编译缓存,超过按 LRU 淘汰
    // 淘汰后对应的 ClassLoader 失去引用,可被 GC
    private final Cache<String, Object> compileCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterAccess(30, TimeUnit.MINUTES)
        .removalListener((key, value, cause) -> {
            log.debug("编译缓存淘汰: {} cause={}", key, cause);
        })
        .build();
    
    private final QLExpressRunner runner = new QLExpressRunner();
    
    public Object execute(String script, Map<String, Object> context) {
        String key = DigestUtils.md5Hex(script);
        
        // 先查缓存
        Object cached = compileCache.getIfPresent(key);
        if (cached != null) {
            return runner.execute(script, context, null);
        }
        
        // 未命中:走正常编译执行
        Object result = runner.execute(script, context, 
            errorList -> log.warn("规则执行异常: {}", errorList));
        compileCache.put(key, result);
        return result;
    }
}

但这里有个问题——QLExpress 的 execute() 内部已经做了编译缓存,再加一层缓存只是换了个地方存引用,ClassLoader 还是被 QLExpress 内部缓存持有。所以更彻底的做法是:不用 QLExpress 的缓存,每次用新的 Runner 实例执行完就扔。

public Object execute(String script, Map<String, Object> context) {
    // 每次都创建新的 Runner,执行完就丢弃
    // ClassLoader 在 Runner 被 GC 后一并回收
    QLExpressRunner runner = new QLExpressRunner();
    try {
        return runner.execute(script, context, null);
    } finally {
        // 不持有 runner 的引用,让它随方法结束变成垃圾
    }
}

缺点是没有编译缓存了,每条规则每次执行都要编译一次。对于调用频率不高的规则(运营配的活动规则一天执行几千次),编译开销可以忽略。如果调用频率很高(每秒几千次),那保留 Caffeine 缓存 + 引擎轮换机制是更平衡的选择。


六、效果对比

指标问题期临时止血引擎轮换
Metaspace 峰值1.2G512MB(凌晨清)120MB
Metaspace 稳定值持续增长锯齿波动80-150MB 平稳
Class Count15万+2万~8万波动1.5万 平稳
Pod OOM 重启每天数次偶尔0
规则执行 RT正常凌晨清缓存瞬间略高正常

最明显的变化是 Metaspace 的走势图——从一条斜向上的线变成了围着 100MB 上下浮动的横线。


七、注意事项

注意一:-XX:MaxMetaspaceSize 是兜底不是方案。 设 512M 能防止服务挂掉,但如果 ClassLoader 泄漏不改,Metaspace 还是会打到上限然后抛 OutOfMemoryError: Metaspace。只是从"机器死"变成了"服务死"——稍微优雅了一点,但不解决问题。

注意二:不是只有 QLExpress 会泄漏 Metaspace。 Groovy、Aviator、甚至 Java 的 ScriptEngine——只要涉及"动态编译脚本 → 生成类 → 加载到 JVM",就有 Metaspace 泄漏风险。排查方法一样:jcmd <pid> GC.class_stats | head 看哪些类在膨胀。

注意三:ThreadLocal 是 ClassLoader 回收的天敌。 如果引擎执行的线程中设置了 ThreadLocal 并持有旧 ClassLoader 加载的类的实例,该 ClassLoader 永远不会被回收。建议在引擎轮换时清理 ThreadLocal,或者引擎执行的线程使用独立线程池(用完就停)。

注意四:别在高峰期做引擎轮换。 轮换瞬间(创建新引擎 + 加载类)会有一次性的 CPU 和 Metaspace 开销。建议在凌晨低峰期或者用平滑过渡(新旧引擎并行运行一段时间再切)。


动态脚本引擎的 Metaspace 泄漏,是那种"系统跑了一年都很正常,突然有一天就挂了"的慢性病。JVM 不会告诉你 ClassLoader 没卸载,GC 日志不会显示 Metaspace 在偷偷涨。等到 OOM 那天,可能已经积攒了十几万个类。

你们在用 QLExpress、Groovy 或者 Aviator 做规则引擎吗?有没有观察过 Metaspace 的走势?


标题:规则引擎每改一次就加载一次类,Metaspace默默涨到1G把服务打挂了
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/24/1782024214918.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消