版本信息
- 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.ClassLoader。JVM 规定 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 为空时(所有节点已卸载),主动替换 LiquorEvaluator 的 tempClassLoader,使旧 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 官方能提供正式的解决方案。
版本信息
使用场景
我们的 ETL 调度系统通过定时任务周期性地执行数据管道:每次调度为多个任务动态创建 Script Node → 执行 → finally 中调用
FlowBus.unloadScriptNode()清理。问题现象
服务运行一段时间后(约 1-2 周),Metaspace OOM:
通过
jstat -gc监控发现 Metaspace Used (MU) 持续增长,从不下降,直到达到 MaxMetaspaceSize 后 OOM。根因分析
通过反编译 LiteFlow 2.15.2 和 Liquor 1.5.8 的字节码,定位到以下问题链:
1.
unloadScriptNode()只清除 LiteFlow 层引用反编译
JavaxExecutor.class:只从
compiledScriptMap中移除了Execable引用,不触及底层 Liquor 的 ClassLoader。2. Liquor 的
tempClassLoader默认 10000 次编译才替换反编译
LiquorEvaluator.class:DynamicClassLoader继承java.lang.ClassLoader。JVM 规定 Class 只有在其 ClassLoader 被 GC 后才能从 Metaspace 卸载。在tempClassLoader被替换之前,所有编译的 Class 共享同一个 ClassLoader,全部无法被 GC 回收。3.
JavaxExecutor.isCache默认为 false反编译
JavaxExecutor.init():Boolean.parseBoolean(null)返回false,所以CodeSpec.cached(false),走tempClassLoader路径。泄漏链路总结
建议
ScriptExecutor.unLoad()/cleanCache()应增加对底层脚本引擎 ClassLoader 的清理能力。以下是几种可能的方向:方案 A:
unLoad后检查是否需要重置 ClassLoader在
JavaxExecutor中,当compiledScriptMap为空时(所有节点已卸载),主动替换LiquorEvaluator的tempClassLoader,使旧 ClassLoader 可被 GC 回收。方案 B:提供显式的 Metaspace 清理 API
在
ScriptExecutor中增加类似resetClassLoader()的方法,供使用者在批量卸载脚本节点后调用。方案 C:降低 Liquor 的
cahceCapacity或使其可配置当前
LiquorEvaluator硬编码cahceCapacity=10000,导致在动态创建/销毁场景下 ClassLoader 长期不替换。如果能通过 LiteFlow 的script-setting暴露此配置,使用者可以根据场景调整。当前的临时 Workaround
通过反射在每次执行后强制替换
tempClassLoader:这个方案有效但依赖反射,存在 Liquor 版本升级后字段名变更的风险,期望 LiteFlow 官方能提供正式的解决方案。