JVM中的本机内存跟踪

2025/04/02

1. 概述

有没有想过为什么Java应用程序消耗的内存比通过众所周知的-Xms和-Xmx调整标志指定的数量多得多?由于各种原因和可能的优化,JVM可能会分配额外的本机内存,这些额外的分配最终会使消耗的内存超出-Xmx限制。

在本教程中,我们将列举JVM中本机内存分配的一些常见来源及其大小调整标志,然后学习如何使用本机内存跟踪来监视它们。

2. 本机分配

堆通常是Java应用程序中最大的内存消耗者,但还有其他消耗者。除了堆之外,JVM还从本机内存中分配相当大的块来维护其类元数据、应用程序代码、JIT生成的代码、内部数据结构等。在接下来的部分中,我们将探讨其中的一些分配。

2.1 元空间

为了维护已加载类的一些元数据,JVM使用了一个专用的非堆区域,称为Metaspace。在Java 8之前,等效项称为PermGen或Permanent Generation。Metaspace或PermGen包含有关已加载类的元数据,而不是它们的实例,后者保存在堆内。

这里重要的是堆大小配置不会影响元空间大小,因为元空间是堆外数据区域。为了限制元空间大小,我们使用其他调整标志:

  • -XX:MetaspaceSize和-XX:MaxMetaspaceSize设置最小和最大元空间大小
  • 在Java 8之前,-XX:PermSize和-XX:MaxPermSize设置最小和最大PermGen大小

2.2 线程

JVM中最消耗内存的数据区域之一是栈,它与每个线程同时创建。栈存储局部变量和部分结果,在方法调用中起着重要作用。

默认线程栈大小取决于平台,但在大多数现代64位操作系统中,它约为1MB。此大小可通过-Xss调整标志进行配置。

与其他数据区域相比,当线程数没有限制时,分配给栈的总内存实际上是无限的。还值得一提的是,JVM本身需要一些线程来执行其内部操作,如GC或即时编译。

2.3 代码缓存

为了在不同的平台上运行JVM字节码,需要将其转换为机器指令,JIT编译器在程序执行时负责此编译。

当JVM将字节码编译为汇编指令时,它会将这些指令存储在称为代码缓存的特殊非堆数据区域中。代码缓存可以像JVM中的其他数据区域一样进行管理,-XX:InitialCodeCacheSize和-XX:ReservedCodeCacheSize调整标志确定代码缓存的初始大小和最大可能大小。

2.4 垃圾回收

JVM附带了一些GC算法,每个算法都适用于不同的用例。所有这些GC算法都有一个共同特征:它们需要使用一些堆外数据结构来执行它们的任务,这些内部数据结构消耗更多的本机内存。

2.5 符号

让我们从字符串开始,它是应用程序和库代码中最常用的数据类型之一。由于它们无处不在,因此它们通常占据堆的很大一部分。如果大量这些字符串包含相同的内容,那么堆的很大一部分将被浪费。

为了节省一些堆空间,我们可以为每个String存储一个版本,并让其他版本引用存储的版本。这个过程称为字符串驻留。由于JVM只能驻留编译时字符串常量,因此我们可以手动调用intern()方法来处理我们想要驻留的字符串。

JVM将驻留字符串存储在一个特殊的本机固定大小哈希表中,称为字符串表,也称为字符串池。我们可以通过-XX:StringTableSize调整标志配置表大小(即桶的数量)。

除了字符串表之外,还有另一个本地数据区域,称为运行时常量池。JVM使用此池来存储常量,例如必须在运行时解析的编译时数字字面量或方法和字段引用。

2.6 本机字节缓冲区

JVM通常是大量本机分配的罪魁祸首,但有时开发人员也可以直接分配本机内存,最常见的方法是JNI的malloc调用和NIO的直接ByteBuffers。

2.7 额外的调整标志

在本节中,我们针对不同的优化场景使用了一些JVM调优标志。使用以下提示,我们可以找到与特定概念相关的几乎所有调优标志:

$ java -XX:+PrintFlagsFinal -version | grep <concept>

PrintFlagsFinal打印JVM中的所有–XX选项。例如,要查找所有与元空间相关的标志:

$ java -XX:+PrintFlagsFinal -version | grep Metaspace
      // truncated
      uintx MaxMetaspaceSize                          = 18446744073709547520                    {product}
      uintx MetaspaceSize                             = 21807104                                {pd product}
      // truncated

3. 本机内存跟踪(NMT)

现在我们知道了JVM中本机内存分配的常见来源,是时候了解如何监控它们了。**首先,我们应该使用另一个JVM调优标志启用本机内存跟踪:-XX:NativeMemoryTracking=off sumary detail**。默认情况下,NMT处于关闭状态,但我们可以启用它来查看其观察结果的摘要或详细视图。

