dromara / dromara/liteflow

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

Open
#92 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Java
Stars
3.9k
Forks
525
PR merge metrics
No merged PRs in 30d

Description

## 版本信息

- 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()` 清理。

```java
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`:

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

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

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

反编译 `LiquorEvaluator.class`:

```java
// 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()`:

```java
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`:

```java
// 在 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 官方能提供正式的解决方案。

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.