引言

JVM 是 Java 生态的核心引擎,而内存管理与垃圾回收(Garbage Collection,GC)则是 JVM 中最精密、也最容易出问题的子系统。对于初学者,OutOfMemoryError 是常见的梦魇;对于高级开发者,GC 调优则是性能优化绕不开的话题。本文将系统性地拆解 JVM 内存模型、对象创建与布局、GC 算法原理、主流垃圾收集器以及实用调优思路。

JVM 运行时数据区

根据《Java 虚拟机规范》,JVM 将运行时内存划分为五个主要区域:

区域 线程共享 存储内容 异常
堆(Heap) 对象实例、数组 OutOfMemoryError
方法区(Method Area) 类信息、常量、静态变量 OutOfMemoryError
虚拟机栈(VM Stack) 栈帧(局部变量表、操作数栈等) StackOverflowError
本地方法栈(Native Stack) Native 方法调用 StackOverflowError
程序计数器(PC Register) 当前执行指令地址

堆(Heap)—— GC 的主战场

堆是 JVM 中最大的一块内存区域,几乎所有对象实例都在这里分配。从分代回收的视角看,堆分为:

  • 新生代(Young Generation):含一个 Eden 区和两个 Survivor 区(From/To),默认比例 8:1:1
  • 老年代(Old Generation):存放长期存活的对象和大对象

方法区与元空间

在 HotSpot 虚拟机中,JDK 7 及之前使用永久代(PermGen)实现方法区。JDK 8 彻底移除了永久代,改用元空间(Metaspace)。两者的核心区别在于:永久代使用 JVM 堆内存(受 -XX:MaxPermSize 限制),而元空间使用本地内存(Native Memory),默认无上限。

这一变化的直接好处是:再也不会遇到 java.lang.OutOfMemoryError: PermGen space,取而代之的可能是本地内存耗尽。

虚拟机栈

每个线程拥有独立的虚拟机栈。每次方法调用对应一个**栈帧(Stack Frame)**的压入,方法返回对应栈帧的弹出。栈帧中包含:

  • 局部变量表:基本类型和对象引用
  • 操作数栈:字节码指令执行时的操作数
  • 动态链接:指向运行时常量池中该方法的引用
  • 返回地址:方法返回后的执行位置

对象的创建过程与内存布局

当执行 new Object() 时,JVM 执行以下步骤:

  1. 类加载检查:检查对应类是否已加载、链接、初始化
  2. 分配内存:在堆上为新对象分配空间(指针碰撞 或 空闲列表)
  3. 内存零值初始化:将分配的内存空间全部置零
  4. 设置对象头:存储哈希码、GC 分代年龄、锁状态标志等
  5. 执行 <init>():调用构造函数

对象内存布局

一个 HotSpot 虚拟机中的对象在堆内存中的布局可分解为三个部分:

1
2
3
4
5
6
7
┌──────────────────────────────────────────┐
│ Object Header (Mark Word + Klass Pointer)│
├──────────────────────────────────────────┤
│ Instance Data (实例数据) │
├──────────────────────────────────────────┤
│ Padding (对齐填充) │
└──────────────────────────────────────────┘
  • Mark Word:存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态等),在 64 位 JVM 中占 8 字节
  • Klass Pointer:指向方法区中类元数据的指针。默认开启压缩指针时占 4 字节
  • 实例数据:各字段的实际内容
  • 对齐填充:HotSpot 要求对象起始地址为 8 字节的整数倍

垃圾回收算法

标记-清除(Mark-Sweep)

最基础的算法:先标记所有存活对象,再统一清除未标记对象。缺点是会产生大量内存碎片,且效率不高。

标记-复制(Mark-Copy)

将内存分为两块,每轮只使用一块。GC 时将存活对象复制到另一块,然后整体清除当前块。优点是分配效率高(指针碰撞),无碎片;缺点是内存利用率只有 50%。新生代的回收正是基于这种思想——Eden + Survivor 中,两个 Survivor 区总是有一个空闲。

标记-整理(Mark-Compact)

标记后不直接清除,而是将存活对象向一端移动,然后清理边界外的内存。适合老年代,因为老年代存活率高,复制成本太高。

分代收集理论

当代垃圾收集器大多基于以下经验假设:

  1. 弱分代假说:绝大多数对象朝生夕死
  2. 强分代假说:熬过越多次 GC 的对象越难消亡
  3. 跨代引用假说:跨代引用仅占极少数

根据这些假设,新生代使用标记-复制算法(快速回收大量短命对象),老年代使用标记-清除或标记-整理算法(减少复制开销)。

