深入理解 Java GC:核心算法与常见收集器
前言
Java 开发者通常不需要像 C/C++ 开发者那样手动管理内存分配和释放,这得益于 Java 虚拟机(JVM)强大的**垃圾回收(Garbage Collection, GC)**机制。GC 自动地查找并回收不再被程序使用的内存(即“垃圾”),从而避免了内存泄漏和野指针等问题。
然而,GC 并非没有代价。不合适的 GC 配置或算法选择可能导致应用暂停(Stop-the-World, STW),影响性能和用户体验。因此,理解 GC 的基本原理和常用算法对于 Java 性能调优至关重要。
GC 的核心任务:识别垃圾
GC 的首要任务是找出哪些内存可以被回收。现代 JVM 主要采用**可达性分析(Reachability Analysis)**算法来判断对象是否存活:
- GC Roots: 首先确定一系列必须存活的“根”对象(GC Roots)。常见的 GC Roots 包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即 Native 方法)引用的对象。
- 活动线程。
- 同步锁(
synchronized
关键字)持有的对象。
- 可达性分析: 从 GC Roots 开始,沿着引用链向下搜索。如果一个对象可以通过任何引用链从 GC Roots 到达,则称该对象是可达的 (Reachable),意味着它正在被使用,不能回收。
- 不可达对象: 如果一个对象无法从任何 GC Roots 通过引用链到达,则认为该对象是不可达的 (Unreachable),即为“垃圾”,可以被回收。
注意: 即使是不可达对象,也至少要经历两次标记过程才真正被回收(涉及 finalize()
方法,但此方法已不推荐使用)。
基本 GC 算法
基于可达性分析找到垃圾后,就需要具体的算法来回收这些空间。以下是几种基础的 GC 算法思想,它们是现代复杂 GC 收集器的基石:
1. 标记-清除 (Mark-Sweep)
这是最基础的 GC 算法,分为两个阶段:
- 标记 (Mark): 从 GC Roots 开始遍历,标记所有可达的对象。
- 清除 (Sweep): 遍历整个堆内存,清除所有未被标记的对象(垃圾),回收它们占用的空间。
优点:
- 实现简单。
- 不需要移动对象。
缺点:
- 效率问题: 标记和清除两个过程的效率都不算高,尤其是在对象数量庞大时。
- 空间碎片: 清除后会产生大量不连续的内存碎片。碎片过多可能导致后续无法为较大的新对象分配足够的连续空间,从而提前触发下一次 GC。
2. 复制 (Copying / Mark-Copy)
为了解决标记-清除算法的碎片问题,复制算法应运而生。它将可用内存按容量划分为大小相等的两块(如 From 空间和 To 空间),每次只使用其中一块。
- 过程: 当进行 GC 时,将正在使用的那块内存(From 空间)中所有存活的对象复制到另一块未使用的内存(To 空间)中,并按顺序紧密排列。然后,一次性清空整个 From 空间。之后,From 空间和 To 空间的角色互换。
优点:
- 无碎片: 回收后内存是连续的,分配效率高。
- 实现简单: 只需移动存活对象,然后清空原区域。
缺点:
- 空间浪费: 可用内存缩小为原来的一半,代价较高。
- 复制开销: 如果存活对象很多,复制操作的开销会很大。
3. 标记-整理 (Mark-Compact / Mark-Sweep-Compact)
该算法结合了标记-清除和复制算法的优点。它也分为两个(或三个)阶段:
- 标记 (Mark): 同标记-清除算法,标记所有存活对象。
- 整理 (Compact): 将所有存活的对象向内存空间的一端移动,并按顺序排列。
- 清除 (Sweep/Implicit): 清理掉边界以外的内存区域。
优点:
- 无碎片: 解决了标记-清除的碎片问题。
- 无空间浪费: 不像复制算法那样需要牺牲一半空间。
缺点:
- 效率问题: 标记和移动对象都需要时间,尤其移动对象的成本较高,效率低于复制算法(在存活对象少时)和标记-清除算法(在不考虑碎片时)。
- 需要暂停: 移动对象时通常需要暂停用户线程(Stop-the-World)。
分代收集 (Generational Collection) - 主流策略
现代商用 JVM 大多采用分代收集策略,这并非一种具体的算法,而是一种基于对象生命周期特点的内存管理策略。它基于一个重要的观察(弱分代假说):
- 绝大多数对象都是“朝生夕死”的。
- 熬过越多次 GC 的对象就越难以死亡。
基于此,JVM 将堆内存划分为不同的区域(代):
- 新生代 (Young Generation):
- 存放新创建的对象。绝大多数对象在此区域产生并消亡。
- 内部通常又细分为一个 Eden 区 和两个 Survivor 区(From 和 To,也称 S0 和 S1)。
- Minor GC / Young GC: 发生在新生代的 GC,非常频繁,回收速度快。通常采用复制算法,因为新生代对象存活率低,复制成本小,效率高。
- 新对象在 Eden 区分配。
- 当 Eden 区满时触发 Minor GC。
- 将 Eden 区和 From Survivor 区中存活的对象复制到 To Survivor 区。
- 清空 Eden 和 From 区。
- 交换 From 和 To 区的角色。
- 对象每经历一次 Minor GC 且存活下来,其年龄计数器会加 1。
- 老年代 (Old Generation / Tenured Generation):
- 存放生命周期较长的对象,或者在新生代中经历多次 Minor GC 仍然存活的对象(达到一定年龄阈值),或者大对象(超过特定大小直接在老年代分配)。
- Major GC / Old GC: 发生在老年代的 GC。通常比 Minor GC 慢得多(可能慢 10 倍以上)。经常伴随 Minor GC 一起发生(有时称为 Full GC,但定义不完全统一)。
- 老年代对象存活率高,不适合复制算法。通常采用标记-清除或标记-整理(及其变种)算法。
- (JDK 8 之前) 永久代 (Permanent Generation) / (JDK 8 及之后) 元空间 (Metaspace):
- 用于存储类的元数据、常量池等信息。这部分区域的 GC 通常与老年代 GC 绑定发生。元空间使用本地内存(Native Memory),不再受 JVM 堆大小限制(但受物理内存限制)。
分代收集的好处: 可以根据不同代的特点选用最合适的 GC 算法,将 GC 对应用程序的影响降至最低。
常见的 JVM 垃圾收集器
JVM 提供了多种垃圾收集器,它们是上述基础算法和分代策略的具体实现,各有侧重(吞吐量优先 vs. 停顿时间优先):
1. Serial GC (-XX:+UseSerialGC
)
- 特点: 单线程执行 GC,新生代使用复制算法,老年代使用标记-整理算法。GC 时必须暂停所有用户线程(STW)。
- 场景: 适用于客户端模式(Client VM)、单核 CPU 或内存较小的环境。简单高效(在单核下),但停顿时间可能较长。
2. Parallel GC / Throughput Collector (-XX:+UseParallelGC
)
- 特点: Serial GC 的多线程版本。新生代使用并行复制算法 (
Parallel Scavenge
),老年代默认使用并行标记-整理算法 (Parallel OldGC
,通过-XX:+UseParallelOldGC
开启,JDK 8 默认开启)。GC 时也需要 STW,但并行执行能缩短总的停顿时间,提高吞吐量(用户代码运行时间 / (用户代码运行时间 + GC 时间))。 - 场景: JDK 8 的默认 GC。适用于后台计算、数据处理等对吞吐量要求高、对单次停顿时间不太敏感的服务端应用。
3. CMS (Concurrent Mark Sweep) GC (-XX:+UseConcMarkSweepGC
)
- 特点: 以获取最短回收停顿时间为目标。主要用于老年代。基于标记-清除算法实现。其核心在于并发标记和并发清除阶段,可以与用户线程一起工作,大大减少了 STW 时间。但仍有短暂的初始标记和重新标记阶段需要 STW。
- 缺点:
- CPU 敏感: 并发阶段会占用 CPU 资源,可能影响用户线程性能。
- 浮动垃圾 (Floating Garbage): 并发清除阶段用户线程可能产生新的垃圾,无法本次回收,需等待下次 GC。
- 空间碎片: 基于标记-清除,会产生内存碎片,可能导致无法分配大对象而提前触发 Full GC(使用标记-整理进行)。
- 场景: 适用于对响应时间有较高要求的互联网应用或 B/S 系统服务(如 Web 服务器)。在 JDK 9 中被标记为废弃,JDK 14 中被移除。
4. G1 (Garbage-First) GC (-XX:+UseG1GC
)
- 特点: 面向服务端应用,旨在取代 CMS。开创了基于区域 (Region) 的内存布局和可预测停顿时间模型。
- 将整个 Java 堆划分为多个大小相等的独立区域 (Region),每个 Region 可以扮演 Eden、Survivor 或 Old 的角色。
- 跟踪每个 Region 的回收价值(回收可获得的空间大小和预估耗时)。
- 在后台维护一个优先列表,优先回收价值最大的 Region(“Garbage-First” 的由来)。
- 通过
-XX:MaxGCPauseMillis
设定目标最大停顿时间(尽力满足,非硬性保证)。 - 整体上采用标记-整理(回收时将存活对象复制到空闲 Region),局部(Region 之间)看是复制算法。不会产生内存碎片。
- 场景: JDK 9 及之后版本的默认 GC。适用于大内存(>4GB)、要求低延迟和高吞吐量的应用。
5. ZGC (-XX:+UseZGC
, JDK 11 引入, JDK 15 正式可用)
- 特点: 低延迟垃圾收集器,目标是将 GC 停顿时间控制在 10ms 以内(甚至 1ms)。使用读屏障 (Read Barrier)、染色指针 (Colored Pointers) 和负载屏障 (Load Barrier) 等技术,使得几乎所有的 GC 工作(标记、转移、重定位)都能并发执行,STW 时间极短且不随堆大小或存活对象数量增加而增加。基于 Region,采用标记-整理。
- 场景: 需要超低延迟、大内存(TB 级别)的应用。
6. Shenandoah GC (-XX:+UseShenandoahGC
, OpenJDK 特有, 非 Oracle JDK 标准)
- 特点: 类似于 ZGC,也是一款低延迟 GC,目标是与应用线程并发执行 GC,减少停顿时间。与 G1 的主要区别在于其并发整理阶段使用连接矩阵 (Brooks Pointers) 等技术来处理对象移动过程中的引用更新,不需要 G1 的 Remembered Sets。
- 场景: 对低延迟有极高要求的应用,作为 ZGC 的替代或补充选择(尤其是在非 Oracle JDK 环境中)。
如何选择 GC?
- JDK 默认值: 通常是经过广泛测试的良好起点(JDK 8 是 Parallel GC,JDK 9+ 是 G1 GC)。
- 应用场景:
- 客户端/内存小/单核: Serial GC。
- 高吞吐量/后台计算: Parallel GC。
- 低延迟/大内存/Web 服务: G1 GC (首选)、ZGC、Shenandoah。
- 性能测试: 没有银弹。最好的方式是在接近生产的环境下,针对具体应用进行测试和调优,监控 GC 日志,观察吞吐量、延迟、CPU 和内存使用情况。
总结
Java GC 通过自动化内存管理极大地简化了开发。理解其核心原理(可达性分析)、基础算法(标记-清除、复制、标记-整理)、主流策略(分代收集)以及各种收集器的特点和适用场景,是进行 JVM 性能分析和调优的基础。随着硬件发展和应用需求变化,GC 技术也在不断演进,向着更高吞吐、更低延迟的目标迈进。