Durante el desarrollo, cuando el código se implementa en el test
entorno para realizar pruebas, el contenedor a menudo se reinicia debido a OOM, pero la misma generación prod
se implementa en el entorno sin ningún problema. Se sospecha que el paquete de imagen base implementado en caliente ha sido reemplazado en el test
entorno recientemente (porque nuestro servicio solo puede probarse en el test
entorno , por lo tanto, utilizando el complemento de implementación en caliente de modificación de código de la empresa). Por lo tanto, quise investigar la causa de OOM a través de algunas herramientas de JVM y finalmente encontré el error en el código histórico. Al mismo tiempo, el nuevo paquete de imagen básico implementado en caliente amplifica este efecto, lo que resulta en una fuga de metaspace
memoria . El proceso de solución de problemas es como sigue:
1. Ver el uso de memoria de la JVM
Para observar el uso de memoria de la JVM, usamos principalmente Arthas
el dashboard
comando y las jstat
herramientas . Primero, mire arthas
el áreadashboard
del panel de datos y descubra que el uso de la memoria del área aumenta constantemente hasta que supera el valor establecido y se produce un OOM.Memory
metaspace
MaxMetaspaceSize
metaspace
Además, verifique la situación de gc a través de la jstat
herramienta . El último gc se debió a que metaspace
el uso de la memoria superó el umbral de gc y metaspace
la tasa de uso siempre estuvo por encima del 90 %, lo que verificó aún más el metaspace
OOM:
2. Analizar las razones de MetaSpace OOM
Después de JDK8, metaspace
el área de datos correspondiente al área de datos de tiempo de ejecución de Java se 方法区
usa para almacenar datos como información de tipo, constantes, variables estáticas y cachés de código compilados por el compilador en tiempo real que ha cargado la máquina virtual 方法区
. límite de tamaño en sí mismo (limitado por el tamaño de la memoria física), pero puede usar el -XX:MaxMetaspaceSize
parámetro para establecer el límite superior Aquí establecemos 2G.
Dado que la información de metadatos de la clase ha reventado el metaespacio, echemos un vistazo a la carga y descarga de la clase, usemos la jstat
herramienta para ver la situación de carga de la clase y comparemos la diferencia entre test
los prod
dos entornos:
prueba
pinchar
prod
El servicio del entorno carga y descarga muchas clases (esto es en realidad un problema en línea, que se descubrió a través de esta investigación, y la razón se analiza más adelante), y es test
aún más grave, se cargan muchas clases, pero casi ninguna. las clases se descargan.
Concéntrese test
en la carga de clases del servicio, use Arthas
el classloader
comando :
A primera vista AviatorClassLoader
, sospecho. Otros cargadores son de spring y jdk. Este es de Google. Al mismo tiempo, la cantidad de instancias de este cargador de clases y la cantidad de clases cargadas es muy grande y continúa creciendo con el funcionamiento del servicio . Es bueno que la cantidad de instancias del cargador de clases sea grande, después de todo, solo tiene una copia del Class
objeto y no reventará el metaespacio, pero Class
será problemático cargarlo, porque es la clase que lo carga para juzgar si es la misma clase o no . El cargador + el nombre de clase completamente calificado se deciden juntos, por lo que puede comenzar con este cargador de clases, buscar el código globalmente y encontrar el punto de introducción.
3. Hemostasia rápida
Nuestro proyecto es un servicio de clase de informe del que nos hicimos cargo. Se utiliza un motor de cálculo de expresiones en el proyecto para AviatorEvaluator
calcular indicadores compuestos basados en cadenas de expresión. El pseudocódigo es el siguiente:
public static synchronized Object process(Map<String, Object> eleMap, String expression) {
// 表达式引擎实例
AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();
// 生产表达式对象
Expression compiledExp = instance.compile(expression, true);
// 计算表达式结果
return compiledExp.execute(eleMap);
}
复制代码
donde expression
es la cadena de expresión, que eleMap
es el par clave-valor entre el nombre de la variable y el valor específico de la expresión. Por expression
ejemplo a+b
, el par clave-valor correspondiente de eleMap es {"a":10, "b":20}
y process
el resultado del método es 30
.
之前从未仔细看过这段代码,现在看下来似乎有点不太对劲:synchronized
和 newInstance
是重复了吗?synchronized
是用来做同步的,说明这段代码中有共享资源竞争的情况,应该就是 AviatorEvaluator
实例了,目前的逻辑每次执行 process
都会实例化一个 AviatorEvaluator
对象,这已经不仅仅是线程私有了,而是每一个线程每次调用这个方法都会实例化一个对象,已经属于 线程封闭 的场景了,不需要用 synchronized
关键字做同步。我们的业务场景对这个方法的调用量非常大,每个指标的计算都会调用这个方法。至此,有以下结论:
synchronized
同步和线程封闭每个线程私有一个对象二者选其一就行;- 如果
AviatorEvaluator
是线程安全的话,使用单例模式就行,可以减轻堆区的内存压力;
谷歌出品的工具类不是线程安全的?不会吧?
查阅资料得知,AviatorEvaluator
的 execute
方法是线程安全的,代码里的使用姿势不对,修改代码如下,重新发布,不再OOM了。
// 删除 synchronized
public static Object process(Map<String, Object> eleMap, String expression) {
AviatorEvaluator.execute(expression, eleMap, true); // true 表示使用缓存
}
复制代码
4、代码分析
但是,还是有两点难以解释:
a) 为什么是 metaspace
?
b) 为什么使用热部署镜像的 test
环境出现 OOM,而 prod
没有出现 OOM?
如果是因为 AviatorEvaluator
对象太多导致的,那也应该是堆区 OOM;同时,prod
环境的请求量远大于 test
环境,如果是像目前 test
这种 metaspace
的膨胀速度,线上肯定也是会 OOM 的,差异在于 test
用的热部署的基础镜像包。
首先探寻第一个问题的答案,为什么是 metaspace
?
热部署?ClassLoader? 方法区?此时,脑海里回想起一道经典八股文:
动态代理的两种方式?(JDK动态代理和CGLIB动态代理的区别?AOP的实现原理?运行期、类加载期的字节码增强?)
感觉这次 OOM 很大可能是 ClassLoader 和 热部署(字节码增强) 撞出的火花 ...
通过阅读 AviatorEvaluator
的源码,发现调用链简化后大概是这样:
public Object execute(final String expression, final Map<String, Object> env, final boolean cached) {
Expression compiledExpression = compile(expression, expression, cached); // 编译生成 Expression 对象
return compiledExpression.execute(env); // 执行表达式,输出结果
}
复制代码
private Expression compile(final String cacheKey, final String exp, final String source, final boolean cached) {
return innerCompile(expression, sourceFile, cached); // 编译生成 Expression 对象
}
复制代码
private Expression innerCompile(final String expression, final String sourceFile, final boolean cached) {
ExpressionLexer lexer = new ExpressionLexer(this, expression);
CodeGenerator codeGenerator = newCodeGenerator(sourceFile, cached); //!!这个方法 new AviatorClassLoader 的实例
return new ExpressionParser(this, lexer, codeGenerator).parse();
}
复制代码
public CodeGenerator newCodeGenerator(final String sourceFile, final boolean cached) {
// 每个 AviatorEvaluatorInstance 一个 AviatorClassLoader 的实例作为成员变量
AviatorClassLoader classLoader = this.aviatorClassLoader;
//!!这个方法通过上面的类加载器不断生成并加载新的Class对象
return newCodeGenerator(classLoader, sourceFile);
}
复制代码
public CodeGenerator newCodeGenerator(final AviatorClassLoader classLoader, final String sourceFile) {
ASMCodeGenerator asmCodeGenerator =
// 使用字节码工具ASM生成内部类
new ASMCodeGenerator(this, sourceFile, classLoader, this.traceOutputStream);
}
复制代码
public ASMCodeGenerator(final AviatorEvaluatorInstance instance, final String sourceFile,
final AviatorClassLoader classLoader, final OutputStream traceOut) {
// 生成了唯一的内部类
this.className = "Script_" + System.currentTimeMillis() + "_" + CLASS_COUNTER.getAndIncrement();
}
复制代码
这时候原因差不多清晰了,使用 AviatorEvaluatorInstance
对象计算表达式会使用成员变量里的一个 AviatorClassLoader
加载自定义的字节码生成 CodeGenerator
对象。 AviatorEvaluatorInstance
用单例模式使用固然没什么问题,但是如果每次都 new 一个AviatorEvaluatorInstance
对象的话,就会有成百上千的 AviatorClassLoader
对象,这也解释了上面通过 Arthas
查看 classloader
会有这么多对应的实例,但 metaspce
的 Class
还是只有一份的,问题不大。同时看到生成的字节码对象都是 Script_
开头的,使用 arthas
的 sc
命令看下满足条件的类(数量非常多,只截取了部分),果然找到了 OOM 的元凶:
第二个疑问:为什么 prod
没有OOM?
上面通过 jstat
发现 prod
卸载了很多类,而 test
几乎不卸载类,两个环境唯一的区别是 test
用了热部署的基础镜像。
咨询了负责热部署的同事,了解到热部署 agent 会对 classloader
有一些 强引用
,监听 classloader
的加载的一些类来监听热更新,这会导致内存泄漏的问题。同时得到反馈,热部署后面会用 弱引用
优化。这里引用下《深入理解java虚拟机》中的解释:
因为大量的 AviatorEvaluatorInstance
创建了大量的 AviatorClassLoader
,并被热部署 agent 强引用了,得不到回收,那么这些类加载器加载的 Script_*
的Class对象也得不到卸载,直到 metaspace
OOM。
通过 JVM 参数 -Xlog:class+load=info
、-Xlog:class+unload=info
看下 prod
环境的类加载和类卸载日志,确实有大量 Script_*
类的加载和卸载:
而 test
环境这没有此种类的卸载,这边就不再贴图了。
此刻,我比较好奇的是热部署包里的什么类型的对象强引用了我们的自定义类加载器?使用 jprofiler
看下两个环境的堆转储文件,不看不知道,一看吓一跳,问题比想象的更加严重:
prod
test
对比 prod
和 test
环境的引用情况,test
环境存在热更新类 WatchHandler
对象到 AviatorClassLoader
的强引用,而 prod
环境不存在; 进一步,选择一个具体的 AviatorClassLoader
实例看下引用情况,又有了重大发现:
prod
test
prod
环境的 AviatorClassLoader
除了加载我们业务需要的自定义类 Script_*
,还加载了很多热更新相关的类,同时不同 AviatorClassLoader
实例加载的热更新相关的类的 hashcode
也是不同了,说明每个 AviatorClassLoader
实例都加载了一轮,这才是元空间占用内存的大头。
当然,在正确使用 AviatorEvaluator
的情况下(使用单例模式),就不会出现这么严重的问题了,但依然存在热部署 agent 对自定义 classloader 的强引用问题。
5、总结
这是我第一次系统地排查 OOM 问题,把之前一些零散模糊的知识点给串起来的,下面总结了一些本次排查涉及到的 JVM 基础概念和工具:
元空间:
segmentfault.com/a/119000001…
类加载器:
segmentfault.com/a/119000003…
segmentfault.com/a/119000002…
JDK工具:
jps JVM Process Status Tool 进程状况工具
jstat JVM Statistics M onitoring Tool 统计信息监视工具
jinfo Configuration Info for Java Java配置信息工具
jmap Memory Map for Java 内存映像工具
jhat JVM Heap Analysis Tool 堆转储快照分析工具
jstack Stack Trace for Java 堆栈跟踪工具
Jcmd 多功能诊断命令行工具