最近正好碰到了堆外内存的小问题,看到这篇文章学习一下,遂翻译到这里。虽然现如今翻译的价值已经大打折扣,大量的翻译工具、AI都可以很快、比较好地完成这个任务,不过花这个时间也还是有点意义的吧我觉得,起码对我个人来说,我也可以借这个机会再次评估下GPT的翻译能力。目前看起来要翻译地信达雅还是有点差距的,对GPT3.5来说,当然这也是因为对它期许比较高,评价是略胜普通的翻译工具吧。

翻完才发现写的也就那样,偏向于过程的记录和归档。更推荐大家去看看毕昇的NMT系列文章,比较硬核,甚至有点过于硬核。


GOV.UK Verify,我们最近遇到了一个技术上的性能问题:我们的应用程序存在内存泄漏,而这个问题会影响到所有面向终端用户的功能。我们计划明年4月上线,为此我们一直在进行大量的性能测试。我们发现,如果我们的性能基准测试像往常一样运行(夜间),而应用程序(我们称之为“前端”)超过48小时没有重新启动,它就会崩溃。尽管这个问题还没有影响到公众,但我们很想在它进一步恶化之前解决它。

对于开发人员而言,真正深入挖掘内存问题的机会是非常罕见的,这也可以从我们的调查(连同其他工作)持续了一个月之久上得以体现。本篇文章简要记录了我们尝试解决该问题的方法,当然还有发生问题的原因,这些信息最终让问题得以解决。

我们的前端应用是使用Java语言和Dropwizard框架构建的RESTful服务。它是一个包含了代码以及各种诸如JavaScript、CSS文件和图片等资源的单一Jar文件。

Heap

我们做的第一件事是确保我们已经设置了

-XX:+HeapDumpOnOutOfMemoryError

这个JVM(Java虚拟机)参数,以便在应用程序崩溃时获取堆转储文件,使得我们可以查看应用程序崩溃时的发生了什么。经过几次测试后,应用程序退出,但我们没有得到期望的堆转储文件(.hprof)。JVM生成了一个日志文件hs_err_pid<pid>.log,但这仅告诉我们出现了致命错误、内存历史记录和一堆其他JVM信息。这看起来很奇怪,因此我们在一个实时性能测试运行中连接Visual VM,并通过这种方式监控堆的使用情况。通过这种方式,我们发现在整个测试运行期间,堆使用率几乎没有增长,这解释了为什么我们没有得到堆转储。泄漏必然是在堆外发生。

Outside The Heap

如果问题是在堆空间的话,那就太好了。堆空间中的内存泄漏及其解决方案通常都有详细的文档说明。不幸的是,情况并非如此。幸运的是,我们使用Metricscollectd将JVM和本机内存的使用情况推送到了Graphite中:

上图表明:

  1. 通常需要运行约两次性能测试才能使应用程序崩溃。
  2. Java内存的增长在堆之外,在问题触发OOM导致进程被Linux内核杀死之前会增长到 3.5G。
  3. Java堆不是问题所在。

泄漏发生在堆外这一事实给我们留下了两个可能的元凶:

  1. 元空间(Metaspace)
  2. 本地内存(Native Memory)

在git和graphite中经过一番探索后,我们注意到在我们第一次遇到问题时,我们已经升级到Java 8。新版本引入的变化之一是删除了旧的 PermGen 内存空间,并替换引入了Metaspace。Metaspace(像其前身 PermGen 一样)位于堆外。我们是否可能在 Metaspace 中引发了泄漏?

Visual VM 再次让我们确认情况并非如此。尽管我们没有设置最大元空间大小,但它并没有随着时间的推移而显着增长。

Playing the guessing game

本地内存泄漏的问题在于 Java 虚拟机 (JVM) 不知道底层操作系统的可用内存状况,也不关心它。这迫使我们尝试了好几种解决方案,但都没有取得进展。

Did anything change in the code?

回顾Graphite中的时间轴,我们可以看到应用程序的内存使用量是在8月5日左右开始增加的。查看此期间的提交记录,问题的主要嫌疑是引入了一个依赖于Netty的第三方库,这可能导致过度的内存消耗;然而,移除该库并没有解决问题。

Was it the upgrade from Java 1.7 to 1.8?

在这个问题出现之前,我们将机器上的 Java 版本从 1.7 更改为 1.8。然而。回退到 Java 1.7 并没有解决问题。

Is there any setting that could restrict the amount of virtual (native) memory the java process uses?

我们尝试按照这里的文档限制JVM使用的内存池大小,但由于我们的Ubuntu版本没有具备该功能的glibc版本(稍后详细说明),这种方法对我们无效。

