【译】 JVM Anatomy Park #12: 本地内存追踪

原文地址:JVM Anatomy Park #12: Native Memory Tracking

问题

我有 512 MB 可用内存,所以我设置 -Xms512m -Xmx512m,然后 VM 抛出“内存不足”就停止了。为什么?

理论

JVM 是本地应用程序,所以它也需要内存空间来维护内部的数据结构,用以表示程序代码、生成的机器码、内存元数据、类元数据、内部分析等。这些内存并没有计算在 Java 堆内,因为其中大部分内存是本地的,分配在 C 堆中,或者 mmap 的内存。JVM 也准备了一些资源,以备长期运行的程序加载一些类,生成一些代码等。对于某些内存受限的短期程序,上述默认的设置可能会浪费一些内存。

OpenJDK8 有一个非常棒的 VM 特性,称为“本地内存追踪” (NMT):这个特性记录所有的 VM 内部分配,并且将它们分类,了解它们来自哪里等。这个特性对于理解 VM 如何使用内存是非常宝贵的。

可以通过 -XX:NativeMemoryTracking=summary 启动 NMT。你可以使用 jcmd 转储当前的 NMT 数据,也可以通过 -XX:+PrintNMTStatistics 在 JVM 停止时转储数据。通过 -XX:NativeMemoryTracking=detail 可以输出 mmap 的内存映射和 malloc 的调用栈。

大部分情况下,“summary” 可以满足了解概况的需求。但是我们也可以阅读 “detail” 日志,了解内存分配来自何处,用处又是什么,阅读 VM 源代码,或者调整 VM 参数看看相互的影响。以简单的 “Hello World” 程序为例:

public class Hello {
  public static void main(String... args) {
    System.out.println("Hello");
  }
}

很明显分配的内存主要是 Java 堆,我们设置 -Xmx16m -Xms16m 做为基准线:

Native Memory Tracking:

Total: reserved=1373921KB, committed=74953KB
-                 Java Heap (reserved=16384KB, committed=16384KB)
                            (mmap: reserved=16384KB, committed=16384KB)

