Java 基础体系 · 第 17/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

JVM 内存与垃圾回收:堆、栈、元空间、G1、ZGC 和调优

Java 程序运行时的“内存”不是一个单一区域。JVM 至少同时管理对象堆、线程栈、类元数据、代码缓存、直接内存以及若干实现相关的本地内存。垃圾回收器主要负责堆中的对象,但一次线上故障可能表现为:

  • java.lang.OutOfMemoryError: Java heap space
  • java.lang.OutOfMemoryError: Metaspace
  • java.lang.OutOfMemoryError: Direct buffer memory
  • java.lang.StackOverflowError
  • GC 暂停时间过长
  • GC 线程占满 CPU,但堆占用并不高
  • 类不断加载、卸载失败,最终元空间耗尽
  • 进程 RSS 远大于 -Xmx

理解这些现象,需要先区分三类概念:

  1. JVM 规范规定的运行时数据区:例如堆、方法区、每线程的 PC 寄存器、JVM 栈和本地方法栈。
  2. HotSpot 的实现:例如元空间是方法区的一种实现,G1 将堆划分为 Region,ZGC 使用屏障和染色指针。
  3. 操作系统和进程资源:例如线程栈虚拟内存、直接内存、代码缓存、JFR 缓冲区和 libc 分配器使用的内存。

后文以 Java 25 LTS 和 HotSpot 为背景。凡是 JVM 规范保证的行为、HotSpot 常见实现和工程经验,会明确区分。


一、先建立内存全景:哪些内存由谁管理

JVM 规范要求描述运行时数据区,但并不规定 HotSpot 必须使用某种物理布局,也不规定垃圾回收器的具体算法。典型 HotSpot 进程可以抽象为:

flowchart TB
    A[Java 进程] --> B[Java 堆]
    A --> C[线程相关内存]
    A --> D[类与代码相关内存]
    A --> E[其他本地内存]

    B --> B1[新生代]
    B --> B2[老年代]
    B --> B3[G1 Region 或 ZGC Page]

    C --> C1[PC 寄存器]
    C --> C2[JVM 栈]
    C --> C3[本地方法栈]
    C --> C4[线程栈外的线程结构]

    D --> D1[元空间]
    D --> D2[Compressed Class Space]
    D --> D3[Code Cache]

    E --> E1[DirectByteBuffer 对应的本地内存]
    E --> E2[JVM 内部数据结构]
    E --> E3[JFR、GC、类加载等缓冲区]

1. JVM 规范中的运行时数据区

《Java Virtual Machine Specification》描述了以下运行时数据区:

  • PC 寄存器:每个线程独立,用于记录当前执行的 JVM 指令位置。执行本地方法时,规范对其值没有强制的具体要求。
  • JVM 栈:每个线程独立,保存方法调用帧。帧中包含局部变量表、操作数栈、动态链接信息以及方法返回相关信息。
  • 本地方法栈:服务于本地方法调用。规范允许实现以不同方式实现,HotSpot 通常将其与线程栈相关的本地调用机制统一处理。
  • :线程共享,用于存放对象和数组,是垃圾回收器主要管理的区域。
  • 方法区:线程共享,用于存放类级别的信息,例如运行时常量池、字段和方法数据、方法与构造方法的代码。规范没有要求它必须叫“元空间”,也没有规定它必须位于堆外。

HotSpot 将方法区实现为 Metaspace(元空间),并将类指针相关区域称为 Compressed Class Space。因此,“方法区”和“元空间”不能简单当作规范名与实现名的完全同义词:前者是规范概念,后者是 HotSpot 的实现。

2. 栈不是“存放所有局部变量的地方”

一个 Java 方法被调用时,JVM 为该线程创建一个栈帧。以如下代码为例:

static int add(int a, int b) {
    int c = a + b;
    return c;
}

执行 add(1, 2) 时,抽象过程可以表示为:

  1. 调用者将参数压入调用约定要求的位置。
  2. add 创建自己的栈帧。
  3. 局部变量表保存 abc 的值或引用。
  4. 操作数栈执行 iloadiadd 等字节码操作。
  5. ireturn 将结果返回,当前栈帧销毁。

如果局部变量是对象引用:

static void f() {
    byte[] data = new byte[1024];
}

栈帧中的 data 通常是一个引用,而数组对象本身位于堆中。引用的具体表示由实现决定,不能把“栈上有引用”理解为“对象在栈上”。

HotSpot 可能通过 逃逸分析标量替换 消除某些对象分配,甚至将对象字段拆成标量。但这是 JIT 优化,不是 Java 语言或 JVM 规范保证的对象存放规则。因此不能依赖“短命对象一定在栈上”。

3. 栈溢出与堆溢出是两条不同故障路径

递归调用没有终止条件:

public class StackOverflowDemo {
    static void recurse() {
        recurse();
    }

    public static void main(String[] args) {
        recurse();
    }
}

通常结果是:

