排查 JVM 元空间 metaspace 溢出问题 - 老 K 的 Java 博客

本文由 简悦 SimpRead 转码, 原文地址 javakk.com

OutOfMemoryError:Metaspace 背景

OutOfMemoryErrorMetaspace 背景


我最近开始使用 Jython,以便在 Delphix 的一个项目的 Java 虚拟机(JVM)中执行 Python 代码。对于那些不熟悉 Jython 的人来说,它是基于 JVM 的 Python 实现。您可以将 Python 源代码编译为 Java 字节码并在 JVM 中执行。当我们开始使用 Jython 时,一切都很顺利…… 直到我们开始对我们的产品进行功能测试。每隔一次测试运行都会遇到 **java.lang.OutOfMemoryError**​ Metaspace 元空间错误。继续阅读找出原因。

使用 Jython 很容易。这就像生成一个PythonInterpreter的实例、Jython 解释器的 Java 包装器一样简单,您就可以执行任意 Python 代码了。在我们的项目中,我们创建了一个沙盒,这样代码就不能执行任何恶意的系统调用。作为沙盒的一部分,我们导入大约 50 个白名单的模块,客户可以使用。每个模块都被编译成一个类文件。这意味着,对于 Jython 解释器对象的一个实例,我们必须加载大约 50 个新的 Java 类。必须指出的是,在我们的初始设计中,我们有多个 Jython 解释器运行不同的代码。我们使用单元测试对代码进行了压力测试,该单元测试将并行创建数百个 Jython 解释器并执行一些 Python 代码。我们从来没有遇到过任何问题。然而,一旦我们开始对我们的产品进行功能测试,我们就开始 **java.lang.OutOfMemoryError**​ Metaspace 非常频繁。

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_4.png

内存泄露分析


回顾一下,metaspace是 Java 进程中包含类元数据的区域。在 java8 之前,metaspace位于堆上,但从 java8 开始,它被移出堆,进入本机内存。默认情况下,元空间仅受 JVM 进程可用的本机内存量的限制,但实际上您应该将其限制为适合您的应用程序的大小(这需要一些调优和实验)。您可以使用名为MaxMetaspaceSize的 JVM 标志来限制元空间的大小。如果您不限制元空间,您可能直到很晚才注意到内存泄漏(可能是在生产设置中)。

Java 8 之前:

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_5.png

Java8 开始:

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_6.png

有一些事情可能会导致内存不足的元空间错误。最常见的是:

  • 加载的类太多
  • 加载了重复的类
  • Large classes
  • 类加载器泄漏

当发生元空间错误时,调查的第一步是查看 JVM 进程生成的堆转储。为了研究堆转储,我一直在使用 eclipse MAT(内存分析器工具)。我首先使用 “重复类” 特性来查看是否有一些类可能会无正当理由多次加载

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_7.png

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_8.png

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_9.png

看上面的图片,您立即看到有许多 Java 类的名称以$py结尾。这些 class 中有很多将近 20 份!在 eclipse MAT 中查看线程概述,只有少数线程执行 Python 代码。这意味着 Jython 解释器对象不是被垃圾收集器清理干净,就是清理得非常慢。现在让我们看看哪些对象阻止这些类被垃圾收集。

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_10.png

通过合并到shortest paths to the garbage collector roots垃圾收集器根的最短路径,我们可以看到阻止这些类被清理的大多数引用都来自系统终结器 Finalizer

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_11.png

