Skip to content

FlowBus.unloadScriptNode() 无法释放 Metaspace,liteflow-script-javax 动态编译的 Class 持续累积导致 OOM #92

@DSir-eden

Description

@DSir-eden

版本信息

  • LiteFlow: 2.15.2
  • 脚本插件: liteflow-script-javax + liteflow-script-javax-pro
  • Liquor: 1.5.8(由 liteflow-script-javax 传递依赖)
  • JDK: 17
  • 运行环境: K8S Pod,Metaspace 192-512MB

使用场景

我们的 ETL 调度系统通过定时任务周期性地执行数据管道:每次调度为多个任务动态创建 Script Node → 执行 → finally 中调用 FlowBus.unloadScriptNode() 清理。

try {
    // 动态创建脚本节点
    for (DFC dfc : dfcList) {
        LiteFlowNodeBuilder.createScriptNode()
            .setId(beanName).setName(beanName)
            .setScript(dfc.getScript()).setLanguage(dfc.getLanguage())
            .build();
    }
    LiteFlowChainELBuilder.createChain().setChainId(chainId).setEL(el).build();
    
    // 执行
    flowExecutor.execute2FutureWithRid(chainId, null, requestId, flowContent);
} finally {
    // 清理
    FlowBus.removeChain(chainId);
    for (DFC dfc : dfcList) {
        FlowBus.unloadScriptNode(beanName);
    }
}

问题现象

服务运行一段时间后(约 1-2 周),Metaspace OOM:

java.lang.OutOfMemoryError: Metaspace

通过 jstat -gc 监控发现 Metaspace Used (MU) 持续增长,从不下降,直到达到 MaxMetaspaceSize 后 OOM。

根因分析

通过反编译 LiteFlow 2.15.2 和 Liquor 1.5.8 的字节码,定位到以下问题链:

1. unloadScriptNode() 只清除 LiteFlow 层引用

反编译 JavaxExecutor.class

// JavaxExecutor.unLoad(String nodeId)
public void unLoad(String nodeId) {
    compiledScriptMap.remove(nodeId);  // ← 仅此一行
}

只从 compiledScriptMap 中移除了 Execable 引用,不触及底层 Liquor 的 ClassLoader

2. Liquor 的 tempClassLoader 默认 10000 次编译才替换

反编译 LiquorEvaluator.class

// LiquorEvaluator 构造函数
public LiquorEvaluator(ClassLoader parent) {
    this(parent, 10000);  // cahceCapacity = 10000
}

// build() 方法(非缓存路径,即 liteflow-script-javax 的默认路径)
protected Class<?> build(CodeSpec codeSpec) {
    if (!codeSpec.isCached()) {
        tempCount++;
        if (tempCount > cahceCapacity) {       // 10000 次后才替换
            tempClassLoader = compiler.newClassLoader();
            tempCount = 0;
        }
        compiler.setClassLoader(tempClassLoader);  // 所有编译共享同一 ClassLoader
    }
    // ... compile and load class
}

DynamicClassLoader 继承 java.lang.ClassLoaderJVM 规定 Class 只有在其 ClassLoader 被 GC 后才能从 Metaspace 卸载。在 tempClassLoader 被替换之前,所有编译的 Class 共享同一个 ClassLoader,全部无法被 GC 回收。

3. JavaxExecutor.isCache 默认为 false

反编译 JavaxExecutor.init()

public ScriptExecutor init() {
    String isCache = config.getScriptSetting().get("javax-is-cache");
    this.isCache = Boolean.parseBoolean(isCache);  // null → false
    // ...
}

Boolean.parseBoolean(null) 返回 false,所以 CodeSpec.cached(false),走 tempClassLoader 路径。

泄漏链路总结

FlowBus.unloadScriptNode(nodeId)
  → JavaxExecutor.unLoad(nodeId)
    → compiledScriptMap.remove(nodeId)     ← 只断开 LiteFlow 的引用
    ✗ 不触及 LiquorEvaluator.tempClassLoader
    ✗ tempClassLoader 仍持有所有已编译 Class 的字节码(DynamicClassLoader.byteCodes Map)
    ✗ ClassLoader 未被替换 → Class 无法从 Metaspace 卸载

建议

ScriptExecutor.unLoad() / cleanCache() 应增加对底层脚本引擎 ClassLoader 的清理能力。以下是几种可能的方向:

方案 A:unLoad 后检查是否需要重置 ClassLoader

JavaxExecutor 中,当 compiledScriptMap 为空时(所有节点已卸载),主动替换 LiquorEvaluatortempClassLoader,使旧 ClassLoader 可被 GC 回收。

方案 B:提供显式的 Metaspace 清理 API

ScriptExecutor 中增加类似 resetClassLoader() 的方法,供使用者在批量卸载脚本节点后调用。

方案 C:降低 Liquor 的 cahceCapacity 或使其可配置

当前 LiquorEvaluator 硬编码 cahceCapacity=10000,导致在动态创建/销毁场景下 ClassLoader 长期不替换。如果能通过 LiteFlow 的 script-setting 暴露此配置,使用者可以根据场景调整。


当前的临时 Workaround

通过反射在每次执行后强制替换 tempClassLoader

// 在 finally 块中,unloadScriptNode 之后调用
Object evaluator = LiquorEvaluator.getInstance();
Field compilerField = LiquorEvaluator.class.getDeclaredField("compiler");
compilerField.setAccessible(true);
DynamicCompiler compiler = (DynamicCompiler) compilerField.get(evaluator);

Field tempCLField = LiquorEvaluator.class.getDeclaredField("tempClassLoader");
tempCLField.setAccessible(true);
tempCLField.set(evaluator, compiler.newClassLoader());

Field tempCountField = LiquorEvaluator.class.getDeclaredField("tempCount");
tempCountField.setAccessible(true);
tempCountField.setInt(evaluator, 0);

这个方案有效但依赖反射,存在 Liquor 版本升级后字段名变更的风险,期望 LiteFlow 官方能提供正式的解决方案。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions