1. 概述

我们可能都注意到,Java应用的内存消耗并不严格遵循基于 -Xmx(最大堆大小)的配置。实际上,JVM 的内存区域远不止堆内存这一块。要限制总内存使用,需要了解一些额外的内存设置,下面我们从 Java 应用的内存结构和内存分配来源开始讲起。

2. Java 进程的内存结构

Java 虚拟机 (JVM) 的内存分为两大类:堆内存和非堆内存。

堆内存是 JVM 内存中最广为人知的部分,用于存储应用创建的对象。JVM 在启动时初始化堆内存。当应用创建新对象实例时,该对象会驻留在堆中,直到应用释放该实例,然后垃圾回收器 (GC) 会回收实例占用的内存。因此,堆大小会随负载变化,尽管我们可以通过 -Xmx 选项配置最大堆大小。

非堆内存则包含其余部分。正是它让我们的应用能使用超过配置堆大小的内存。JVM 的非堆内存分为多个不同区域:JVM 代码和内部结构、已加载的性能分析代理代码、每个类的结构(如常量池、字段和方法的元数据)、方法和构造函数的代码,以及字符串常量池等,都属于非堆内存。

值得一提的是,我们可以使用 -XX 选项调整某些非堆内存区域,例如 -XX:MaxMetaspaceSize(相当于 Java 7 及更早版本的 –XX:MaxPermSize)。后续教程中我们会看到更多标志。

除了 JVM 本身,Java 进程还在其他区域消耗内存。例如,堆外技术通常使用直接 ByteBuffer 处理大内存,使其不受 GC 控制;另一个来源是本地库使用的内存。

3. JVM 的非堆内存区域

继续深入 JVM 的非堆内存区域:

3.1. 元空间

元空间是存储类元数据的本地内存区域。加载类时,JVM 会将类的元数据(即其运行时表示)分配到元空间。当类加载器及其所有类从堆中移除时,它们在元空间中的分配才会被 GC 回收。

但释放的元空间不一定归还给操作系统。部分或全部内存可能仍被 JVM 保留,供后续类加载重用。

在 Java 8 之前的版本中,元空间称为永久代 (PermGen)。但与元空间是堆外内存区域不同,永久代位于特殊的堆区域

3.2. 代码缓存

即时编译器 (JIT) 将其输出存储在代码缓存区域。JIT 编译器将频繁执行部分的字节码编译为本地代码(即热点代码)。Java 7 引入的分层编译机制中,客户端编译器 (C1) 先编译带有检测信息的代码,然后服务器编译器 (C2) 使用分析数据优化编译该代码。

分层编译的目标是混合使用 C1 和 C2 编译器,兼顾快速启动和长期性能。分层编译会使需要缓存的代码量增加多达四倍。自 Java 8 起,JIT 默认启用分层编译,但我们仍可禁用它。

3.3. 线程栈

线程栈包含每个执行方法的所有局部变量,以及线程调用到达当前执行点的方法链。线程栈只能由创建它的线程访问。

理论上,线程栈内存是运行线程数的函数,由于线程数无限制,线程区域可能无界并占用大量内存。实际上,操作系统会限制线程数,JVM 也会根据平台为每个线程的栈内存设置默认大小。

3.4. 垃圾回收

JVM 提供一组 GC 算法,可根据应用场景选择。无论使用哪种算法,GC 过程都会分配一定量的本地内存,但具体内存量取决于使用的垃圾回收器

3.5. 符号区域

JVM 使用符号区域存储字段名、方法签名和字符串常量等符号。在 Java 开发工具包 (JDK) 中,符号存储在三个不同的表中

  • 系统字典:包含所有已加载类型信息(如 Java 类)。
  • 常量池:使用符号表数据结构保存类、方法、字段和枚举类型的已加载符号。JVM 为每个类型维护一个运行时常量池,包含从编译时数值字面量到运行时方法甚至字段引用等多种常量。
  • 字符串表:包含所有常量字符串的引用(即字符串常量池)。