Exception in thread "main" java.lang.StackOverflowError

原因是单个线程的栈空间不足,而不是堆中对象太多。可以用:

java -Xss512k StackOverflowDemo
java -Xss2m StackOverflowDemo

增大 -Xss 可能允许更深的调用,但每个线程的栈保留空间也可能增大。在线程数量很多的服务中,盲目增大 -Xss 会增加进程虚拟地址空间和本地内存压力。

相反,以下代码更可能触发堆溢出:

import java.util.ArrayList;
import java.util.List;

public class HeapOverflowDemo {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[1024 * 1024]);
        }
    }
}

运行:

java -Xms128m -Xmx128m HeapOverflowDemo

如果 list 仍然可达,数组就不能被回收,最终通常出现:

java.lang.OutOfMemoryError: Java heap space

这两个错误的诊断方法、根因和修复方向完全不同。


二、堆:对象生命周期、可达性和 GC Roots

1. 堆的容量边界

常见参数含义如下:

  • -Xms:初始堆大小。
  • -Xmx:最大堆大小。
  • -Xmn:传统分代收集器中常用于指定新生代大小;对 G1、ZGC 不应作为主要调优手段。
  • -XX:InitialRAMPercentage-XX:MaxRAMPercentage:在未显式设置固定堆大小时,根据可识别的物理内存或容器内存计算堆大小。

-Xmx 只限制 Java 堆,并不限制整个 Java 进程。一个进程的 RSS 还可能包括:

RSSHeap+Metaspace+CodeCache+ThreadStacks+DirectMemory+JVMNativeMemory+NativeAllocatorOverheadRSS \approx Heap + Metaspace + CodeCache + ThreadStacks + DirectMemory + JVMNativeMemory + NativeAllocatorOverhead

这不是 JVM 规范公式,而是排查容器内存问题时的近似分解。每一项都可能存在提交内存、保留地址空间和实际驻留页之间的差异。

2. “对象不可达”才是回收依据

垃圾回收器不是按照“对象是否超龄”直接删除对象,而是从一组根集合出发,遍历对象图。

设对象图为:

G=(V,E)G=(V,E)

其中:

  • VV 是对象集合;
  • EE 是对象字段或数组元素形成的引用边;
  • RVR\subseteq V 是 GC Roots 集合。

若某对象 vv 满足:

rR,rv\exists r\in R,\quad r \leadsto v

即存在从某个 GC Root 到 vv 的引用路径,则 vv 是可达对象,不能被回收。反之,如果不存在这样的路径,它就是垃圾候选对象。

常见 GC Roots 包括:

  • 活跃 Java 线程栈中的引用;
  • 静态字段引用;
  • JNI 全局引用;
  • JVM 内部持有的特殊对象;
  • 某些同步锁、系统类加载器和运行时结构中的引用。

注意,“对象没有业务意义”不等于“对象不可达”。例如:

class Cache {
    static final Map<String, byte[]> DATA = new HashMap<>();
}

如果 DATA 中的值没有淘汰机制,它们仍然通过静态字段到达。GC 无法判断这些缓存项“业务上已经过期”。

3. finalize 不能作为资源管理机制

现代 Java 不应依赖对象终结机制释放文件、连接或本地资源。对象是否被回收与资源释放时机不同,GC 也不保证对象在某个确定时间被处理。

应使用显式生命周期:

try (var input = java.nio.file.Files.newInputStream(
        java.nio.file.Path.of("data.txt"))) {
    input.read();
}

try-with-resources 通过 AutoCloseable.close() 确定释放资源;GC 只负责回收不再可达的 Java 对象。


三、代际回收:为什么有新生代和老年代

分代假设是:

  1. 大多数对象存活时间很短;
  2. 少数对象会长期存活;
  3. 年轻对象之间的引用通常多于老对象指向年轻对象的引用。

因此,收集器可以频繁处理新生代,而不必每次扫描整个堆。

1. 新生代回收的基本流程

以复制式新生代回收为例:

  1. 应用线程在 Eden 中分配对象。
  2. Eden 满或达到触发条件,发生 Young GC。
  3. 收集器从 GC Roots 和存活的老年代引用开始扫描。
  4. 存活对象复制到 Survivor 区或晋升到老年代。
  5. 原 Eden 和部分 Survivor 区整体回收。

假设某次 Young GC 前:

Eden:     100 MB,其中存活 10 MB
Survivor: 20 MB,其中存活 8 MB
Old:      500 MB,其中有 2 MB 指向年轻对象

收集器需要处理的年轻存活数据不是 120 MB,而主要是:

10 MB + 8 MB + 老年代跨代引用指向的年轻对象

这正是分代回收的收益来源:扫描和复制的成本更接近存活对象量,而不是整个年轻代容量。

2. 跨代引用为何需要记忆集

如果老年代对象 O 引用了年轻对象 Y

Old object O  ─────►  Young object Y

Young GC 不能只扫描年轻代中的根,否则会漏掉 Y。但每次都扫描整个老年代又很昂贵。因此收集器维护卡表、记忆集等辅助结构,记录可能包含跨区域引用的位置。

写屏障通常在引用写入时执行:

oldObject.field = youngObject;

收集器或编译器插入的屏障会把相关卡片标记为脏,后续 GC 只扫描这些候选区域。代价是:

  • 每次相关引用写入增加少量 CPU;
  • 记忆集占用额外内存;
  • 大量跨区域引用会增加 GC 扫描成本。

这解释了一个常见边界:对象存活率不高并不一定意味着 GC 成本低。如果对象图跨代、跨 Region 的引用非常密集,辅助数据结构和扫描工作仍可能很大。

3. 晋升失败与长生命周期对象

当年轻代存活对象无法放入 Survivor 或老年代空间不足时,可能出现晋升压力。老年代回收如果无法及时腾出空间,可能退化为更长的停顿,甚至抛出 OutOfMemoryError

“加大新生代”也不是万能解法:

  • 新生代太小:Young GC 频繁;
  • 新生代太大:单次 Young GC 可能复制更多存活对象,暂停变长;
  • 老年代增长过快:混合回收压力增加;
  • 长生命周期对象太多:最终由活跃集合大小决定最低堆需求。

四、元空间:类元数据为何在堆外

1. 元空间保存什么

HotSpot 的元空间用于保存类元数据,典型内容包括:

  • 类的结构信息;
  • 字段和方法元数据;
  • 方法字节码相关结构;
  • 运行时常量池的实现数据;
  • 类加载器相关元数据;
  • 注解、方法句柄和部分反射相关结构。

Java 对象实例仍然位于堆中。例如:

Class<?> type = String.class;

Class 对象本身可视为堆对象,但它关联的类元数据由 JVM 在元空间等本地区域管理。

2. 类加载、类卸载和元空间增长

类加载过程可抽象为:

sequenceDiagram
    participant L as ClassLoader
    participant M as Metaspace
    participant H as Heap
    participant GC as GC

    L->>L: 加载字节码
    L->>L: 验证、准备、解析、初始化
    L->>M: 创建类元数据
    L->>H: 创建 Class 对象
    Note over L,M: 类与定义它的 ClassLoader 建立关联

    L-->>L: 业务结束,但可能仍有引用
    GC->>GC: 检查 ClassLoader 是否可达
    alt ClassLoader 不可达且无活跃使用
        GC->>M: 卸载类元数据
        GC->>H: 回收关联 Class 对象
    else 仍然可达
        GC-->>M: 不能卸载
    end

类卸载通常要求:

  1. 定义该类的类加载器不可达;
  2. 该加载器定义的类没有被其他活跃对象、线程、JNI 或运行时结构继续使用;
  3. 收集器执行了支持类卸载的完整回收周期。

因此,下面这些行为容易导致元空间持续增长:

  • 每次请求都创建新的类加载器;
  • 动态代理或字节码生成不断产生新类;
  • Web 容器重载时旧类加载器被线程、静态字段或 ThreadLocal 引用;
  • 脚本引擎、插件系统没有销毁类加载器;
  • 线程上下文类加载器仍指向旧应用。

这与普通堆泄漏相似,但根对象可能是类加载器,表现却是 Metaspace 增长。

3. 元空间参数

常见参数包括:

-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

MetaspaceSize 主要影响初始触发元空间相关 GC 的阈值,不等价于“初始元空间容量”。MaxMetaspaceSize 限制元空间可使用的最大容量;设置过小可能导致频繁类卸载尝试或最终 OutOfMemoryError: Metaspace

诊断元空间问题时,不应只看堆:

jcmd <pid> GC.heap_info
jcmd <pid> VM.classloader_stats

VM.classloader_stats 是 HotSpot 诊断命令,输出各类加载器及其加载类数量等信息。若怀疑本地内存整体超限,应在启动时启用 Native Memory Tracking:

java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary

NMT 有运行时开销,并且必须在启动时启用;不能等到进程已经运行后再完整开启。


五、垃圾回收的三件核心工作

不同收集器实现不同,但核心工作通常可以归纳为:

1. 标记:找出存活对象

从 GC Roots 遍历引用图,标记可达对象。并发标记期间,应用线程可能继续修改引用,因此需要写屏障或其他并发协议记录变化。

2. 清扫:回收不可达对象

直接回收不可达对象可以形成空闲块,但会产生碎片。分配大对象时,即使总空闲空间足够,也可能没有足够大的连续空间。

3. 压缩或疏散:移动存活对象

把存活对象移动到新位置,可以消除碎片并改善分配效率。但对象移动后,所有指向它的引用都必须更新,且应用线程不能在更新过程中访问错误地址。

