Java项目中引入Groovy的优化过程
背景
项目中需要动态的配置规则,所以技术选型上选择了跟Java融合度比较高的 Groovy(因为可以直接调用java的类库),但是由于缺乏groovy实战经验,在项目开发过程中遇到了以下问题:
- JVM metaspace 持续 OOM
即在项目部署后,业务调用量上来后,Metaspace 空间持续不断上涨,当 commited 的大小接近 Metaspace 的设置值(-XX:MetaspaceSize=
像下图所示的这样,应用 Metaspace 的默认值和最大值都是 256M.
从图上可以看会频繁的发生 FullGC, 最终导致了 OOM.
众所周知,FullGC是很占用 CPU 资源的,会影响到我们应用本身的性能,生产环境中的应用,应该尽量避免 FullGC 的发生.
问题的定位
Metaspace一般存放 Class,所以肯定是代码中某个地方会持续不断的生成 Class.
在发生 OOM 后,我们一般会先从JVM内存的快照文件入手去分析一下,只要在 VM 中配置了以下参数,
则 JVM 在发生 OOM 之前会 Dump 出 heap及线程快照等信息.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/dump.hprof
Dump 出的快照文件可以使用内存分析工具:MAT、Jprofile 等工具进行分析.
但是这次的 OOM 从快照文件里看不出什么有用的信息,再回头查看监控图才发现,OOM 都发生在每次的 FullGC 后重新申请内存空间的阶段,如下图所示,这个时候那些持续产生出的 Class 已经被 GC 回收掉了,所以在 Dump 的快照里已经看不到了.
因此,需要去线上的机器里使用 jmap 命令 Dump 出还没有被 GC 回收掉的内存快照。jmap -dump:format=b,file=/tmp/my.hprof [java进程id]
这个时候再去分析,就发现了问题所在,按重复的类进行归类,看到了一个叫 Script1 的类,通过引用关系发现它来自 Groovy.
我们当前的业务逻辑是:每次处理一个请求的时候,都会调用 GroovyShell 去读取一个字符串脚本去运行,而 GroovyShell 每次会为每个字符串脚本生成一个 Class,这个 Class 就是我们上面从快照里看到的 Script1. 随着时间的推移,系统累计处理的请求越来越多,这些 Class 也同时在 Metaspace 里不断的累积,最终导致了不断的 FullGC 及 OOM.
FullGC及OOM问题的修复
下面是 GroovyShell 加载执行脚本的大体流程:
出问题的代码是这样子的:
private final static GroovyShell shell = new GroovyShell();
// 替换参数
String scriptString = CalculateUtils.replaceScriptVariable(scriptString, variables);
// 转换加载Script类并生成 script对象
Script script = shell.parse(scriptString);
// 运行 script
Object result = script.run();
从上面的代码可以看出,在每次运行脚本前,将入参预先组装到脚本模板字符串中,然后调用 GroovyShell 去转换成对应的类Script,并且生成对象script,然后运行run方法得到结果,这样做的问题,就是每次生成的类Script并不能复用,因为每次组装 scriptString 的入参都是不一样的.
可以只用模板字符串生成 Script 类,将这个类生成的对象缓存起来,每次业务调用时,仅传入参数到这个生成的类中执行,就可以避免每次都去全新加载类和生成对象了。
改造之后的代码如下
我们使用一个 Map 对象来缓存 Script 对象,key 为模版ID, 因为实际的业务,模版并不会很多,所以这个 Map 也不会很大,为了保证线程安全,我们使用ConcurrentHashMap.
/**
* 规则脚本缓存map,
* KEY: 模板ID
* VALUE: groovy生成的Script对象
*/
private static final Map<Long, Script> SCRIPTS = new ConcurrentHashMap<>();
每次先从缓存中获取script,拿不到时再去创建一个新的
private static Script getScript(Long templateId, String templateScript) {
if (SCRIPTS.containsKey(templateId)) {
return SCRIPTS.get(templateId);
} else {
Script script = SHELL.parse(templateScript);
SCRIPTS.put(templateId, script);
return script;
}
}
每次执行脚本时,通过 Binding 对象来绑定参数到 script 对象里,这里记得要将参数 map 进行深拷贝,不然会被 scrip 执行时修改掉.
// get script
Script script = getScript(request.getFundItemCalcConfig().getId(), request.getCalculateRuleValue());
// set params (deep-copy)
HashMap<String, String> params = new HashMap<>(request.getParamsMap());
script.setBinding(new Binding(params));
// groovy execute
Object groovyResult = script.run();
改好之后发到线上进行观察,发现 Metasapce 终于平息了下来,长舒一口气。
But, 新的问题来了,在观察系统日志时,发现了几条错误日志:
java.lang.IllegalArgumentException: Cannot compare java.lang.String with value '300' and java.lang.Integer with value '10'
at org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation.compareToWithEqualityCheck(DefaultTypeTransformation.java:822)
线程安全问题的解决
产生原因
为了保证脚本入参类型的统一性,我们的系统规定,所有的变量入参都为 String 类型,可以在脚本内部进行数据类型转换等行为,假如我们配置一个类似于 Max 函数的比较大小的脚本, 需要这样配置:
1 a = Integer.valueOf(a)
2 b = Integer.valueOf(b)
3 if(a >= b) {
4 return a
5 } else {
6 return b
7 }
而 a, b 就是通过 binding 对象绑定到 scritp 对象的,而 binding 对象是 script 对象一个属性,所以当多线程访问时会有线程安全问题.
假如有两个线程同时在使用 script 对象,
- 线程1已经执行到了第3行,拿到了a的值,假设此时a的值是Integer类型的10.
- 接着线程2得到了CPU的执行权,给script设置了新的binding,但是还没有开始调用script.run(), 此时b的值为字符串类型的'300'
- 接着重新回到了线程1,继续执行第三行代码,获取到的b的值为字符串类型,在做比较时,便抛出了上面IllegalArgumentException 异常.
解决办法
加锁
将操作 script 的过程加锁:
synchronized (script){
script.setBinding(binding);
result = script.run();
}
这样会保证在设置参数和运行的整个过程中,只有一个线程在执行. 解决了上面的问题,实际测试也没有问题,但是加锁总是不好的,在请求量大的时候会严重影响TPS. 降低系统的吞吐量。所以还有一种更好的无锁解决办法.
无锁
因为我们的应用是消费 MQ 消息来处理业务的,并且 MQ 处理的线程池设置的是固定大小,没有允许动态扩展,所以完全可以给每个线程维护一份 script 对象缓存,因为业务动态规则脚本并不多,所以即使这样做,缓存也不会很大,不会占用太多的内存空间。
每个线程拥有自己的 script 对象,从根本上就不会出现线程安全的问题,大大提高了业务处理效率.
代码的改造也很简单,只需要将缓存 map 的 key 里面加上线程信息即可:
private static Script getScript(Long templateId, String templateScript) {
// generate cache key
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(Thread.currentThread().getName());
stringBuilder.append(StringConstant.UNDERLINE);
stringBuilder.append(templateId);
String cacheKey = stringBuilder.toString();
if (SCRIPTS.containsKey(cacheKey)) {
return SCRIPTS.get(cacheKey);
} else {
Script script = SHELL.parse(templateScript);
SCRIPTS.put(cacheKey, script);
return script;
}
}
实际运行的效果也很不错,没有继续再出现 IllegalArgumentException 异常,并且业务处理效率也没有受影响.
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。