3.6. 竞技场内存

竞技场是 JVM 对基于竞技场的内存管理的实现,与 glibc 的竞技场内存管理不同。它被 JVM 的某些子系统(如编译器、符号)使用,或在本地代码使用依赖 JVM 竞技场的内部对象时使用。

3.7. 其他区域

所有无法归类到本地内存区域的其他内存使用都归入此部分。例如,DirectByteBuffer 的使用间接在此部分可见。

4. 内存监控工具

现在我们了解到 Java 内存使用不仅限于堆内存,接下来探讨跟踪总内存使用的方法。可通过性能分析和内存监控工具进行发现,然后通过特定调优优化总使用量。

快速浏览 JDK 附带的 JVM 内存监控工具:

  • jmap:命令行工具,可打印运行中 VM 或核心文件的内存映射。我们也可用 jmap 查询远程机器上的进程。但 JDK 8 引入 jcmd 后,推荐使用 jcmd 替代 jmap 以获得增强诊断和更低性能开销。
  • jcmd:用于向 JVM 发送诊断命令请求,这些请求对控制 Java Flight Recordings、故障排除和诊断 JVM 及 Java 应用很有用。***jcmd 不适用于远程进程**。本文将展示 jcmd 的具体用法。
  • jhat:通过启动本地 Web 服务器可视化堆转储文件。创建堆转储有多种方式,如使用 jmap -dumpjcmd GC.heap_dump filename
  • hprof:可展示 CPU 使用率、堆分配统计和竞争监控分析。根据请求的分析类型,hprof 指示虚拟机收集相关 JVM 工具接口 (JVM TI) 事件,并将事件数据处理为分析信息。

除 JVM 附带工具外,操作系统也提供检查进程内存的命令。Linux 发行版可使用 pmap 工具,它提供 Java 进程内存使用的完整视图。

5. 本机内存跟踪

本机内存跟踪 (NMT) 是 JVM 功能,可用于跟踪 VM 的内部内存使用。NMT 不跟踪所有本地内存使用(如第三方本地代码内存分配),但对大多数典型应用已足够。

启用 NMT:

java -XX:NativeMemoryTracking=summary -jar app.jar

-XX:NativeMemoryTracking 的其他可用值是 offdetail。注意启用 NMT 会产生性能开销。此外,NMT 会为所有 malloc 内存添加两个机器字作为 malloc 头。

然后使用 jps 或无参数的 jcmd 查找应用进程 ID (pid):

jcmd
<pid> <our.app.main.Class>

找到应用 pid 后,使用 jcmd 继续操作,它提供大量监控选项。查看可用选项:

jcmd <pid> help

输出显示 jcmd 支持不同类别(如 Compiler、GC、JFR、JVMTI、ManagementAgent 和 VM)。某些选项(如 VM.metaspaceVM.native_memory)有助于内存跟踪。下面探索其中几个。

5.1. 本机内存摘要报告

最实用的是 VM.native_memory,可用于查看应用 VM 内部本地内存使用摘要:

jcmd <pid> VM.native_memory summary
<pid>:

Native Memory Tracking:

Total: reserved=1779287KB, committed=503683KB
- Java Heap (reserved=307200KB, committed=307200KB)
  ...
- Class (reserved=1089000KB, committed=44824KB)
  ...
- Thread (reserved=41139KB, committed=41139KB)
  ...
- Code (reserved=248600KB, committed=17172KB)
  ...
- GC (reserved=62198KB, committed=62198KB)
  ...
- Compiler (reserved=175KB, committed=175KB)
  ...
- Internal (reserved=691KB, committed=691KB)
  ...
- Other (reserved=16KB, committed=16KB)
  ...
- Symbol (reserved=9704KB, committed=9704KB)
  ...
- Native Memory Tracking (reserved=4812KB, committed=4812KB)
  ...
- Shared class space (reserved=11136KB, committed=11136KB)
  ...