Stop-The-World(STW) 表示暂停应用线程,而不是停止整个进程。GC 线程仍可能运行。并发收集器的目标是把标记、重定位等工作尽量放在应用线程运行期间完成,但通常仍存在短暂停顿和并发阶段。


六、G1:基于 Region 的分区化收集器

G1(Garbage-First)是 HotSpot 的分区化、分代、可预测暂停目标收集器。在 Java 25 的常见 HotSpot 配置中,G1 是默认收集器,但“默认”属于实现和发行版行为,不是 JVM 规范保证。

1. Region 不是固定的新生代或老年代

G1 将堆划分为大量大小相同的 Region。一个 Region 在某一时刻可能承担:

  • Eden Region;
  • Survivor Region;
  • Old Region;
  • Humongous Region。

Region 的角色可以随着 GC 周期变化。与传统连续新生代不同,G1 可以从多个离散 Region 组成逻辑上的年轻代。

大对象若超过 Region 一半,通常会以 Humongous 对象处理,占用连续的一个或多个 Region。大量 Humongous 对象可能造成:

  • Region 分配压力;
  • 更高的记忆集和标记成本;
  • 回收时机不理想;
  • 即使总空闲空间不少,也难以满足连续 Region 需求。

2. G1 的一次 Young GC

假设堆有 8 个 Region:

R0 Eden  存活 2 MB
R1 Eden  存活 1 MB
R2 Eden  存活 0 MB
R3 Survivor 存活 3 MB
R4 Old,含引用指向 R0
R5 Old
R6 空闲
R7 空闲

一次 Young GC 的逻辑步骤是:

  1. 暂停应用线程。
  2. 通过 GC Roots 和老年代记忆集找到 R0、R1、R3 中的存活对象。
  3. 将这些对象复制到新的 Survivor 或 Old Region。
  4. 更新对象引用和记忆集。
  5. 释放原来的年轻 Region。
  6. 恢复应用线程。

如果存活对象总量是 6 MB,而空闲目标 Region 足够,复制成本主要与这 6 MB 有关,而不是与所有 Region 的容量之和有关。

3. G1 的并发标记和混合回收

当老年代占用达到某个阈值,G1 可能启动并发标记周期。其抽象阶段为:

  1. Initial Mark:通常与一次 STW 阶段关联,标记 GC Roots 直接可达对象。
  2. Concurrent Mark:应用线程继续运行,收集器并发遍历对象图。
  3. Remark:再次短暂停顿,处理并发期间引用变化。
  4. Cleanup:整理统计数据并回收完全空闲的 Region。
  5. Mixed GC:在年轻代回收基础上,额外选择部分垃圾比例较高的老年代 Region。

“Garbage-First”不是每次都优先回收所有老年代,而是在满足暂停目标和复制资源约束的前提下,优先选择预计收益较高的 Region。

4. G1 的暂停预测不是硬实时保证

-XX:MaxGCPauseMillis=200 表示希望目标暂停时间接近 200 ms,而不是严格保证任何一次暂停都不超过 200 ms。

一次 G1 暂停至少受以下因素影响:

  • 待处理 Region 数量;
  • 存活对象复制量;
  • 根扫描和线程根数量;
  • 记忆集大小及脏卡数量;
  • Humongous 对象;
  • GC 线程数;
  • 操作系统调度和 CPU 争用;
  • 是否发生晋升或疏散失败。

如果活跃对象很多,或者没有足够空闲 Region 作为复制目标,就算把暂停目标调得更小,收集器也无法违反物理工作量约束。

5. G1 的常见调优参数

java \
  -Xms8g -Xmx8g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags \
  -jar app.jar

这里:

  • -Xms8g -Xmx8g 让堆上下限一致,减少运行时扩缩容带来的变量;不是所有应用都必须这样设置。
  • -XX:+UseG1GC 明确选择 G1;Java 25 HotSpot 通常已默认使用 G1,因此主要价值是让启动配置显式化。
  • -XX:MaxGCPauseMillis=200 设置暂停目标,不是 SLA 保证。
  • -Xlog:gc*,safepoint:... 同时记录 GC 和安全点相关信息,用于把“GC 慢”和“安全点进入慢”区分开。

-XX:G1ReservePercent 可为疏散保留一定堆空间;过小可能提高疏散失败风险,过大则减少可用于对象分配的空间。-XX:InitiatingHeapOccupancyPercent 可影响并发标记启动时机,但 G1 的实际行为还受自适应策略影响。除非有 GC 日志证明某个阶段是瓶颈,否则不应成组修改这些参数。


七、ZGC:以低暂停为目标的并发收集器

ZGC 适合对长尾延迟敏感、堆较大且不希望出现长时间 STW 的场景。它的核心不是“完全没有暂停”,而是将标记、准备和重定位等工作尽可能并发化,把暂停阶段压缩到较短的根处理和状态切换工作。

Java 23 起,Generational ZGC 成为默认模式;Java 25 应明确区分:

-XX:+UseZGC
-XX:+ZGenerational

