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()
方法的对象在被垃圾回收之前都会排队。有一个后台进程终结器线程正在运行并执行每个对象的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()
方法的标志:
-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 java agent
允许您传递标志以排除或包含要为其创建代码覆盖率的包。所以说你应该只测量自己代码的代码覆盖率,而不是第三方依赖项,这可以通过传递include=com.your.package.name.*
标记到JaCoCO代理。
除了使用eclipse的MAT分析metaspace内存溢出的方式外,还可以参考这篇文章的排查手段:https://javakk.com/160.html
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/805.html
暂无评论