我们系统中引入了groovy脚本,很多代码直接使用groovy脚本来写,最后通过GroovyShell的evaluate方法来执行脚本。这样,确实很灵活很方便,但是没想到就是因为用了这个groovy脚本,把服务器都搞死了。
先看一下现象,我们一个springboot的服务,启动起来之后,占用内存大概800多M。然而,随着时间的推移,占用的内存会慢慢增加,变成1G,再变成2G、3G,慢慢的,服务器的可用内存就都被占用的差不多了。然后,CPU使用率就极速上升,达到了80%几,90%几。然后就没有然后了,服务器就GG了。
查看springboot服务的日志,也完全看不出问题,完全看不到内存溢出、内存泄漏相关的报错。其实,想想都知道的,肯定是哪里内存没释放,但是找起来却不那么好找,排查了半天,最终才定位到GroovyShell这个类上。
要分析这个问题,我们先要认识一下AppClassLoader,它是java内置类加载器,用来加载用户应用程序的类。里面有一个parallelLockMap,主要用来存储类锁,避免JVM加载同名的类,和提高类加载的并发度。
而我们可以看到GroovyShell里面有个GroovyClassLoader,GroovyClassLoader如果加载的是无类名的Script,每次都会生成一个不同的类名,导致parallelLockMap不断膨胀。如果执行脚本的频率不是很高,可能很难发现这个问题,还有几G空闲内存的服务器可能几个月都不会出现问题。但是,如果执行脚本的频率非常高,很快就会出现内存泄露问题。
看一下evaluate方法,里面有个generateScriptName方法。
public Object evaluate(String scriptText) throws CompilationFailedException {
return this.evaluate(scriptText, this.generateScriptName(), "/groovy/shell");
}
再看下generateScriptName方法,可以看到counter每次都是加1的。
protected String generateScriptName() {
return "Script" + this.counter.incrementAndGet() + ".groovy";
}
现在,问题就清晰了,生成script时,都是直接传入脚本内容,无论脚本内容是否重复,都新创建了class,造成了内存泄露,接下来就看看怎么解决。
我们可以通过Key-Value的方式,将脚本生成的class或Script对象通过Map方式缓存起来,这样生成的class和script就是有限的了,无论程序运行多少次,都不会存在parallelLockMap一直增加的情况,这样应该就能解决问题了。
原代码:
public Object execute(String scriptText, Map<String, Object> vars) {
groovyBinding.setThreadVariables(vars);
GroovyShell shell = new GroovyShell(groovyBinding);
Object rtn = shell.evaluate(scriptText);
return rtn;
}
修改后的代码:
public Object execute(String scriptText, Map<String, Object> vars) {
groovyBinding.setThreadVariables(vars);
GroovyShell shell = new GroovyShell(groovyBinding);
Script script;
String cacheKey = DigestUtils.md5Hex(scriptText);
if (scriptCache.containsKey(cacheKey)) {
script = scriptCache.get(cacheKey);
}
else {
script = shell.parse(scriptText);
scriptCache.put(cacheKey, script);
}
Object rtn = script.run();
return rtn;
}
修改完,重启springboot服务,再也不会出现内存泄露问题了,总算松了一口气!