Aviator表达式引擎缓存引发内存溢出分析和解决方案

一、问题现象

最近,我们项目的现场运维人员反映,每隔一两个月,网站的页面打开就会变得特别缓慢。而重启后端服务之后,网站的响应速度又能够恢复正常。这种反复出现的性能问题,给用户体验带来了极大的不便,我们有必要彻底分析并解决这个问题。

二、问题定位

2.1 排查过程

为了更好地重现和分析问题,我们要求运维人员下次出现类似情况时,先不要着急重启后端服务。果不其然,过了一段时间后,页面打开变慢的问题再次出现了。

我们首先通过浏览器开发者模式查看网页发起的接口请求信息,发现后端接口的响应速度非常慢,导致整个页面载入延迟严重。接下来,我们找到对应的后端服务进程,在后端服务器上直接调用该接口,测试其性能表现。结果显示,即使是在服务器上直接调用,该接口的响应速度依然很慢。

这种现象就值得高度怀疑了,我们继续观察了服务进程的GC(垃圾回收)情况,发现存在大量的Full GC,堆内存使用率接近99%。种种迹象表明,这很可能是由于内存泄露导致的性能问题。

为了进一步确认,我们使用相关命令将Java堆内存Dump下来,以便后续分析。

2.2 内存分析

拿到堆Dump文件后,我们使用Eclipse Memory Analyzer Tool(MAT)工具对其进行了分析。结果显示,在堆内存中,有一个特别大的ConcurrentHashMap对象,其中存储了大量的数据。通过查看对象的包路径,我们发现这个对象来自于aviator这个第三方依赖包。

查看项目代码,发现我们在使用Aviator表达式引擎时,确实有对表达式求值结果进行了缓存,原本是为了提高表达式执行的性能。不过,有一处代码的实现存在缺陷,导致每次执行表达式时,相关的变量数据也被缓存到了ConcurrentHashMap中,而不是重用缓存数据。

伪代码示意如下:

1
2
3
4
5
6
7
// 构造Aviator执行环境,这里的for循环是模拟了多次调用
public static void main( String[] args )
{
for (int i = 0; i < 100; i++) {
Long result = (Long) AviatorEvaluator.execute(" 2 + " + i, null, true);
}
}

由于每次传入的i的值不同,所以表达式在缓存中会被认为是新的entries,并不断占用内存空间。随着时间推移,缓存中的数据持续增长,最终导致内存溢出的问题。

三、解决方案

问题原因查明后,解决方案也就比较明确了。我们需要将变量数据的传入方式改为使用Aviator内置提供的env机制,避免将变量数据缓存进HashMap。伪代码如下:

1
2
3
4
5
6
7
8
9
10
// 构造Aviator执行环境,这里的for循环是模拟了多次调用
public static void main( String[] args )
{
for (int i = 0; i < 100; i++) {
Map<String, Object> env = new HashMap<>();
env.put("i", i);
// 使用env来传入变量,保证表达式本身不会变化
Long result = (Long) AviatorEvaluator.execute(" 2 + i", env, true);
}
}

这样一来,相同的表达式字符串在缓存中就只会存在一个条目,变量值的改变不会影响缓存的行为。经过这个调整后,我们的缓存使用就合理化了,不会再出现无限增长导致内存溢出的情况。

四、技术小结

通过这次事件,我们可以总结出以下一些经验教训:

  1. 使用缓存技术时,一定要谨慎设计键值对的构成,避免由于键过于”宽泛”而导致命中率下降、内存占用增长。对于本例,我们就是由于把可变数据也纳入了缓存的Key,才酿成了内存泄漏的隐患。
  2. 对于开源工具的使用,最好要全面了解其机制和最佳实践,而不是operates半遮面。Aviator表达式引擎为了保证变量的动态作用域,专门提供了env环境机制,我们本应使用这个标准方式。
  3. 及时有效地监控系统的运行状态、性能指标等,对发现和定位线上问题至关重要。这次问题就是被运维人员的经验性发现而得以解决,如果系统监控做的好,同样可以立即发现异常。
  4. 学会善用各类工具和框架,高效诊断分析内存、CPU等系统瓶颈和根本原因。本文用到的MAT、GC日志、HeapDump等,都是分析Java应用程序不可或缺的利器。