非分代模式仍可能作为兼容或对比用途存在,但分代模式是当前 ZGC 的主流方向。具体发行版仍应通过 java -XX:+PrintFlagsFinal -version 和官方发行说明确认。

1. ZGC 为什么能并发移动对象

对象移动有一个难点:

应用线程持有 oldAddress
收集器将对象移动到 newAddress
应用线程随后仍使用 oldAddress

如果应用线程直接解引用旧地址,就会访问错误位置。

ZGC 通过以下机制协调:

  • 染色指针(Colored Pointers):在引用表示中携带对象状态信息。具体位布局受平台和实现影响,不能把它理解为普通 Java 引用可直接操作的公开格式。
  • Load Barrier(加载屏障):应用线程读取引用时,检查对象状态,必要时修复或重映射引用。
  • 并发标记:应用运行时完成大部分标记工作。
  • 并发重定位:对象移动和引用修复尽量与应用并发执行。
  • 转发表(Forwarding Table):记录旧地址到新地址的映射,使旧引用可以被解析到新对象位置。

抽象流程如下:

sequenceDiagram
    participant A as Java 应用线程
    participant Z as ZGC 并发线程
    participant O as 旧对象地址
    participant N as 新对象地址

    Z->>O: 标记对象并决定重定位
    Z->>N: 复制对象内容
    Z->>Z: 写入 O -> N 转发表
    A->>O: 读取引用
    A->>A: Load Barrier 检查状态
    A->>Z: 查询转发表
    Z-->>A: 返回 N
    A->>N: 访问对象并修复引用

这不是说每次读取都会进行昂贵的全局查询。屏障路径会根据对象状态走快速路径或慢速路径,具体实现由 HotSpot 和平台决定。

2. Generational ZGC 的意义

分代 ZGC 同样利用“多数对象短命”的假设,但保留 ZGC 的并发低延迟设计。它需要维护代际引用信息,因此也会产生写屏障、记忆结构和额外 CPU 成本。

与 G1 相比,ZGC 的取舍通常是:

  • 更强调低暂停和低长尾;
  • 并发 GC 工作可能占用更多 CPU;
  • 堆大小、对象分配速率和根集合仍然影响暂停与吞吐;
  • 不是所有低延迟问题都由 GC 引起。

3. ZGC 示例

java \
  -Xms16g -Xmx16g \
  -XX:+UseZGC \
  -XX:+ZGenerational \
  -Xlog:gc*,safepoint:file=gc-zgc.log:time,uptime,level,tags \
  -jar app.jar

-Xms-Xmx 相同并非 ZGC 的必需条件。ZGC 支持在运行中归还部分未使用堆内存;是否启用以及归还速度受版本和实现参数影响,不能只凭 RSS 短时间变化判断 GC 是否异常。

某些版本支持 -XX:SoftMaxHeapSize 作为软目标,使收集器尽量将堆控制在某个大小,同时允许在压力下扩展到 -Xmx。它不是硬上限,也不是“保证 RSS 不超过该值”的进程内存限制。


八、G1 和 ZGC 如何选择

不能用“堆大就一定 ZGC”或“G1 一定吞吐更高”作为绝对规则。应先定义服务目标:

1. 延迟目标

如果要求关注:

P99.9 < 200 ms
P99.99 < 500 ms

就必须把 GC 暂停、Safepoint、线程调度、锁竞争、网络抖动和 I/O 等因素分开测量。ZGC 可以降低 GC 长暂停风险,但不会消除应用自身的长尾。

2. 吞吐目标

批处理、离线计算和高吞吐服务可能更关注:

Throughput=Application TimeApplication Time+GC TimeThroughput = \frac{Application\ Time} {Application\ Time + GC\ Time}

GC 并发线程越多,暂停可能下降,但应用线程可使用的 CPU 也可能减少。ZGC 的并发成本在 CPU 紧张的机器上可能更明显;G1 的停顿和吞吐折中可能更适合某些负载。

3. 活跃集合和分配速率

定义:

  • LL:稳定状态下的活跃对象大小;
  • AA:单位时间分配速率;
  • TT:希望两次关键回收之间覆盖的时间;
  • HH:安全余量;
  • XX:最大堆。

一个粗略容量条件是:

X>L+A×T+HX > L + A \times T + H

例如:

活跃集合 L = 6 GB
分配速率 A = 500 MB/s
并发标记和回收周期 T = 10 s
安全余量 H = 2 GB

则:

X>6+0.5×10+2=13 GBX > 6 + 0.5 \times 10 + 2 = 13\text{ GB}

因此 12 GB 堆很可能没有足够余量,16 GB 只是较合理的实验起点,并不构成最终配置。若对象存活率升高,LL 增大;若 CPU 争用使回收周期变长,TT 增大;两者都会提高所需堆容量。


九、调优前必须区分四种“内存不足”

1. Java 堆不足

典型错误:

java.lang.OutOfMemoryError: Java heap space