提醒一下,所有实现 [finalize()](https://javakk.com/tag/finalize "查看更多关于 finalize() 的文章")方法的对象在被垃圾回收之前都会排队。有一个后台进程终结器线程正在运行并执行每个对象的finalize()方法。只有这样,垃圾回收器才能释放与这些对象关联的内存。

就像 Brian Goetz 在关于 “垃圾收集和性能” 的文章中指出的:

在回收可终结对象之前,至少需要两个垃圾回收周期(在最佳情况下)。

eclipse MAT 有一项功能是 “Finalizer Overview” 来查看队列中等待完成的对象。

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_12.png

当我在 Finalizer Overview 终结器概述里看到超过 58 万个 Jython 对象时,大吃一惊 (全是 org.python * 开头的)正在等待被释放finalization。你可以看到PythonTreePyStringPyStringMap等正在等待终结器线程。深入到 Jython 源代码中,注意到这些类都没有实现finalize()方法。但它们都从 PyObject 继承finalize()方法。

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_15.png

但是PyObjectfinalize()方法是空的,它包含了一个注释,它为进一步的研究提供了一些线索。

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_16.png

从注释看出 Jython 代码期望空的finalize()方法被优化掉。在编译期中显然没有这种情况,因为我尝试了一个实验,我用一个空的finalize()方法编译了一个 Java 类,在反编译之后它仍然存在。这意味着空finalize()方法必须在运行时由实时(JIT)编译器优化。

但在 JVM 中并不是这样,我们启动 JVM 来运行功能测试。这让我想到,也许我们的 JVM flags标志之一(我们有很多)阻止 JIT 编译器优化空的finalize方法。因此,我决定设计一个小实验来帮助我找到 “罪魁祸首”。

https://javakk.com/wp-content/uploads/2020/11/java_metaspace_17.png

实验基于以下想法:

  1. 编译具有空finalize()方法的EmptyFinalize类(在上面的屏幕截图中)的源代码
  2. 启动 JVM 进程时,除了在测试 VM 上运行的功能测试中使用的一个标志外,其余的都使用
  3. 创建EmptyFinalize的实例
  4. 进入无限循环
  5. dump 堆快照
  6. 验证系统终结器是否在EmptyFinalize对象的垃圾回收器根目录中(空的finalize()方法没有进行优化)
  7. 重复上述步骤,直到第 6 点

经过漫长而乏味的过程后,我发现了导致 JIT 编译器无法优化空finalize()方法的标志:

1
-javaagent:/lib/org.jacoco/org.jacoco.agent-0.8.5.jar

JaCoCo 是用来测量函数和单元测试运行中代码覆盖率的工具。也就是说,上面的 flag 标志只在测试运行期间传递给我们的 JVM 进程。这就解释了为什么我不能在本地复制这个问题!

那为什么 JaCoCo 会阻止 JIT 完成它的工作呢?JaCoCo 对 Java 进程做了什么?JaCoCo 的文档揭示了这个问题:

覆盖分析机制

覆盖率信息必须在运行时收集。为此,JaCoCo创建原始类定义的插入指令的版本。插装过程是在使用所谓的 Java 代理加载类的过程中动态进行的。

字节码操作

检测需要修改和生成 Java 字节码的机制。JaCoCo在内部为此使用了 ASM 库。

当然,为了让 JaCoCo 测量代码覆盖率,它需要在运行时插入 Java 字节码。空finalize()方法没有得到优化,**因为它们从不为空!**我可能会注意到,如果我使用相同的 Java 字节码操作库(ASM)来检查 Jython 对象的字节码,这些对象的finalize()方法将被优化掉。

结论及解决方案


代码覆盖工具导致 JVM 元空间 metaspace 溢出

在测试中我们将 JaCoCo 代理传递给 JVM 进程,而没有指定要检测哪些 Java 包来测量代码覆盖率。这意味着我们最终将检测项目中所有依赖项的字节码!

[jacoco](https://javakk.com/tag/jacoco "查看更多关于 jacoco 的文章") java agent允许您传递标志以排除或包含要为其创建代码覆盖率的包。所以说你应该只测量自己代码的代码覆盖率,而不是第三方依赖项,这可以通过传递include=com.your.package.name.*标记到 JaCoCO 代理。

除了使用 eclipse 的 MAT 分析 metaspace 内存溢出的方式外,还可以参考这篇文章的排查手段:https://javakk.com/160.html