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 -dump
或jcmd 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
的其他可用值是 off
和 detail
。注意启用 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.metaspace
、VM.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" 内存表示通过 malloc
或 mmap
预映射的总地址范围,即该区域的最大可寻址内存。"committed" 表示实际使用的内存。
此处可找到输出的详细解释。要查看内存使用变化,可依次使用 VM.native_memory baseline
和 VM.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 外,还可使用其他分配器(如 jemalloc 或 tcmalloc)。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 标志。