假设我们要跟踪典型Spring Boot应用程序的本机分配:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar

在这里,我们启用NMT,同时分配300MB的堆空间,使用G1作为我们的GC算法。

3.1 即时快照

当启用NMT时,我们可以随时使用jcmd命令获取本机内存信息:

$ jcmd <pid> VM.native_memory

为了找到JVM应用程序的PID,我们可以使用jps命令:

$ jps -l                    
7858 app.jar // This is our app
7899 sun.tools.jps.Jps

现在,如果我们将jcmd与适当的pid一起使用,VM.native_memory会使JVM打印出有关本机分配的信息:

$ jcmd 7858 VM.native_memory

让我们逐段分析一下NMT输出。

3.2 总分配

NMT报告总保留和已提交的内存如下:

Native Memory Tracking:
Total: reserved=1731124KB, committed=448152KB

预留内存代表我们的应用程序可能使用的内存总量,相反,已提交内存等于我们的应用当前正在使用的内存量

尽管分配了300MB的堆,但我们应用程序的总保留内存接近1.7GB,远多于此。同样,已提交内存约为440MB,这同样比300MB多得多。

在总计部分之后,NMT报告每个分配源的内存分配。因此,让我们深入探讨每个来源。

3.3 堆

NMT报告了我们预期的堆分配:

Java Heap (reserved=307200KB, committed=307200KB)
          (mmap: reserved=307200KB, committed=307200KB)

保留和已提交的内存均为300MB,与我们的堆大小设置相匹配。

3.4 元空间

以下是NMT对已加载类的类元数据的描述:

Class (reserved=1091407KB, committed=45815KB)
      (classes #6566)
      (malloc=10063KB #8519) 
      (mmap: reserved=1081344KB, committed=35752KB)

将近1GB的保留空间和45MB的空间用于加载6566个类。

3.5 线程

以下是有关线程分配的NMT报告:

Thread (reserved=37018KB, committed=37018KB)
       (thread #37)
       (stack: reserved=36864KB, committed=36864KB)
       (malloc=112KB #190) 
       (arena=42KB #72)

总共为37个线程分配了36MB的内存-每个栈几乎1MB。JVM在创建时将内存分配给线程,因此保留分配和提交分配是相等的。

3.6 代码缓存

让我们看看NMT对JIT生成和缓存的汇编指令的看法:

Code (reserved=251549KB, committed=14169KB)
     (malloc=1949KB #3424) 
     (mmap: reserved=249600KB, committed=12220KB)

目前,将近13MB的代码被缓存,并且这个数量可能会增加到大约245MB。

3.7 GC

以下是有关G1 GC内存使用情况的NMT报告:

GC (reserved=61771KB, committed=61771KB)
   (malloc=17603KB #4501) 
   (mmap: reserved=44168KB, committed=44168KB)

我们可以看到,几乎有60MB被保留并用于帮助G1。

让我们看看更简单的GC(比如串行GC)的内存使用情况:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar

串行GC几乎只使用1MB:

GC (reserved=1034KB, committed=1034KB)
   (malloc=26KB #158) 
   (mmap: reserved=1008KB, committed=1008KB)

显然,我们不应该仅仅因为内存使用情况而选择GC算法,因为串行GC的Stop-the-World特性可能会导致性能下降。然而,有几种GC可供选择,它们各自以不同方式平衡内存和性能。

3.8 符号

以下是有关符号分配(例如字符串表和常量池)的NMT报告:

Symbol (reserved=10148KB, committed=10148KB)
       (malloc=7295KB #66194) 
       (arena=2853KB #1)

将近10MB分配给符号。

3.9 随时间推移

NMT允许我们跟踪内存分配随时间的变化情况,首先,我们应该将应用程序的当前状态标记为基线:

$ jcmd <pid> VM.native_memory baseline
Baseline succeeded

然后,过一会儿,我们可以将当前内存使用情况与该基线进行比较:

$ jcmd <pid> VM.native_memory summary.diff

NMT使用+和–符号来告诉我们内存使用情况在这段时间内是如何变化的:

Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB
-             Java Heap (reserved=307200KB, committed=307200KB)
                        (mmap: reserved=307200KB, committed=307200KB)
 
-             Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB)
// Truncated

总保留内存和已提交内存分别增加了3MB和6MB,可以很容易地发现内存分配中的其他波动。

3.10 详细NMT

NMT可以提供关于整个内存空间映射的非常详细的信息,要启用此详细报告,我们应该使用-XX:NativeMemoryTracking=detail调整标志。

4. 总结

在本文中,我们列举了JVM中本机内存分配的不同因素。然后,我们学习了如何检查正在运行的应用程序以监控其本机分配。有了这些见解,我们可以更有效地调整应用程序并调整运行时环境的大小。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章