证据:

  • GC 日志显示回收后占用仍接近 -Xmx
  • 堆转储显示大量业务对象或集合;
  • 活跃对象持续增长。

修复方向可能是:

  • 修复引用泄漏;
  • 限制缓存;
  • 降低批量处理峰值;
  • 增大堆;
  • 调整收集器和分配速率。

单纯加大堆只能延后故障。若活跃集合持续增长,最终仍满足:

L(t)XL(t) \geq X

2. 元空间不足

典型错误:

java.lang.OutOfMemoryError: Metaspace

证据:

  • 类加载数量持续增长;
  • 类加载器统计显示旧加载器无法释放;
  • 动态代理、脚本、热部署或插件系统产生大量类。

修复方向应优先检查类加载器生命周期,而不是只增大 MaxMetaspaceSize

3. 直接内存不足

典型错误:

java.lang.OutOfMemoryError: Cannot reserve enough space for object heap
java.lang.OutOfMemoryError: Direct buffer memory

ByteBuffer.allocateDirect() 使用堆外本地内存,但其 Java 包装对象在堆中。直接内存受清理时机、引用存活、NIO 使用方式和 -XX:MaxDirectMemorySize 等因素影响。

这类问题可能出现:

Java heap 使用量不高
但进程 RSS 持续上升

因为堆转储只能看到包装对象,不能完整反映所有本地内存分配。

4. 线程或本地内存不足

线程数过多会消耗:

  • 每个线程的栈空间;
  • 线程控制结构;
  • 调度和内核资源;
  • 线程相关的 JVM 本地内存。

在容器中还可能由 cgroup 限制触发进程被杀,而不是 Java 主动抛出 OOME。此时要同时看容器事件、进程 RSS、线程数和 JVM 内存分类。


十、用证据链诊断 GC 和内存问题

性能调优不应从“先改参数”开始,而应形成以下证据链:

业务现象
  ↓
时间线:请求延迟、吞吐、错误率
  ↓
GC 日志与 Safepoint
  ↓
JFR 事件
  ↓
线程转储 / 堆转储 / 类加载器统计
  ↓
定位对象、线程、类加载器或本地内存来源
  ↓
小范围改动并复测

1. 查看 JVM 和 GC 基本信息

jcmd <pid> VM.version
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> VM.command_line

这些命令的作用不同:

  • VM.version:确认实际 JVM 版本和供应商;
  • VM.flags:查看生效的 HotSpot 参数;
  • GC.heap_info:查看堆和收集器相关概要;
  • VM.command_line:确认启动命令行,避免只看部署脚本而忽略实际参数。

不要只根据配置文件推断生效参数,因为容器入口脚本、环境变量和 JVM ergonomics 可能改变最终配置。

2. 采集 JFR

一次短时诊断示例:

jcmd <pid> JFR.start \
  name=gc-diagnosis \
  settings=profile \
  duration=5m \
  filename=/tmp/gc-diagnosis.jfr

JFR 会记录 GC、Safepoint、线程、锁、分配和 CPU 等事件。profile 通常比 default 采集更丰富的数据,但具体事件和开销依赖 JDK 版本与配置。生产采集前应评估磁盘和开销。

用 JDK Mission Control(JMC)打开 .jfr 文件后,重点观察:

  • GC Pause 与应用延迟是否同一时间发生;
  • Allocation in new TLAB / outside TLAB;
  • Object Allocation Rate;
  • Safepoint 到达和停留时间;
  • CPU 热点是否在 GC 线程;
  • 锁竞争、线程阻塞和 I/O 是否才是长尾来源。

JFR 能建立时间关联,但不能替代堆转储中的对象保留关系。

3. 线程转储

jcmd <pid> Thread.print -l > /tmp/threads.txt

适合判断:

  • 大量线程是否阻塞在同一把锁;
  • 是否存在死循环或线程池耗尽;
  • GC 前后应用线程状态是否改变;
  • 是否有线程持有旧类加载器、ThreadLocal 或本地调用。

多次采集比单次更有价值。例如每隔 5 秒采集三次:

for i in 1 2 3; do
  jcmd <pid> Thread.print -l > "/tmp/thread-$i.txt"
  sleep 5
done

如果多个线程转储中同一批线程长期处于相同阻塞栈,才更支持“锁或下游依赖阻塞”的判断。

4. 堆转储和风险

jcmd <pid> GC.heap_dump /tmp/app.hprof

堆转储通常需要较大的磁盘空间,并可能造成明显停顿。不要在磁盘空间不足或延迟极敏感的实例上未经评估执行。

堆转储分析应回答:

  • 哪些对象数量最多;
  • 哪些对象保留大小(retained size)最大;
  • 从 GC Roots 到这些对象的路径是什么;
  • 是否由静态字段、线程、ThreadLocal、缓存或类加载器持有。

