Una experiencia de resolución de problemas de OOM causados por la implementación en caliente

Durante el desarrollo, cuando el código se implementa en el testentorno para realizar pruebas, el contenedor a menudo se reinicia debido a OOM, pero la misma generación prodse implementa en el entorno sin ningún problema. Se sospecha que el paquete de imagen base implementado en caliente ha sido reemplazado en el testentorno recientemente (porque nuestro servicio solo puede probarse en el testentorno , 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 metaspacememoria . 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 Arthasel dashboardcomando y las jstatherramientas . Primero, mire arthasel á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.MemorymetaspaceMaxMetaspaceSizemetaspace

imagen-20220321211638581.png

Además, verifique la situación de gc a través de la jstatherramienta . El último gc se debió a que metaspaceel uso de la memoria superó el umbral de gc y metaspacela tasa de uso siempre estuvo por encima del 90 %, lo que verificó aún más el metaspaceOOM:

imagen-20220321210420477.png

2. Analizar las razones de MetaSpace OOM

Después de JDK8, metaspaceel á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:MaxMetaspaceSizepará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 jstatherramienta para ver la situación de carga de la clase y comparemos la diferencia entre testlos proddos entornos:

prueba imagen-20220322151602700.png

pinchar imagen-20220321205737121.png

prodEl 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 testaún más grave, se cargan muchas clases, pero casi ninguna. las clases se descargan.

Concéntrese testen la carga de clases del servicio, use Arthasel classloader comando :

imagen-20220322152751478.png

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 Classobjeto y no reventará el metaespacio, pero Classserá 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 AviatorEvaluatorcalcular 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 expressiones 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 processel resultado del método es 30.

之前从未仔细看过这段代码,现在看下来似乎有点不太对劲:synchronized newInstance 是重复了吗?synchronized 是用来做同步的,说明这段代码中有共享资源竞争的情况,应该就是 AviatorEvaluator 实例了,目前的逻辑每次执行 process 都会实例化一个 AviatorEvaluator 对象,这已经不仅仅是线程私有了,而是每一个线程每次调用这个方法都会实例化一个对象,已经属于 线程封闭 的场景了,不需要用 synchronized 关键字做同步。我们的业务场景对这个方法的调用量非常大,每个指标的计算都会调用这个方法。至此,有以下结论:

  1. synchronized 同步和线程封闭每个线程私有一个对象二者选其一就行;
  2. 如果 AviatorEvaluator 是线程安全的话,使用单例模式就行,可以减轻堆区的内存压力;

谷歌出品的工具类不是线程安全的?不会吧?

e897dc4eebd02136afa606257edbd75ca088ccd.jpeg

查阅资料得知,AviatorEvaluatorexecute 方法是线程安全的,代码里的使用姿势不对,修改代码如下,重新发布,不再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 会有这么多对应的实例,但 metaspceClass 还是只有一份的,问题不大。同时看到生成的字节码对象都是 Script_ 开头的,使用 arthassc 命令看下满足条件的类(数量非常多,只截取了部分),果然找到了 OOM 的元凶:

imagen-20220322161921753.png

第二个疑问:为什么 prod 没有OOM?

上面通过 jstat 发现 prod 卸载了很多类,而 test 几乎不卸载类,两个环境唯一的区别是 test 用了热部署的基础镜像。

咨询了负责热部署的同事,了解到热部署 agent 会对 classloader 有一些 强引用,监听 classloader 的加载的一些类来监听热更新,这会导致内存泄漏的问题。同时得到反馈,热部署后面会用 弱引用 优化。这里引用下《深入理解java虚拟机》中的解释:

imagen-20220322162758150.png

因为大量的 AviatorEvaluatorInstance 创建了大量的 AviatorClassLoader ,并被热部署 agent 强引用了,得不到回收,那么这些类加载器加载的 Script_* 的Class对象也得不到卸载,直到 metaspace OOM。

通过 JVM 参数 -Xlog:class+load=info-Xlog:class+unload=info 看下 prod 环境的类加载和类卸载日志,确实有大量 Script_* 类的加载和卸载:

imagen-20220323170525770.png

imagen-20220323170443096.png

test 环境这没有此种类的卸载,这边就不再贴图了。

此刻,我比较好奇的是热部署包里的什么类型的对象强引用了我们的自定义类加载器?使用 jprofiler 看下两个环境的堆转储文件,不看不知道,一看吓一跳,问题比想象的更加严重:

prod imagen-20220323150030584.png

test imagen-20220323143656225.png

对比 prodtest 环境的引用情况,test 环境存在热更新类 WatchHandler 对象到 AviatorClassLoader 的强引用,而 prod 环境不存在; 进一步,选择一个具体的 AviatorClassLoader 实例看下引用情况,又有了重大发现:

prod imagen-20220323145954125.png

test imagen-20220323150328647.png

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    多功能诊断命令行工具

Supongo que te gusta

Origin juejin.im/post/7079761581546373127
Recomendado
Clasificación