本文由 简悦 SimpRead 转码, 原文地址 javakk.com
OutOfMemoryError:Metaspace 背景
我最近开始使用 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 非常频繁。
内存泄露分析
回顾一下,metaspace
是 Java 进程中包含类元数据的区域。在 java8 之前,metaspace
位于堆上,但从 java8 开始,它被移出堆,进入本机内存。默认情况下,元空间仅受 JVM 进程可用的本机内存量的限制,但实际上您应该将其限制为适合您的应用程序的大小(这需要一些调优和实验)。您可以使用名为MaxMetaspaceSize
的 JVM 标志来限制元空间的大小。如果您不限制元空间,您可能直到很晚才注意到内存泄漏(可能是在生产设置中)。
Java 8 之前:
Java8 开始:
有一些事情可能会导致内存不足的元空间错误。最常见的是:
- 加载的类太多
- 加载了重复的类
- Large classes
- 类加载器泄漏
当发生元空间错误时,调查的第一步是查看 JVM 进程生成的堆转储。为了研究堆转储,我一直在使用 eclipse MAT(内存分析器工具)。我首先使用 “重复类” 特性来查看是否有一些类可能会无正当理由多次加载。
看上面的图片,您立即看到有许多 Java 类的名称以$py
结尾。这些 class 中有很多将近 20 份!在 eclipse MAT 中查看线程概述,只有少数线程执行 Python 代码。这意味着 Jython 解释器对象不是被垃圾收集器清理干净,就是清理得非常慢。现在让我们看看哪些对象阻止这些类被垃圾收集。
通过合并到shortest paths to the garbage collector roots
垃圾收集器根的最短路径,我们可以看到阻止这些类被清理的大多数引用都来自系统终结器 Finalizer。
提醒一下,所有实现 [finalize()](https://javakk.com/tag/finalize "查看更多关于 finalize() 的文章")
方法的对象在被垃圾回收之前都会排队。有一个后台进程终结器线程正在运行并执行每个对象的finalize()
方法。只有这样,垃圾回收器才能释放与这些对象关联的内存。
就像 Brian Goetz 在关于 “垃圾收集和性能” 的文章中指出的:
在回收可终结对象之前,至少需要两个垃圾回收周期(在最佳情况下)。
eclipse MAT 有一项功能是 “Finalizer Overview” 来查看队列中等待完成的对象。
当我在 Finalizer Overview 终结器概述里看到超过 58 万个 Jython 对象时,大吃一惊 (全是 org.python * 开头的)正在等待被释放finalization
。你可以看到PythonTree
、PyString
、PyStringMap
等正在等待终结器线程。深入到 Jython 源代码中,注意到这些类都没有实现finalize()
方法。但它们都从 PyObject 继承finalize()
方法。
但是PyObject
的finalize()
方法是空的,它包含了一个注释,它为进一步的研究提供了一些线索。
从注释看出 Jython 代码期望空的finalize()
方法被优化掉。在编译期中显然没有这种情况,因为我尝试了一个实验,我用一个空的finalize()
方法编译了一个 Java 类,在反编译之后它仍然存在。这意味着空finalize()
方法必须在运行时由实时(JIT)编译器优化。
但在 JVM 中并不是这样,我们启动 JVM 来运行功能测试。这让我想到,也许我们的 JVM flags
标志之一(我们有很多)阻止 JIT 编译器优化空的finalize
方法。因此,我决定设计一个小实验来帮助我找到 “罪魁祸首”。
实验基于以下想法:
- 编译具有空
finalize()
方法的EmptyFinalize
类(在上面的屏幕截图中)的源代码 - 启动 JVM 进程时,除了在测试 VM 上运行的功能测试中使用的一个标志外,其余的都使用
- 创建
EmptyFinalize
的实例 - 进入无限循环
- dump 堆快照
- 验证系统终结器是否在
EmptyFinalize
对象的垃圾回收器根目录中(空的finalize()
方法没有进行优化) - 重复上述步骤,直到第 6 点
经过漫长而乏味的过程后,我发现了导致 JIT 编译器无法优化空finalize()
方法的标志:
|
|
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