-                     Class (reserved=1066093KB, committed=14189KB)
                            (classes #391)
                            (malloc=9325KB #148)
                            (mmap: reserved=1056768KB, committed=4864KB)

-                    Thread (reserved=19614KB, committed=19614KB)
                            (thread #19)
                            (stack: reserved=19532KB, committed=19532KB)
                            (malloc=59KB #105)
                            (arena=22KB #38)

-                      Code (reserved=249632KB, committed=2568KB)
                            (malloc=32KB #297)
                            (mmap: reserved=249600KB, committed=2536KB)

-                        GC (reserved=10991KB, committed=10991KB)
                            (malloc=10383KB #129)
                            (mmap: reserved=608KB, committed=608KB)

-                  Compiler (reserved=132KB, committed=132KB)
                            (malloc=2KB #23)
                            (arena=131KB #3)

-                  Internal (reserved=9444KB, committed=9444KB)
                            (malloc=9412KB #1373)
                            (mmap: reserved=32KB, committed=32KB)

-                    Symbol (reserved=1356KB, committed=1356KB)
                            (malloc=900KB #65)
                            (arena=456KB #1)

-    Native Memory Tracking (reserved=38KB, committed=38KB)
                            (malloc=3KB #41)
                            (tracking overhead=35KB)

-               Arena Chunk (reserved=237KB, committed=237KB)
                            (malloc=237KB)

好吧。设置 16 MB Java 堆,实际占用 75 MB 确实出人意料。

瘦身:理智的部分

让我们翻看 NMT 输出看看有哪部分可以优化一下。

从熟悉的部分开始:

-                        GC (reserved=10991KB, committed=10991KB)
                            (malloc=10383KB #129)
                            (mmap: reserved=608KB, committed=608KB)

这部分表示 GC 本地数据结构。日志显示 GC malloc 了近 10MB,mmap 了近 0.6MB。如果这部分内存描述堆的结构(例如,marking bitmaps、card tables、remembered sets 等),那么随着堆的增长,这部分内存也会增长。确实如此:

# Xms/Xmx = 512 MB
-                        GC (reserved=29543KB, committed=29543KB)
                            (malloc=10383KB #129)
                            (mmap: reserved=19160KB, committed=19160KB)

# Xms/Xmx = 4 GB
-                        GC (reserved=163627KB, committed=163627KB)
                            (malloc=10383KB #129)
                            (mmap: reserved=153244KB, committed=153244KB)

# Xms/Xmx = 16 GB
-                        GC (reserved=623339KB, committed=623339KB)
                            (malloc=10383KB #129)
                            (mmap: reserved=612956KB, committed=612956KB)

malloc 部分很可能是用于并行 GC 的任务队列的 C 堆分配,mmap 部分很可能是位图。并不出人意料,它们随着堆内存增长,占用配置堆内存的 3-4%。这就引出了部署的问题,就像问题中那样:将所有可用的物理内存配置为堆内存的大小,将会超出内存的限制,可能导致内存交换,也可能唤起 OOM killer。

但是这部分成本依赖使用的GC,因为不同的 GC 使用不同的数据结构描述 Java 堆。例如,换回 OpenJDK 中最轻量级的 GC,-XX:+UseSerialGC,实验的结果将发生戏剧性的变化:

-Total: reserved=1374184KB, committed=75216KB
+Total: reserved=1336541KB, committed=37573KB

--                     Class (reserved=1066093KB, committed=14189KB)
+-                     Class (reserved=1056877KB, committed=4973KB)
                             (classes #391)
-                            (malloc=9325KB #148)
+                            (malloc=109KB #127)
                             (mmap: reserved=1056768KB, committed=4864KB)

--                    Thread (reserved=19614KB, committed=19614KB)
-                            (thread #19)
-                            (stack: reserved=19532KB, committed=19532KB)
-                            (malloc=59KB #105)
-                            (arena=22KB #38)
+-                    Thread (reserved=11357KB, committed=11357KB)
+                            (thread #11)
+                            (stack: reserved=11308KB, committed=11308KB)
+                            (malloc=36KB #57)
+                            (arena=13KB #22)

--                        GC (reserved=10991KB, committed=10991KB)
-                            (malloc=10383KB #129)
-                            (mmap: reserved=608KB, committed=608KB)
+-                        GC (reserved=67KB, committed=67KB)
+                            (malloc=7KB #79)
+                            (mmap: reserved=60KB, committed=60KB)

--                  Internal (reserved=9444KB, committed=9444KB)
-                            (malloc=9412KB #1373)
+-                  Internal (reserved=204KB, committed=204KB)
+                            (malloc=172KB #1229)
                             (mmap: reserved=32KB, committed=32KB)

这改善了 “GC” 部分,因为这需要分配更少的元数据,同时也改善了 “Thread” 部分,因为从并行改为串行 GC,需要的 GC 线程数也少了。这意味着我们可以通过调低 Parallel、G1、CMS、Shenandoah等的 GC 线程数,局部改善内存占用。稍微我们看一下线程栈。需要注意的是,修改 GC 或者 GC 线程数将会造成性能影响 —— 通过这个修改,你在倾向时间空间权衡中的另外一方。

这也改善了 “Class” 部分,因为元数据的表示也有些许不同。我们能进一步压缩 ”Class“ 占用的内存么?让我们试一下类数据共享(CDS),通过 -Xshare:on 启动:

-Total: reserved=1336279KB, committed=37311KB
+Total: reserved=1372715KB, committed=36763KB

--                    Symbol (reserved=1356KB, committed=1356KB)
-                            (malloc=900KB #65)
-                            (arena=456KB #1)
-
+-                    Symbol (reserved=503KB, committed=503KB)
+                            (malloc=502KB #12)
+                            (arena=1KB #1)

不错,通过从共享档案中加装预解析的描述,内部符号表又节约了 0.5 MB。

现在让我们关注线程部分。日志是这样的:

-                    Thread (reserved=11357KB, committed=11357KB)
                            (thread #11)
                            (stack: reserved=11308KB, committed=11308KB)
                            (malloc=36KB #57)
                            (arena=13KB #22)

你会看到线程占用的大部分空间是线程栈。你可以通过 -Xss 降低栈空间大小(默认为 1M)。注意这将会增加 StackOverflowException 的风险,所以如果你想要修改这个配置,那么需要测试所有可能的配置,以防不良作用。姑且通过 -Xss256k 设置为 256KB:

-Total: reserved=1372715KB, committed=36763KB
+Total: reserved=1368842KB, committed=32890KB

--                    Thread (reserved=11357KB, committed=11357KB)
+-                    Thread (reserved=7517KB, committed=7517KB)
                             (thread #11)
-                            (stack: reserved=11308KB, committed=11308KB)
+                            (stack: reserved=7468KB, committed=7468KB)
                             (malloc=36KB #57)
                             (arena=13KB #22)

还不错,又省了 4 MB。当然,如果程序有更多线程,那么改善将更明显,这部分很可能是仅次于 Java 堆的第二大内存消耗者。

继续分析线程,JIT 编译器自身也有线程。这部分解释了为什么我们设置栈大小为 256KB,但是从数据上来看平均的栈大小仍然是 7517 / 11 = 683 KB。通过 -XX:CICompilerCount=1 调低编译器的线程数,并且通过 -XX:-TieredCompilation 仅仅启动最低一层编译:

-Total: reserved=1368612KB, committed=32660KB
+Total: reserved=1165843KB, committed=29571KB

--                    Thread (reserved=7517KB, committed=7517KB)
-                            (thread #11)
-                            (stack: reserved=7468KB, committed=7468KB)
-                            (malloc=36KB #57)
-                            (arena=13KB #22)
+-                    Thread (reserved=4419KB, committed=4419KB)
+                            (thread #8)
+                            (stack: reserved=4384KB, committed=4384KB)
+                            (malloc=26KB #42)
+                            (arena=9KB #16)

还不错,减少了三个线程,线程栈也没有了!注意,这样会造成性能影响:更少的编译器线程意味着更慢的预热

降低 Java 堆大小,选择合适的 GC,减少 VM 线程数,降低 Java 线程栈大小和线程数,这些是内存受限场景下降低 VM 内存占用的通用技术。完成这些设置之后,我们将 16MB 测试用例的内存占用降低为:

-Total: reserved=1373922KB, committed=74954KB
+Total: reserved=1165843KB, committed=29571KB

瘦身:疯狂的部分

注:这部分的建议都比较疯狂。一切风险由你自己承担。不要在家里尝试。

这部分涉及调低内部 VM 设置。这里不保证有效,并且可能会造成意外事故。例如,我们可以通过仔细编码控制所需的栈大小。但是我们不知道 JVM 内部的情况,所以调低 VM 线程的栈大小是很危险的。尝试设置 -XX:VMThreadStackSize=256

-Total: reserved=1165843KB, committed=29571KB
+Total: reserved=1163539KB, committed=27267KB

--                    Thread (reserved=4419KB, committed=4419KB)
+-                    Thread (reserved=2115KB, committed=2115KB)
                             (thread #8)
-                            (stack: reserved=4384KB, committed=4384KB)
+                            (stack: reserved=2080KB, committed=2080KB)
                             (malloc=26KB #42)
                             (arena=9KB #16)

是的,又降低了编译器和 GC 线程栈的 2MB 内存。

让我们继续滥用编译器代码:为什么我们不降低初始代码缓存大小(生成代码的区域大小)呢?设置 -XX:InitialCodeCacheSize=4096(单位是字节!):

-Total: reserved=1163539KB, committed=27267KB
+Total: reserved=1163506KB, committed=25226KB

--                      Code (reserved=49941KB, committed=2557KB)
+-                      Code (reserved=49941KB, committed=549KB)
                             (malloc=21KB #257)
-                            (mmap: reserved=49920KB, committed=2536KB)
+                            (mmap: reserved=49920KB, committed=528KB)

 -                        GC (reserved=67KB, committed=67KB)
                             (malloc=7KB #78)

哈哈!一旦遇到大量编译的情况,内存就不够用了,但是到目前还好。

再仔细观察 ”Class“ 部分,我们可以看到 Hello World 程序中初始元数据存储最大为 4MB。我们可以调低 -XX:InitialBootClassLoaderMetaspaceSize=4096 (单位是字节):

-Total: reserved=1163506KB, committed=25226KB
+Total: reserved=1157404KB, committed=21172KB

--                     Class (reserved=1056890KB, committed=4986KB)
+-                     Class (reserved=1050754KB, committed=898KB)
                             (classes #4)
-                            (malloc=122KB #83)
-                            (mmap: reserved=1056768KB, committed=4864KB)
+                            (malloc=122KB #84)
+                            (mmap: reserved=1050632KB, committed=776KB)

 -                    Thread (reserved=2115KB, committed=2115KB)
                             (thread #8)

总的来说,经过所有这些疯狂的设置,我们更接近 16MB 的堆内存大小了,仅仅浪费了 8.5 MB:

-Total: reserved=1165843KB, committed=29571KB
+Total: reserved=1157404KB, committed=21172KB

如果我们尝试在定制构建中裁剪 JVM,那么我们可以更接近。

将所有内容放在一起

为了消遣,我们可以看看测试用例中本地内存成本如何随着堆大小变化:

这证实了我们的直觉,GC 成本是 Java 堆大小的常数因子,仅仅在堆很小的情况下,本地 VM 的成本才重要,因为此时 VM 成本的绝对值占全部内存的大部分。这张图忽略了第二重要的事情:线程栈。

观察

默认的 JVM 配置是为长期运行的服务器类应用程序准备的,所以 GC的选择,内部数据结构的初始大小,栈大小等可能不合适短期运行、内存受限的应用程序。理解当前 JVM 配置中主要的内存占用部分可以帮忙我们降低 JVM 内存占用。

使用 NMT 探索 VM 内存占用通常来说是一个很有启发性的工作。它几乎可以直接让我们找到改善内存占用的地方。当运行实际的生产应用程序时,将实时的 NMT 监控连接到性能管理系统可以帮助我们调整 JVM 参数。这比解析 /proc/$pid/maps 中不透明的内存映射简单多了。

也可以看一下 Christine Flood 的《OpenJDK 与容器》。

你可能感兴趣的:(【译】 JVM Anatomy Park #12: 本地内存追踪)