- Arena Chunk (reserved=176KB, committed=176KB)
  ... 
- Logging (reserved=4KB, committed=4KB)
  ... 
- Arguments (reserved=18KB, committed=18KB)
  ... 
- Module (reserved=175KB, committed=175KB)
  ... 
- Safepoint (reserved=8KB, committed=8KB)
  ... 
- Synchronization (reserved=4235KB, committed=4235KB)
  ... 

输出展示了 JVM 内存区域(如 Java 堆、GC、线程)的摘要。"reserved" 内存表示通过 mallocmmap 预映射的总地址范围,即该区域的最大可寻址内存。"committed" 表示实际使用的内存。

此处可找到输出的详细解释。要查看内存使用变化,可依次使用 VM.native_memory baselineVM.native_memory summary.diff

5.2. 元空间和字符串表报告

尝试其他 jcmd 的 VM 选项,查看特定本地内存区域(如元空间、符号和字符串常量池)的概览。

查看元空间:

jcmd <pid> VM.metaspace

输出示例:

<pid>:
Total Usage - 1072 loaders, 9474 classes (1176 shared):
...
Virtual space:
  Non-class space:       38.00 MB reserved,      36.67 MB ( 97%) committed 
      Class space:        1.00 GB reserved,       5.62 MB ( <1%) committed 
             Both:        1.04 GB reserved,      42.30 MB (  4%) committed 
Chunk freelists:
   Non-Class: ...
       Class: ...
Waste (percentages refer to total committed size 42.30 MB):
              Committed unused:    192.00 KB ( <1%)
        Waste in chunks in use:      2.98 KB ( <1%)
         Free in chunks in use:      1.05 MB (  2%)
     Overhead in chunks in use:    232.12 KB ( <1%)
                In free chunks:     77.00 KB ( <1%)
Deallocated from chunks in use:    191.62 KB ( <1%) (890 blocks)
                       -total-:      1.73 MB (  4%)
MaxMetaspaceSize: unlimited
CompressedClassSpaceSize: 1.00 GB
InitialBootClassLoaderMetaspaceSize: 4.00 MB

查看应用的字符串表:

jcmd <pid> VM.stringtable 

输出示例:

<pid>:
StringTable statistics:
Number of buckets : 65536 = 524288 bytes, each 8
Number of entries : 20046 = 320736 bytes, each 16
Number of literals : 20046 = 1507448 bytes, avg 75.000
Total footprint : = 2352472 bytes
Average bucket size : 0.306
Variance of bucket size : 0.307
Std. dev. of bucket size: 0.554
Maximum bucket size : 4

6. JVM 内存调优

我们知道 Java 应用的总内存是堆分配与 JVM 或第三方库的非堆分配之和。非堆内存在负载下大小变化较小。通常,一旦所有使用的类加载完成且 JIT 充分预热,应用的非堆内存使用就趋于稳定。不过,我们仍可用一些标志指导 JVM 管理特定区域的内存使用。

***jcmd 提供 VM.flag 选项查看 Java 进程的所有标志(包括默认值),因此可用它检查默认配置并了解 JVM 配置方式:**

jcmd <pid> VM.flags

输出示例:

<pid>:
-XX:CICompilerCount=4 
-XX:ConcGCThreads=2 
-XX:G1ConcRefinementThreads=8 
-XX:G1HeapRegionSize=1048576 
-XX:InitialHeapSize=314572800 
...

下面查看不同区域内存调优的 VM 标志

6.1. 堆内存

JVM 堆调优标志较多。配置最小和最大堆大小使用 -Xms-XX:InitialHeapSize)和 -Xmx-XX:MaxHeapSize)。若想按物理内存百分比设置堆大小,可使用 -XX:MinRAMPercentage-XX:MaxRAMPercentage需注意:使用 -Xms-Xmx 时,JVM 会忽略这两个百分比标志。

