背景

项目中需要动态的配置规则,所以技术选型上选择了跟Java融合度比较高的 Groovy(因为可以直接调用java的类库),但是由于缺乏groovy实战经验,在项目开发过程中遇到了以下问题:

  • JVM metaspace 持续 OOM

即在项目部署后,业务调用量上来后,Metaspace 空间持续不断上涨,当 commited 的大小接近 Metaspace 的设置值(-XX:MetaspaceSize=)就会发生FullGC,当 commited 的大小要超过 Metaspace 设置值的最大值(-XX:MaxMetaspaceSize=)就会发生OOM.

像下图所示的这样,应用 Metaspace 的默认值和最大值都是 256M.
从图上可以看会频繁的发生 FullGC, 最终导致了 OOM.

众所周知,FullGC是很占用 CPU 资源的,会影响到我们应用本身的性能,生产环境中的应用,应该尽量避免 FullGC 的发生.

2023-03-04T02:59:25.png

问题的定位

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.

2023-03-04T03:44:45.png

我们当前的业务逻辑是:每次处理一个请求的时候,都会调用 GroovyShell 去读取一个字符串脚本去运行,而 GroovyShell 每次会为每个字符串脚本生成一个 Class,这个 Class 就是我们上面从快照里看到的 Script1. 随着时间的推移,系统累计处理的请求越来越多,这些 Class 也同时在 Metaspace 里不断的累积,最终导致了不断的 FullGC 及 OOM.

FullGC及OOM问题的修复

下面是 GroovyShell 加载执行脚本的大体流程:
2023-03-04T04:05:16.png
出问题的代码是这样子的:

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 终于平息了下来,长舒一口气。

2023-03-04T04:41:49.png

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 异常,并且业务处理效率也没有受影响.

文章目录