“对象数量最多”不一定等于“泄漏对象”。例如短命的字符串对象可能数量很多,但真正造成堆增长的可能是一个保留数 GB 的 HashMap

5. GC 日志示例

启动时记录:

-Xlog:gc*,safepoint:file=/var/log/app-gc.log:time,uptime,level,tags

一条日志可能包含:

Pause Young (Normal) ... 1024M->256M(4096M) 45.6ms

通常可以解释为:

  • GC 前堆占用约 1024 MB;
  • GC 后约 256 MB;
  • 当前堆容量约 4096 MB;
  • 该暂停耗时约 45.6 ms。

但具体日志格式和字段会随收集器与 JDK 版本变化,不能只用固定正则解析所有 JVM。

如果长期出现:

GC 前 3800 MB
GC 后 3600 MB
最大堆 4096 MB

说明回收后存活集合已经很大。增加 GC 频率无法消除这些存活对象,应分析其引用路径或重新评估堆容量。


十一、Safepoint 慢不一定是 GC 慢

JVM 需要在某些安全状态执行类重定义、偏差锁撤销、部分 GC 阶段和其他运行时操作。应用线程必须到达安全点,JVM 才能继续。

因此一次暂停可以拆成:

到达 Safepoint 的等待时间
+ JVM 在 Safepoint 内执行的时间
+ 恢复线程的时间

如果 GC 日志显示 GC 工作只用了 20 ms,但线程很久没有进入 Safepoint,问题可能来自:

  • 长时间运行的本地方法;
  • 某些循环中的可安全点插入不足;
  • CPU 饥饿;
  • 大量线程调度;
  • 运行时或 JIT 行为。

这时只换 G1 或 ZGC 并不一定有效。应结合 JFR Safepoint 事件、线程状态和 CPU 数据判断。


十二、常见错误调优方式及其反例

1. 看到 GC 频繁就直接增大堆

反例:

堆:4 GB
GC 后稳定在 3.8 GB

此时增加到 8 GB 可能只是让故障晚一些发生。如果 3.8 GB 是泄漏形成的活跃集合,扩大容量不改变根因。

合理顺序是:

  1. 判断 GC 后占用是否持续上升;
  2. 检查分配速率与对象存活率;
  3. 分析堆转储保留路径;
  4. 再决定修复引用、调整业务批量或增加堆。

2. 看到暂停超标就把 MaxGCPauseMillis 调得极小

例如把目标从 200 ms 改成 10 ms,并不意味着 GC 能在 10 ms 内完成所有复制工作。收集器可能因此减少单次回收工作,导致并发标记或回收周期更频繁,吞吐下降,甚至堆空间更容易耗尽。

暂停目标必须与:

  • 存活对象数量;
  • 分配速率;
  • CPU 核数;
  • 堆容量;
  • 业务延迟目标

一起验证。

3. 把 System.gc() 当作泄漏修复

System.gc() 只是请求 JVM 进行显式 GC 的提示,具体是否执行、何时执行以及暂停影响取决于 JVM 和参数。它不能让仍然可达的对象变成垃圾,也不能解决元空间类加载器泄漏。

若第三方库频繁调用显式 GC,可以在充分验证后评估:

-XX:+DisableExplicitGC

但这会改变程序对显式 GC 的行为,不能不经测试直接启用。某些资源释放逻辑错误地依赖显式 GC 时,禁用后可能暴露新的问题。

4. 只看 GC 时间,不看分配速率

假设两台机器的 GC 总时间都为每分钟 10 秒:

机器 A:每分钟运行 50 秒,GC 10 秒
机器 B:每分钟运行 59 秒,GC 10 秒

相同的 GC 时间占比可能隐藏完全不同的请求吞吐和 CPU 竞争。应同时计算:

GC Ratio=GC TimeObservation WindowGC\ Ratio = \frac{GC\ Time}{Observation\ Window}

并结合分配速率、CPU 使用率、请求吞吐和延迟分位数。


十三、一个可复现的调优实验

下面用一个简单程序制造高分配率:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

public class AllocationPressure {
    public static void main(String[] args) throws Exception {
        List<byte[]> retained = new ArrayList<>();

        for (int i = 0; i < 10_000_000; i++) {
            byte[] data = new byte[4096];

            // 少量对象被保留,模拟缓存或业务存活对象
            if (ThreadLocalRandom.current().nextInt(100) == 0) {
                retained.add(data);
            }

            if (i % 100_000 == 0) {
                Thread.sleep(1);
            }
        }

        System.out.println("retained=" + retained.size());
    }
}

使用 G1 运行:

javac AllocationPressure.java

java \
  -Xms512m -Xmx512m \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=50 \
  -Xlog:gc*,safepoint:gc-g1.log:time,uptime,level,tags \
  AllocationPressure

再使用 ZGC 运行:

java \
  -Xms512m -Xmx512m \
  -XX:+UseZGC \
  -XX:+ZGenerational \
  -Xlog:gc*,safepoint:gc-zgc.log:time,uptime,level,tags \
  AllocationPressure