So what can can cause a leak in native memory?

最有可能的罪魁祸首是Java Native Interface(JNI)。虽然我们的应用程序没有显式地调用任何JNI方法,但也许我们某个依赖库中有这样的调用。那么我们该如何找出具体是哪个依赖库呢?

JEMALLOC to the rescue

经过一番搜寻,我们发现了一篇博客文章,建议使用jemalloc记录应用程序运行时的底层内存分配情况,并利用该信息生成展示各进程内存分配情况的图表。

What is jemalloc?

Linux原生的内存分配器是glibc的malloc。JVM(与其他进程相同)会调用C运行时的malloc函数,以从操作系统中分配内存,然后管理Java应用程序的堆。Jemalloc是另一种malloc实现,并带有一个名为 jeprof 的很棒的工具,可以进行内存分配分析,使我们可以可视化地跟踪malloc的调用。

Ok, so what now?

在安装jemalloc后,我们很轻松地通过设置环境变量来强制JVM使用它(而不是glibc的malloc):

export LD_PRELOAD=/usr/local/lib/libjemalloc.so

我们还让jemalloc在每分配1GB内存后将分析结果写入磁盘,并且记录堆栈跟踪(参考博客)。

export MALLOC_CONF=prof:true,lg_prof_interval:30,lg_prof_sample:17

我们随后通过常规的 jvm -jar frontend.jar 命令启动了应用,并开始进行测试。现在jemalloc应该会生成名为jeprof*.heap的性能快照文件。在确认泄漏发生后,我们使用reproof生成了一份内存报告:

jeprof --show_bytes --gif /path/to/jvm/bin/java jeprof*.heap > /tmp/app-profiling.gif

这向我们展示了如下信息:

在这里我们可以看到大约占用了总进程43.4%(1.02G)的内存来自于 java.util.zip.Inflater.inflateBytes -> inflate -> updatewindow。根据Javadoc中的说明,我们知道Inflater类使用了流行的 ZLIB 压缩库提供对通用解压缩的支持。

重新运行测试并进行线程快照转储,我们能够看到调用 java.util.zip.Inflater.inflateBytes 的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dw-116 - GET /assets/stylesheets/style-print.css" #116 prio=5 os_prio=0 tid=0x00007f703803c800 nid=0x4f47 runnable [0x00007f70372ef000] 
java.lang.Thread.State: RUNNABLE at java.util.zip.Inflater.inflateBytes(Native Method)
at java.util.zip.Inflater.inflate(Inflater.java:259) - locked <0x00000000f6e20a30> (a java.util.zip.ZStreamRef)
at java.util.zip.InflaterInputStream.read(InflaterInputStream.java:152)
at java.io.FilterInputStream.read(FilterInputStream.java:133)
at java.io.FilterInputStream.read(FilterInputStream.java:107)
at com.google.common.io.ByteStreams.copy(ByteStreams.java:70)
at com.google.common.io.ByteStreams.toByteArray(ByteStreams.java:115)
at com.google.common.io.ByteSource.read(ByteSource.java:258)
at com.google.common.io.Resources.toByteArray(Resources.java:99)
at io.dropwizard.servlets.assets.AssetServlet.loadAsset(AssetServlet.java:217)
at io.dropwizard.servlets.assets.AssetServlet.doGet(AssetServlet.java:106)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:687)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)

因此,问题的原因在于对AssetServlet的调用。根据Dropwizard的Javadoc,AssetServlet是一种可以从我们前端Jar文件内部位置提供静态资源(如图像、CSS文件等)的类。有了这些知识,我们尝试了以下几种方案阻止资源的解压缩行为,成功地避免了内存泄漏:

  1. 使用未压缩的jar文件(jar 实际上是 zip 文件,默认情况下是压缩的)。
  2. 通过位于我们前端服务之前的nginx实例提供静态资源服务。

下图是没有内存泄漏的情况:

几乎所有(98.7%)的malloc调用都来自于os:malloc,即JVM本身引起的。

Conclusion

这次经历凸显了拥有各种监控和诊断工具的重要性。每个应用程序都应该记录监控指标;类似 Graphite 这样的可视化工具是查看和比较历史数据集的必备工具;Visual VM是一个连接到[远程]实时应用程序并实时查看JVM统计信息的强大工具。

长期以来,排查本地内存泄漏的原因一直是一个棘手的问题,但我们发现jemalloc是分析这些问题的绝佳工具。现在我们将其纳入了我们的工具包中,如果将来再次出现这种问题,预计我们能够更快地解决它们。