文章目录
- 前言
- [一、 垃圾收集算法全景图](#一、 垃圾收集算法全景图)
- [二、 标记-清除算法](#二、 标记-清除算法)
-
- [2.1 算法原理](#2.1 算法原理)
- [2.2 算法流程图](#2.2 算法流程图)
- [2.3 优点分析](#2.3 优点分析)
- [2.4 缺点剖析------内存碎片](#2.4 缺点剖析——内存碎片)
- [2.5 应用场景](#2.5 应用场景)
- [三、 标记-复制算法](#三、 标记-复制算法)
-
- [3.1 算法原理](#3.1 算法原理)
- [3.2 算法流程图](#3.2 算法流程图)
- [3.3 优点分析](#3.3 优点分析)
- [3.4 缺点剖析------内存利用率低](#3.4 缺点剖析——内存利用率低)
- [3.5 应用场景](#3.5 应用场景)
- [四、 标记-整理算法](#四、 标记-整理算法)
-
- [4.1 算法原理](#4.1 算法原理)
- [4.2 算法流程图](#4.2 算法流程图)
- [4.3 优点分析](#4.3 优点分析)
- [4.4 缺点剖析------移动成本高](#4.4 缺点剖析——移动成本高)
- [4.5 应用场景](#4.5 应用场景)
- [五、 分代收集算法](#五、 分代收集算法)
-
- [5.1 算法原理](#5.1 算法原理)
- [5.2 分代协作流程图](#5.2 分代协作流程图)
- [5.3 为什么新生代用复制算法?](#5.3 为什么新生代用复制算法?)
- [5.4 为什么老年代用标记-整理/清除?](#5.4 为什么老年代用标记-整理/清除?)
- [六、 深入思考:现代GC的算法演进](#六、 深入思考:现代GC的算法演进)
-
- [6.1 G1------Region化分代](#6.1 G1——Region化分代)
- [6.2 ZGC------染色指针与并发整理](#6.2 ZGC——染色指针与并发整理)
- [6.3 算法的核心矛盾从未改变](#6.3 算法的核心矛盾从未改变)
- [七、 总结](#七、 总结)
前言
作为一名拥有Java后端工程师,我们对JVM的垃圾收集(Garbage Collection, GC)并不陌生。然而,面对频繁的Minor GC、偶发的Full GC,以及层出不穷的GC调优参数,我们是否真正理解其背后的算法基石?
垃圾收集算法是JVM内存管理的核心。从最初的标记-清除,到标记-复制,再到标记-整理,直至今天普遍采用的分代收集,每一次算法的演进都是为了解决一个核心矛盾:如何在保证吞吐量的同时,最小化应用暂停时间(Stop-The-World)。
本文将摒弃晦涩的源码,从算法原理出发,深入剖析每种算法的优缺点、适用场景,并结合新生代与老年代的不同特点,解释为什么现代GC采用混合策略。
一、 垃圾收集算法全景图
在深入具体算法之前,我们需要明确一个前提:所有垃圾收集算法都遵循"标记-回收"的基本框架。标记阶段用于识别哪些对象是存活的,回收阶段则负责释放已死对象占用的内存。不同的算法,区别在于回收阶段的实现方式。
| 算法 | 核心原理 | 优点 | 缺点 | 经典应用 |
|---|---|---|---|---|
| 标记-清除 | 标记可回收对象,统一清除 | 实现简单,不需要额外空间 | 产生内存碎片,分配效率下降 | CMS收集器的并发标记阶段 |
| 标记-复制 | 内存平分为两块,只使用一块,存活对象复制到另一块 | 无碎片,分配效率高 | 内存利用率低(≤50%) | 新生代(Serial、ParNew、Parallel Scavenge) |
| 标记-整理 | 标记存活对象,向一端移动,清理边界外内存 | 无碎片,空间利用率高 | 移动对象成本高,需要暂停应用 | 老年代(Serial Old、Parallel Old) |
| 分代收集 | 新生代用复制算法,老年代用标记-整理/清除 | 综合各算法优势,兼顾吞吐与延迟 | 实现复杂,需要跨代引用处理 | 所有主流GC(G1、ZGC、 Shenandoah等) |
二、 标记-清除算法
2.1 算法原理
标记-清除算法是最基础的垃圾收集算法,分为两个阶段:
- 标记阶段:从GC Roots出发,遍历所有可达对象,并标记为存活。
- 清除阶段:遍历堆内存,回收所有未被标记的对象,释放其占用的空间。
2.2 算法流程图
是
否
否
是
否
是
开始GC
暂停所有应用线程
Stop-The-World
标记阶段
从GC Roots遍历对象图
对象是否可达?
标记对象为存活
对象标记为可回收
继续遍历引用链
所有对象遍历完成?
清除阶段
遍历堆内存
对象是否被标记?
回收内存空间
保留对象,清除标记
产生内存碎片
恢复应用线程
GC结束
2.3 优点分析
- 实现简单:逻辑直观,不需要额外的内存空间(如复制算法需要预留一块区域)。
- 兼容性好:适用于对象存活率高的场景,不需要移动对象,对象引用不变。
2.4 缺点剖析------内存碎片
标记-清除最大的问题是内存碎片。清除阶段只是将空闲内存记录在空闲列表中,并不做整理。随着GC的频繁发生,内存中会散布大量不连续的空闲区域。
碎片的危害:
- 分配效率下降:当需要分配一个大对象时,即使总空闲内存足够,也可能因为找不到连续空间而提前触发下一次GC。
- 加速GC频率:碎片化严重时,JVM被迫更频繁地进行GC来腾出连续空间。
2.5 应用场景
标记-清除算法在现代GC中很少单独使用,但它的思想被广泛借鉴。例如,CMS(Concurrent Mark Sweep)收集器的核心就是并发标记-清除,同时通过空闲列表来管理内存分配。但由于CMS存在碎片问题,最终在JDK9中被标记为废弃。
三、 标记-复制算法
3.1 算法原理
标记-复制算法将内存划分为两个等大的区域(称为From区和To区),每次只使用其中一个区域。当GC发生时:
- 暂停应用线程。
- 标记From区中的存活对象。
- 将存活对象按顺序复制到To区,并紧凑排列。
- 清空From区。
- 交换From区和To区的角色,保证下一次GC时使用的是新的区域。
3.2 算法流程图
开始GC
暂停应用线程
Stop-The-World
标记阶段
扫描From区存活对象
复制阶段
将存活对象按顺序复制到To区
对象在To区紧凑排列
无碎片
清空From区
交换From和To角色
恢复应用线程
GC结束
3.3 优点分析
- 无内存碎片:复制过程中实现了内存的"压缩",分配新对象时只需要使用指针碰撞技术,分配效率极高。
- 回收效率高:当存活对象比例很低时,复制成本很低,只需要复制少量对象即可清空整个区域。
3.4 缺点剖析------内存利用率低
标记-复制算法的致命缺陷是内存利用率不超过50%。因为总有一半的内存空间处于闲置状态,这在内存敏感的系统中是难以接受的。
HotSpot的优化:为了降低内存浪费,HotSpot虚拟机在新生代中没有采用1:1的划分,而是设计了Eden区 + 两个Survivor区(S0和S1) 的结构,默认比例是8:1:1。
- 实际利用率:新生代可用内存为 Eden + 1个Survivor,总利用率为 90%(8/10 + 1/10)。
- 担保机制:如果Minor GC后存活对象超过Survivor区大小,则直接晋升到老年代,由老年代兜底。
3.5 应用场景
标记-复制算法是新生代垃圾收集的标配。Serial、ParNew、Parallel Scavenge等新生代收集器都基于此算法。新生代对象"朝生夕死"的特点(存活率通常低于10%)使得复制算法的优势得到最大化发挥。
四、 标记-整理算法
4.1 算法原理
标记-整理算法是标记-清除算法的改进版,它在标记阶段后,不直接清除,而是将存活对象向一端移动,形成一个紧凑的内存块,然后清理边界以外的内存。
4.2 算法流程图
开始GC
暂停所有应用线程
Stop-The-World
标记阶段
从GC Roots遍历对象图
标记所有存活对象
整理阶段
将存活对象向一端移动
对象移动过程中
更新引用地址
存活对象紧凑排列
无碎片
清理边界以外的内存
恢复应用线程
GC结束
4.3 优点分析
- 无内存碎片:整理后内存连续,分配新对象时可以使用指针碰撞,分配效率高。
- 空间利用率高:不需要像复制算法那样预留空闲区域,整个内存区域都可以用于对象存储。
4.4 缺点剖析------移动成本高
标记-整理的代价在于移动对象。移动存活对象不仅需要复制数据,还需要更新所有引用这些对象的指针。对于老年代这样对象存活率高的区域,移动成本非常可观。
性能权衡:标记-清除虽然会产生碎片,但不需要移动对象;标记-整理解决了碎片问题,但增加了移动成本。现代GC需要在两者之间寻找平衡。
4.5 应用场景
标记-整理算法主要用于老年代,例如Serial Old和Parallel Old收集器。老年代对象存活率高,不适合复制算法;而标记-清除产生的碎片问题在老年代尤为致命(老年代分配的大对象多,对连续空间要求高),因此标记-整理成为更优选择。
五、 分代收集算法
5.1 算法原理
分代收集不是一种独立的算法,而是一种策略组合。它基于一个观察事实:不同对象的生命周期差异显著。
- 新生代(Young Generation):对象存活率低,大部分对象朝生夕死。适用标记-复制算法,回收效率高。
- 老年代(Old Generation):对象存活率高,且多为大对象。适用标记-清除或标记-整理算法,兼顾碎片与效率。
5.2 分代协作流程图
新生代
是
是
否
大对象
是
是
否
对象分配
分配在哪个区域?
Eden区分配
Eden区满?
触发Minor GC
标记-复制算法
存活对象复制到Survivor区
对象年龄达到阈值?
晋升到老年代
继续在Survivor区
直接进入老年代
老年代空间不足?
触发Full GC
老年代采用标记-整理/清除
GC后空间足够?
分配成功
OutOfMemoryError
5.3 为什么新生代用复制算法?
新生代采用复制算法,是基于以下三点考量:
| 考量维度 | 分析 |
|---|---|
| 对象存活率低 | 新生代对象平均存活率通常低于10%,复制算法只需要复制这10%的存活对象,效率极高。 |
| 分配效率高 | 复制算法配合TLAB(线程本地分配缓冲区)可以实现无锁的指针碰撞分配,速度极快。 |
| 空间利用率可优化 | 通过8:1:1的Eden:Survivor比例,将内存浪费控制在10%以内,远低于理论上的50%。 |
5.4 为什么老年代用标记-整理/清除?
老年代采用标记-整理或标记-清除,原因如下:
| 考量维度 | 分析 |
|---|---|
| 对象存活率高 | 老年代对象存活率通常超过90%,如果使用复制算法,需要复制大量对象,成本过高。 |
| 碎片问题敏感 | 老年代承载着大对象(如缓存、数组),对内存连续性要求高。标记-清除的碎片可能导致大对象分配失败,因此多数情况下更倾向于标记-整理。 |
| GC频率低 | 老年代GC频率远低于新生代,即使标记-整理需要移动对象,其整体开销仍在可接受范围内。 |
六、 深入思考:现代GC的算法演进
分代收集虽然在JDK8及之前的版本中占据统治地位,但随着G1(Garbage First)、ZGC、Shenandoah等现代GC的兴起,垃圾收集算法也在不断演进。
6.1 G1------Region化分代
G1不再将内存物理划分为新生代和老年代,而是划分为若干个等大的Region。每个Region可以扮演Eden、Survivor、Old或Humongous(大对象)的角色。在回收时,G1优先回收垃圾最多的Region(Garbage First的由来),采用标记-复制算法,既避免了碎片,又实现了可控的暂停时间。
6.2 ZGC------染色指针与并发整理
ZGC引入了染色指针技术,实现了并发标记-整理。在整理阶段,ZGC通过指针的额外位信息,实现了对象的并发移动,使得停顿时间不再随堆大小增长而增长(控制在10ms以内)。
6.3 算法的核心矛盾从未改变
尽管现代GC的实现越来越复杂,但垃圾收集算法的核心矛盾始终未变:吞吐量与延迟的权衡,空间利用率与分配效率的平衡。理解标记-清除、标记-复制、标记-整理这三类基础算法,是理解任何复杂GC实现的基础。
七、 总结
| 算法 | 核心优势 | 核心劣势 | 最佳适用场景 |
|---|---|---|---|
| 标记-清除 | 实现简单,不移动对象 | 产生内存碎片 | 对象存活率适中、对碎片不敏感的场景(如CMS的并发阶段) |
| 标记-复制 | 无碎片,分配效率极高 | 内存利用率低(≤50%) | 对象存活率低、分配频繁的场景(新生代) |
| 标记-整理 | 无碎片,空间利用率高 | 移动对象成本高 | 对象存活率高、对连续空间敏感的场景(老年代) |
| 分代收集 | 综合优势,兼顾效率与空间 | 实现复杂,需要跨代引用处理 | 通用场景,所有主流GC的基石 |
垃圾收集算法的演进史,本质上是一部对内存管理效率的极致追求史。从简单粗暴的标记-清除,到为新生代量身定制的复制算法,再到为解决老年代碎片问题而生的标记-整理,直至今天融合了多种策略的分代收集和Region化GC,每一步都在解决特定的痛点。