每一步的意义是:

  1. 固定 -Xms-Xmx,减少堆动态变化这一变量。
  2. 保留约 1% 的数组,制造非零活跃集合。
  3. 记录 GC 和 Safepoint,避免只凭主观感受比较。
  4. 分别运行 G1 和 ZGC,比较暂停、CPU、吞吐和最终堆占用。
  5. 不把一次运行结果外推到所有业务,因为真实程序的对象大小、引用图、线程数和 CPU 竞争都不同。

预期观察不是“某个收集器必然更快”,而是:

  • G1 通常体现分 Region、年轻代和混合回收行为;
  • ZGC 通常体现更多并发回收阶段;
  • 两者都仍受固定堆容量和保留对象影响;
  • 如果程序 CPU 很紧张,ZGC 的并发线程可能改变吞吐;
  • 如果对象全部长期存活,任何收集器都无法通过 GC 降低活跃集合。

十四、生产配置的验证方式

1. 配置必须与容器限制对应

不要只写:

-Xmx8g

还要确认:

  • 容器 memory limit 是否足够容纳堆外内存;
  • 线程数和 -Xss 的乘积是否合理;
  • 直接内存是否存在上限;
  • 元空间是否可能无限增长;
  • Code Cache 和 JFR 等本地区域是否有余量。

例如容器限制为 10 GiB 时,-Xmx8g 不代表一定安全,因为剩余 2 GiB 还要覆盖元空间、线程栈、直接内存和 JVM 本地开销。

2. 变更前后必须保持可比

一次实验最好只改变一个主要变量:

同一版本应用
同一数据规模
同一机器规格
同一并发量
同一测试时长
同一日志采集方式
只改变收集器或一个关键参数

比较指标至少包括:

  • 吞吐;
  • P50、P95、P99、P99.9 延迟;
  • GC 暂停次数和总时间;
  • GC 后堆占用;
  • 分配速率;
  • CPU 使用率;
  • 进程 RSS;
  • Full GC 或退化路径;
  • 错误率和超时率。

3. 参数生效情况必须验证

jcmd <pid> VM.flags
jcmd <pid> GC.heap_info

如果参数来自环境变量或启动脚本,还要记录最终的:

jcmd <pid> VM.command_line

对于版本敏感参数,可以查看:

java -XX:+PrintFlagsFinal -version

但该输出反映当前 HotSpot 实现,不是 JVM 规范承诺的跨实现 API。


十五、把类加载问题与 GC 问题连起来看

类加载器是 GC Roots 分析中的重要边界。动态代理、反射和字节码生成如果不断产生类,问题路径通常是:

生成新类
  → 新类进入某个 ClassLoader
  → ClassLoader 被线程、静态字段或 ThreadLocal 持有
  → 类元数据无法卸载
  → Metaspace 增长
  → 元空间 GC 频繁或最终 OOME

这类问题不能通过观察 Java 堆中普通业务对象来完全解释。应同时检查:

  • 类加载器数量;
  • 每个加载器定义的类数量;
  • 线程上下文类加载器;
  • ThreadLocal 是否跨请求持有对象;
  • 动态代理缓存是否有边界;
  • 热部署后旧应用实例是否仍可达。

字节码验证、解析和初始化决定类是否能够进入可执行状态;而类卸载决定其元数据何时可能释放。类加载生命周期与 GC 生命周期因此是相互关联但不等同的两个过程。


十六、最终的判断框架

面对 JVM 内存或 GC 问题,可以按以下因果顺序判断:

  1. 先确认故障区域:堆、元空间、直接内存、线程栈,还是容器 RSS。
  2. 再确认时间关系:故障是否与 GC 暂停、Safepoint、类加载或分配峰值同时发生。
  3. 再确认存活关系:GC 后堆是否下降,哪些 GC Roots 保留对象。
  4. 再确认回收器阶段:G1 的 Young、Concurrent Mark、Mixed,或 ZGC 的并发标记、重定位和屏障处理。
  5. 最后才修改参数:每次修改都应有假设、指标、验证和回滚方案。

可以用一个最小模型概括:

内存压力=分配速率+活跃集合增长+回收并发成本+堆外资源使用\text{内存压力} = \text{分配速率} + \text{活跃集合增长} + \text{回收并发成本} + \text{堆外资源使用}

G1 通过 Region、记忆集、并发标记和混合回收,在吞吐与暂停目标之间折中;ZGC 通过屏障、染色指针、转发表和并发重定位,优先降低暂停长尾。它们都不能突破两个基本事实:

  • 仍然可达的对象不能被垃圾回收;
  • 进程使用的内存不等于 -Xmx

因此,可靠调优不是寻找一个“神奇参数”,而是把对象生命周期、类加载器生命周期、线程状态、GC 阶段和操作系统内存证据放到同一条时间线上,验证真正的因果关系。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。