Aviator 是一个高性能、轻量级的 Java 语言实现的表达式求值引擎,主要用于各种表达式的动态求值。Aviator 的实现思路与其他轻量级的求值器不同,其他求值器一般都是通过解释的方式运行,而 Aviator 则是直接将表达式编译成 Java 字节码,交给 JVM 去执行。
问题描述
今天发版,监控线上 JVM 信息。发现日志量暴涨,而同时期业务量也增长了一倍多,所以一开始并没有觉着有什么不正常的地方。
日志量暴涨

qps

直到看到类加载的信息,发现系统在不断地加载类,导致 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 中分配空间用于存放这个类的元数据。 如下图所示,类加载器 Id 第一次加载类 X 和 Y 的时候,会在 Metaspace 中为它们开辟空间存放元信息。
Metaspace 分配

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

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

修复后

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