主流垃圾收集器

Serial / Serial Old

单线程收集器,工作时必须停顿所有用户线程(Stop-The-World,STW)。适合单核 CPU 和内存较小的客户端应用。在几十 MB 的堆内,它的 STW 时间通常不超过几十毫秒。

Parallel Scavenge / Parallel Old

多线程并行收集器,同样需要 STW,但利用多核并行缩短停顿时间。关注吞吐量(用户代码时间 / CPU 总时间),适合批处理、科学计算等场景。

CMS(Concurrent Mark Sweep)

CMS 是第一款实现并发收集(用户线程和 GC 线程同时工作)的收集器。它的工作流程分为:

  1. 初始标记(STW):标记 GC Roots 直接关联的对象
  2. 并发标记:与用户线程并发,从 GC Roots 遍历对象图
  3. 重新标记(STW):修正并发标记期间的变动
  4. 并发清除:与用户线程并发,清除未标记对象

CMS 的最大痛点是内存碎片(标记-清除算法)和浮动垃圾(并发清理期间产生的新垃圾只能留到下一次 GC)。JDK 9 起 CMS 被标记为废弃,JDK 14 彻底移除。

G1(Garbage First)

G1 是 JDK 9 以来的默认收集器,设计目标是在可控的停顿时间内完成 GC。它不再采用传统的连续分代布局,而是将堆划分为大小相等的 Region(默认约 2048 个)。

核心概念:

  • Mixed GC:不仅回收新生代 Region,也回收部分老年代 Region
  • Remembered Set(RSet):每个 Region 维护一个 RSet,记录哪些 Region 引用了本 Region 中的对象,避免全堆扫描
  • SATB(Snapshot-At-The-Beginning):保证并发标记的正确性

G1 的调优核心参数是 -XX:MaxGCPauseMillis。收集器会尽量将停顿时间控制在这个目标以下,代价是可能牺牲吞吐量。

ZGC

ZGC(JDK 11 实验性,JDK 15 生产就绪)是面向极低延迟的收集器,目标是将 STW 时间控制在 10ms 以内,且不随堆大小增长而增加(支持 TB 级别堆)。

核心技术:

  • 着色指针(Colored Pointers):在 64 位指针中嵌入元数据(finalizable、remapped、marked 等状态位)
  • 读屏障(Load Barrier):在读取引用时插入自愈逻辑,支持并发重映射
  • 动态 Region:与 G1 类似的分区结构,但支持 Region 的动态创建

Shenandoah

由 Red Hat 主导开发,与 ZGC 目标相似。独特之处在于使用**转发指针(Forwarding Pointer)**实现对象的并发移动,用户线程通过转发指针自动找到对象的新位置。

实用 GC 调优思路

收集器选择

场景 推荐收集器 关键参数
低延迟(Web 应用) G1 -XX:MaxGCPauseMillis=200
极低延迟(金融交易) ZGC / Shenandoah -XX:+UseZGC
高吞吐量(批处理) Parallel -XX:+UseParallelGC
小内存客户端 Serial -XX:+UseSerialGC

常用 JVM 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 堆大小配置
-Xms4g -Xmx4g # 堆初始大小和最大大小(设为相同避免动态调整)

# 新生代大小
-Xmn1g # 或 -XX:NewRatio=2(老年代:新生代 = 2:1)

# Metaspace 限制
-XX:MaxMetaspaceSize=256m

# GC 日志(Java 8)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

# GC 日志(Java 9+)
-Xlog:gc*:file=gc.log:time,level,tags

内存泄漏排查

内存泄漏的本质是”不需要的对象仍然被引用”。排查步骤:

  1. 观察现象jstat -gc <pid> 监控各区域使用率,老年代持续增长是典型信号
  2. 获取堆转储jmap -dump:format=b,file=heap.hprof <pid>-XX:+HeapDumpOnOutOfMemoryError
  3. 使用分析工具:MAT(Memory Analyzer Tool)、JProfiler、VisualVM 打开 dump 文件
  4. 分析支配树:定位占用内存最大的对象及其 GC Roots 路径
  5. 检查常见泄漏源:未关闭的流/连接、ThreadLocal 未清理、静态集合无限增长、内部类持有外部引用等

结语

JVM 的内存管理与 GC 是一个宏大而精深的领域。理解分代理论、熟悉主流收集器的适用场景、掌握基本的排查工具,是每个 Java 开发者从”会用”走向”精通”的必修课。最好的 GC 调优不是调出完美的参数组合,而是写出让 GC 省心的代码——合理控制对象生命周期,精确管理资源释放,从源头减少 GC 压力。