另一个影响内存分配模式的选项是 XX:+AlwaysPreTouch。默认情况下,JVM 最大堆分配在虚拟内存而非物理内存中。只要没有写操作,操作系统可能不分配内存。为规避此问题(尤其对大型 DirectByteBuffers,重新分配可能因重新排列 OS 内存页而耗时),可启用 -XX:+AlwaysPreTouch。预触摸会在所有页上写入 "0",强制操作系统分配内存而非仅保留。预触摸会导致 JVM 启动延迟,因为它在单线程中工作。

6.2. 线程栈

线程栈是每个线程存储其执行方法所有局部变量的区域。使用 -Xss-XX:ThreadStackSize 配置每个线程的栈大小。默认线程栈大小取决于平台,但大多数现代 64 位操作系统默认为 1 MB。

6.3. 垃圾回收

通过以下标志之一设置应用的 GC 算法:-XX:+UseSerialGC-XX:+UseParallelGC-XX:+UseParallelOldGC-XX:+UseConcMarkSweepGC-XX:+UseG1GC

若选择 G1 作为 GC,可通过 -XX:+UseStringDeduplication 启用字符串去重。这能节省大量内存。字符串去重仅适用于长期存活的对象。要规避此限制,可用 -XX:StringDeduplicationAgeThreshold 配置对象的有效年龄。该值表示对象存活的 GC 周期数。

6.4. 代码缓存

自 Java 9 起,JVM 将代码缓存分为三个区域,因此提供特定选项分别调优:

  • -XX:NonNMethodCodeHeapSize:配置非方法段(JVM 内部相关代码),默认约 5 MB。
  • -XX:ProfiledCodeHeapSize:配置分析代码段(C1 编译的代码,生命周期可能较短),默认约 122 MB。
  • -XX:NonProfiledCodeHeapSize:设置非分析代码段大小(C2 编译的代码,生命周期可能较长),默认约 122 MB。

6.5. 分配器

JVM 先保留内存,然后通过 glibc 的 malloc 和 mmap修改内存映射使部分“保留”内存可用。保留和释放内存块的操作可能导致碎片化,进而产生大量未使用内存区域。

除 malloc 外,还可使用其他分配器(如 jemalloctcmalloc)。jemalloc 是通用 malloc 实现,强调避免碎片化和可扩展并发支持,通常比常规 glibc 的 malloc 更智能。此外,jemalloc 还可用于泄漏检查和堆分析。

6.6. 元空间

与堆类似,我们也有配置元空间大小的选项。配置元空间的下限和上限分别使用 -XX:MetaspaceSize-XX:MaxMetaspaceSize

-XX:InitialBootClassLoaderMetaspaceSize 可配置启动类加载器的初始大小。

-XX:MinMetaspaceFreeRatio-XX:MaxMetaspaceFreeRatio 用于配置 GC 后类元数据容量的最小和最大空闲百分比。

还可用 -XX:MaxMetaspaceExpansion 配置元空间在不触发 Full GC 时的最大扩展量。

6.7. 其他非堆内存区域

也有用于调优其他本地内存区域使用的标志。

使用 -XX:StringTableSize 指定字符串池的映射大小,映射大小表示字符串常量池中不同字符串的最大数量。JDK 7+ 默认映射大小为 600013,即默认池中可有 600,013 个不同字符串。

控制 DirectByteBuffers 的内存使用可用 -XX:MaxDirectMemorySize。此选项限制所有 DirectByteBuffers 可保留的内存量。

对需要加载更多类的应用,可使用 -XX:PredictedLoadedClassCount。此选项自 JDK 8 起可用,允许设置系统字典的桶大小。

7. 结论

本文探讨了 Java 进程的不同内存区域及监控内存使用的工具。我们看到 Java 内存使用不仅限于堆内存,因此使用 jcmd 检查和跟踪 JVM 的内存使用。最后,我们回顾了一些可帮助调优 Java 应用内存使用的 JVM 标志。


原始标题:Can a Java Application Use More Memory Than the Heap Size?