Aviator 踩坑日记

Aviator 是一个高性能、轻量级的 Java 语言实现的表达式求值引擎,主要用于各种表达式的动态求值。Aviator 的实现思路与其他轻量级的求值器不同,其他求值器一般都是通过解释的方式运行,而 Aviator 则是直接将表达式编译成 Java 字节码,交给 JVM 去执行。

问题描述

今天发版,监控线上 JVM 信息。发现日志量暴涨,而同时期业务量也增长了一倍多,所以一开始并没有觉着有什么不正常的地方。

日志量暴涨

qps

Metaspace 空间暴涨

直到看到类加载的信息,发现系统在不断地加载类,导致 Metaspace 空间快速增长,频繁触发 major GC(full GC)这是一个极其不正常的现象,因为一个系统运行一段时间之后,其加载的类数量应该是趋于稳定的,不应该存在如此大的波动。
Metaspace

class-loading

原因定位

继续查看监控,发现有一个线程池的调用量暴涨,比平时多了几十倍。
executor-pool

使用这个线程池的是支付路由业务,里面涉及到了表达式求值的逻辑,用到了 Aviator 框架。
业务使用的方法

compile 方法的关键代码如下:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* 将表达式编译为 Expression 对象。
*
* @param cacheKey 默认为表达式。
* @param expression 表达式。
* @param cached 是否缓存编译结果。
* @return
*/
public Expression compile(final String cacheKey, final String expression, final boolean cached) {
if (expression == null || expression.trim().length() == 0) {
throw new CompileExpressionErrorException("Blank expression");
}
if (cacheKey == null || cacheKey.trim().length() == 0) {
throw new CompileExpressionErrorException("Blank cacheKey");
}

//提供两种缓存模式,LRU 缓存和普通缓存。使用 LRU 缓存需要手动指定 LRUMap 容量。
//默认使用普通缓存。
if (cached) {
FutureTask<Expression> existedTask = null;
if (this.expressionLRUCache != null) {
boolean runTask = false;
synchronized (this.expressionLRUCache) {
//如果命中缓存,直接返回结果,不需要重新编译。
existedTask = this.expressionLRUCache.get(cacheKey);
if (existedTask == null) {
existedTask = newCompileTask(expression, cached);
runTask = true;
this.expressionLRUCache.put(cacheKey, existedTask);
}
}
//缓存中不存在时,再重新编译表达式。
if (runTask) {
existedTask.run();
}
} else {
FutureTask<Expression> task = this.expressionCache.get(cacheKey);
if (task != null) {
//如果命中缓存,直接返回结果,不需要重新编译。
return getCompiledExpression(expression, task);
}
task = newCompileTask(expression, cached);
existedTask = this.expressionCache.putIfAbsent(cacheKey, task);
//缓存中不存在时,再重新编译表达式。
if (existedTask == null) {
existedTask = task;
existedTask.run();
}
}
//直接返回之前的编译结果,也就是 Expression 类。
return getCompiledExpression(cacheKey, existedTask);

} else {
//不开启缓存的情况下,每个表达式都需要重新编译。将会产生大量的 AviatorClassLoader 和 Expression 类。
return innerCompile(expression, cached);
}

}

在不开启缓存的情况下,innerCompile 方法将会产生大量的类加载器和内部类。这也是 class-loading 图中 class 数量一直增长的原因。

inner-compile 方法

AviatorClassLoader

ASMCodeGenerator

什么时候分配 Metaspace 空间

当一个类被加载时,它的类加载器会在 Metaspace 中分配空间用于存放这个类的元数据。 如下图所示,类加载器 Id 第一次加载类 X 和 Y 的时候,会在 Metaspace 中为它们开辟空间存放元信息。

Metaspace 分配

什么时候回收 Metaspace 空间

分配给类的 Metaspace 空间,是归属于这个类的类加载器的。只有当这个类加载器被卸载的时候,这个空间才会释放。

所以,只有当这个类加载器加载的所有类都没有存活的对象,并且没有到达这些类和类加载器的引用时,相应的 Metaspace 空间才会被 GC 释放。(JLS 12.7. Unloading of Classes and Interfaces

Metaspace 回收

修复方案

今天的问题之所以发生,是因为 Aviator 框架会不断地生成新的类加载器和类。 我们只需要开启缓存,这样表达式的编译结果就会被缓存起来。下次碰到相同的表达式,直接从缓存中返回结果,不用再编译。避免了因为 Metaspace 空间快速增长而导致频繁 major GC 的问题。

修复前

修复后

小结

  • 对于 JVM 里面的内存需要在启动时进行限制, 包括我们熟悉的堆内存、直接内存和 Metaspace 空间,这是保证线上服务正常运行的兜底措施。
  • 对于使用了 ASM 等字节码增强工具的类库,在使用他们时请多加小心(尤其是 JDK1.8 以后)。使用类库时,多注意代码的写法,尽量不要出现明显的内存泄漏。

引用