GroovyShell使用不当引起的内存泄漏分析

1、背景

一、背景

项目中会使用Groovy动态计算字符串脚本,运行一段时间后监控发现内存使用占比较高。

二、问题原因分析

1、dump堆文件分析
1
jmap -dump:format=b,file=dump.hprof <pid>
2、使用MAT分析堆文件

memory leak

分析堆文件可以查看到有一块区域内存使用较多,疑似存在泄漏问题,进一步查看 Duplicate Classes查看是否存在重复对象

duplicate classes

此时考虑Groovy执行存在内存泄漏的问题。

3、代码分析
1
2
var groovyShell = new GroovyShell();
Object obj = groovyShell.evaluate(scriptContent);

Groovy在执行脚本前会见字符串内容通过反射的形式转为Class文件,每次都会通过反射创建一个 Script 实例对象

1
2
3
4
5
6
7
8
9
10
11
12
public static Script newScript(Class<? extends Script> scriptClass, Binding context) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
Script script;
try {
Constructor<? extends Script> constructor = scriptClass.getConstructor(Binding.class);
script = constructor.newInstance(context);
} catch (NoSuchMethodException e) {
// Fallback for non-standard "Script" classes.
script = scriptClass.getDeclaredConstructor().newInstance();
script.setBinding(context);
}
return script;
}

通过反射创建对象时会加载执行 GroovyObjectSupport.getDefaultMetaClass;最终的问题在于 org.codehaus.groovy.reflection.ClassInfo定义了一个static类型的GlobalClassSet,在GlobalClassSet中会创建一个ManagedConcurrentLinkedQueue,队列中会一直追加通过ClassInfo包裹的Script实例信息。

分析到此可以基本得出由于Script的每次反射创建,队列中的实例数目一直在增加,垃圾回收时无法释放;而由于执行的脚本定义本身时固定的,变化的时运行时绑定的变量信息,可以考虑将 Scripit 实例信息进行缓存

4、代码优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private final ConcurrentHashMap<String, Script> scriptCacheMap = new ConcurrentHashMap<>();  // 存储Scripit对象

public Object executeScript(String script) {
var groovyShell = new GroovyShell();

// 计算脚本 md5
var scriptMd5 = genSourceCacheKey(scriptContent);

// 此处不考虑并发的问题,并发访问时,多解析脚本内容,不会导致数据的不安全
Script groovyScript;
if (scriptCacheMap.containsKey(scriptMd5)) {
groovyScript = scriptCacheMap.get(scriptMd5);
} else {
groovyScript = groovyShell.parse(scriptContent);
scriptCacheMap.put(scriptMd5, groovyScript);
}

Object obj = groovyScript.run();
}

private String genSourceCacheKey(String script) {
// 此处与groovy计算脚本md5保持一致
try {
return EncodingGroovyMethods.md5(script);
} catch (NoSuchAlgorithmException e) {
// TODO Auto-generated catch block
throw new GroovyRuntimeException(e);
}
}

此处调用parse解析字符串脚本时未加锁,即使在多线程并发场景,产生的结果仅仅为多解析几次,Script实例信息存在覆盖的情况,不存在数据不安全的问题。如果加锁还会存在性能损失问题,得不偿失。

三、其他

1、部分文章介绍由于脚本文件名称随即产生引起泄漏

答:实际定位测试时不存在该问题,每次创建Script对象前生成的文件名称都为 Script1.groovy,分析GroovyShell代码可以查看到获取文件名称方法如下:

1
2
3
4
5
private final AtomicInteger counter = new AtomicInteger(0);

protected String generateScriptName() {
return "Script" + counter.incrementAndGet() + ".groovy";
}
2、生产环境和本地验证配置
生产环境 验证环境
Spring Boot 2.0.6 3.3.1
JDK 1.8 21
Groovy 3.0.6 4.0.22

pom.xml

1
2
3
4
5
6
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>4.0.22</version>
<type>pom</type>
</dependency>

GroovyShell使用不当引起的内存泄漏分析
https://probiecoder.cn/java/groovy-shell.html
作者
duwei
发布于
2025年4月22日
许可协议