Java语言与虚拟机:GC算法与垃圾回收器
先聊点实际的。不管是校招刚入门还是大厂面试已经背到烂熟,Java开发者总会撞上一个绕不开的名词——GC,也就是垃圾回收。工作几年后你可能会发现,线上服务频繁Full GC、接口突然卡顿几十秒、内存监控曲线像过山车,这些问题排查到最后,几乎都会落到对JVM内存管理和垃圾回收器的理解上。GC不是某个工具类的功能,也不是什么锦上添花的优化技巧,它直接决定了你的Java程序能够跑多快、能扛多大压力、遇到内存瓶颈时能不能快速定位问题。
这篇文章我会从GC要解决的根问题讲起,逐层拆解三种经典垃圾回收算法的原理和取舍,再结合主流垃圾回收器的实际选型和调优参数,最后给出生产环境下的排查思路。无论你是准备面试、写业务代码时被OOM折磨过,还是纯粹想搞懂JVM内部在干什么,这篇文章都能帮你把GC这条线彻底打通。
1. 内容整体设计与思路拆解
1.1 为什么先搞懂GC,再谈JVM优化
很多初学者对JVM的认知是从“启动参数”开始的,比如-Xmx、-Xms,调完发现内存还是不释放,就开始怀疑参数没生效。其实问题的根源在于:GC管理的内存和操作系统层面的内存不是一回事,JVM内部的堆内存回收有自己的节奏和规则。不懂GC的工作机制,调参基本靠猜,出了问题也只能重启。
我们得先回到最底层的问题上:Java程序运行时会创建大量对象,这些对象在堆内存中分配空间,但Java不像C/C++那样需要开发者手动释放内存。JVM里的垃圾回收器负责自动识别“已经没人使用的对象”并回收其占用的内存。这个过程如果设计得好,程序和内存都能保持在健康状态;设计不好,就会频繁触发回收,甚至长期占用CPU。
所以整张知识地图应该是这样的:先理解对象在堆里的生命周期,再搞清楚垃圾回收器靠什么判断“垃圾”,然后顺着算法的演进逻辑看懂各代回收器的设计差异,最后落到实际调优与排障上。这也是我写这篇文章的结构安排。
1.2 从对象生命周期看GC解决的核心矛盾
先说一个很容易混淆的概念:GC并不等于“清理内存”。它真正要做的事情有两件,一是找出已经没有引用指向的对象,二是回收它们占用的堆空间,同时尽量不影响正在运行的程序。
这背后有一个核心矛盾:吞吐量和响应时间的矛盾。如果要快速回收大量内存,就需要长时间暂停业务线程,这在批处理场景下可以接受,但在高并发的在线服务里就是灾难。如果为了避免长时间停顿而把回收拆得很碎,又会引入频繁的上下文切换和额外的记录开销。不同垃圾回收器本质上就是在吞吐量、低延迟、内存占用这三者之间做取舍。
还有一个让新手头疼的点:JVM把堆内存分成年轻代和老年代,并不是随手设计的。统计表明,绝大多数Java对象“朝生夕灭”,存活时间极短。分代设计让回收器可以针对不同区域使用不同算法:年轻代复制回收,老年代标记整理,这样整体效率远高于每次都对整堆做全量扫描。
2. 核心细节解析:三大GC算法的原理与取舍
2.1 标记-清除算法:最朴素的思路,但藏着一个大坑
标记-清除(Mark-Sweep)是最基础的垃圾回收算法,思路非常直白,分两步走:
- 标记阶段:从GC Roots出发,把所有被引用的对象打上标记。
- 清除阶段:遍历堆内存,把没有标记的对象所占空间回收。
乍一看没什么问题,但它有两个明显的毛病。
第一个毛病就是内存碎片化。回收掉的对象在堆上留下很多不连续的空洞,后续如果有一个比较大的对象要分配,即使总空闲空间足够,也可能因为找不到连续区域而再次触发Full GC。这就像停车场里到处是零散的空位,但一辆大巴进来却停不进去,只能等小车挪走更多位置。
第二个毛病是效率问题。如果堆中有大量存活对象,标记阶段需要遍历整个引用链,清除阶段又要扫一遍全部内存,这两个阶段都会消耗可观的CPU时间。
在实际应用中,纯粹的标记-清除算法很少被直接使用。但它是理解后续一切算法的基础,垃圾回收器的很多设计,本质上都是在解决标记-清除暴露出的这两个问题。比如下面的标记-复制算法,基本就是冲着“碎片化”去的。
2.2 标记-复制算法:用空间换时间的经典方案
标记-复制(Copying)算法的思路很有趣:把一块内存分成大小相等的两块,每次只使用其中一块。回收时,把存活对象复制到另一块空的内存区域,然后把原来那块内存整体清空。
这个算法的优点非常突出:复制后内存排列是紧凑的,不会产生碎片,而且分配内存时只需要移动一个指针就行,非常高效。同时,因为只处理存活对象,当存活率很低时(比如年轻代),它的效率要远高于标记-清除。
但它也有明显的代价——可用内存减半。如果存活率较高,复制操作会变得非常频繁,反而拖慢性能。所以这个算法不适合老年代这种存活对象多的场景,却天然契合年轻代的特点。
JVM的年轻代回收就采用了复制算法,但不是简单的1:1等分,而是把内存划分为一个较大的Eden区和两个较小的Survivor区,比例通常是8:1:1。这样设计的精妙之处在于,每次回收只有少量存活对象需要复制到Survivor,不需要牺牲一半内存,同时还能保证内存的紧凑性。
2.3 标记-整理算法:让老年代不再碎片化的折中之道
老年代里存活对象多、生命周期长,直接用复制算法显然不行,因为复制的成本和空间浪费都难以承受。于是有了标记-整理(Mark-Compact)算法。
流程也很清楚:标记阶段和标记-清除一样,从GC Roots标记所有存活对象,但清除阶段不一样。它不像标记-清除那样直接清掉垃圾对象,而是把所有存活对象向内存的一端移动,然后清理掉边界以外的所有空间。
这就像是整理一间乱糟糟的屋子,把有用的东西归拢到墙边,剩下的杂物集中倒掉,空间一下子就清爽了。整理后内存是连续的,分配新对象时就不需要为了找连续空间而发愁。
缺点是移动对象需要更新所有引用,这个过程要遍历整个堆,所以停顿时间通常比标记-清除更长。但考虑到老年代对象变动频率低,这个代价换来的内存连续性,长期来看是划算的。
2.4 三种算法对比:什么时候选谁
用一张表可以比较直观地看到它们的定位差异:
| 算法 | 内存碎片 | 内存利用率 | 适用场景 | 核心成本 |
|---|---|---|---|---|
| 标记-清除 | 严重 | 高 | 老年代,CMS曾使用 | 碎片问题 |
| 标记-复制 | 无 | 低(浪费空间) | 年轻代 | 复制开销 |
| 标记-整理 | 无 | 高 | 老年代 | 移动对象与更新引用 |
理解了这张表,再看垃圾回收器,你会发现它们其实是组合拳。年轻代天生适合复制,老年代更适合标记-整理,但不同的垃圾回收器在处理边界情况时有不同的策略,这也带来了不同的性能表现和停顿特征。
3. 分代模型与根节点枚举:避免误判“垃圾”的关键
3.1 分代收集的具体划分与对象晋升机制
JVM把堆分成年轻代(Young Generation)和老年代(Old Generation),年轻代内部又分成Eden区和两个Survivor区。新创建的对象几乎都在Eden区分配,Eden区满了会触发Minor GC,过程是这样的:
- 新对象优先在Eden区分配,Survivor区只存放经历过数次Minor GC后仍然存活的对象。
- Minor GC触发时,把Eden区和其中一个Survivor区的存活对象复制到另一个空闲的Survivor区。
- 每经历一次Minor GC,存活对象的年龄加1。当年龄超过阈值(默认15,可通过
-XX:MaxTenuringThreshold调整),对象会晋升到老年代。
这里有一个非常值得注意的细节:大对象直接进入老年代。如果一个对象特别大,比如一个超大的数组,放到Eden区会导致Eden区的复制逻辑复杂化,而且大对象在Survivor区之间来回复制代价很大。JVM提供了-XX:PretenureSizeThreshold参数,超过阈值的大对象直接进入老年代,避免反复复制。
另一个晋升机制是动态年龄判定。如果Survivor区中相同年龄的所有对象大小总和超过Survivor空间的一半,年龄大于或等于该年龄的对象就直接晋升到老年代,不用等达到阈值。这个机制设计得很聪明,防止Survivor区被长期存活对象慢慢塞满。
3.2 GC Roots的枚举与安全点
判断对象是否存活,不是逐个遍历对象看有没有引用它,而是从一组根对象出发,遍历整个引用链。能作为GC Roots的有:
- 虚拟机栈中局部变量表引用的对象。
- 方法区中类的静态属性引用的对象、常量引用的对象。
- 本地方法栈中JNI引用的对象。
- Java虚拟机内部的引用,比如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器等。
- 被同步锁(synchronized)持有的对象。
根节点枚举必须在一个“快照”状态下完成,否则一边遍历一边有新的引用产生,结果就不准确。所以在GC开始时,JVM会暂停所有业务线程(也就是STW,Stop The World),进入一个一致性的状态。即使是最追求低延迟的垃圾回收器,根节点枚举这一小段停顿也是无法完全避免的。
这里还有一个关键概念叫安全点。JVM不会在任何指令位置都能暂停线程,而是在特定的指令位置(比如循环跳转、方法返回、异常跳转)设置安全点。线程运行到安全点时,才能停下来进入GC状态。这也是为什么GC日志里有时会看到线程等待进入安全点的时间,如果业务线程长时间运行到不了安全点,GC就只能一直等着,造成额外的停顿。
3.3 三色标记法与并发漏标问题
现代主流垃圾回收器,比如CMS和G1,都在尝试缩短STW时间,做法是把标记阶段的一部分工作放到业务线程并发执行。这就引出了一个更细致的问题:并发标记时,引用关系在不断变化,怎么避免把仍被引用的对象误判为垃圾?
这里就要提三色标记法了。它把对象分成三种颜色:
- 白色:尚未被访问过的对象,标记结束后仍然为白色的对象会被判定为垃圾。
- 灰色:当前对象已被访问,但其引用的对象还没全部被访问完。
- 黑色:对象及其所有引用都已被访问完,存活对象。
并发标记过程中,业务线程会不断修改引用指向,可能出现一种危险情形:一个黑色对象原本引用的白色对象被业务线程改成引用另一个尚未被访问的白色对象,同时原先的引用被删除,导致那个尚未访问的白色对象失去了被标记的机会,最终被误回收。
解决这个问题的标准方案有两个:增量更新和原始快照。CMS用的是增量更新,当黑色对象插入新的指向白色对象的引用时,把这个引用记录下来,重新标记阶段再去扫描处理。G1用的是原始快照,在删除引用前先把旧的引用关系记录下来,确保之前被引用的对象不会漏标。这两种方式各有取舍,但都是为了让并发标记不“误杀”存活对象。
4. 主流垃圾回收器选型与实战参数解读
4.1 从Serial到CMS:低延迟方向的探索
聊完算法层面,还是要落回到生产环境中用到的具体垃圾回收器上。
Serial回收器是最老牌的回收器,单线程工作,回收时会暂停所有业务线程。它简单可靠,但在多核服务器上浪费资源,只适合小内存、客户端模式或者嵌入式场景。
Parallel回收器(也叫Parallel Scavenge)是JDK 8默认的回收器组合,关注点是可控的吞吐量。它支持通过-XX:MaxGCPauseMillis设置期望的停顿时间,通过-XX:GCTimeRatio设置吞吐量目标。Parallel回收器的核心思想是:既然停顿无法完全消除,那就在指定目标内尽量提高总吞吐量。对计算密集型的离线任务来说,Parallel仍然是很合适的选择。
CMS回收器(Concurrent Mark Sweep)第一次把“并发回收”带进了主流视野。它的设计目标是降低停顿时间,老年代回收时大部分阶段和业务线程并发执行,只有初始标记和重新标记阶段需要STW。CMS的问题也不少:它基于标记-清除算法,会产生内存碎片;并发标记时如果业务线程分配内存太快,可能触发“并发模式失败”,退化为Serial Old进行全量Full GC,反而造成超大停顿。
这些痛点也直接推动了G1回收器的诞生。
4.2 G1回收器:区域化大内存时代的默认选择
G1在JDK 9之后成为默认垃圾回收器,它的核心设计是把堆划分为多个大小相等的Region区域,每个Region既可能是年轻代,也可能是老年代。G1的“Garbage First”不是指全局优先回收垃圾最多的地方,而是跟踪每个Region的回收价值,每次回收时优先处理垃圾比例最高、回收代价最小的Region区域。
G1的特色之一是可预测的停顿时间模型。通过-XX:MaxGCPauseMillis参数设定目标停顿时间,G1会根据这个目标动态调整各代Region的数量,尽量在用户期望的时间窗内完成回收。它不是绝对保证,但在大多数场景下表现非常稳定。
G1的年轻代回收和老年代回收混合进行。全局并发标记周期完成后,进入混合回收阶段,既回收年轻代Region,也回收垃圾比例高的老年代Region。这个过程中,G1依靠记忆集(Remembered Set)来维护Region之间跨区域引用关系。记忆集本质上是一个“反向指针列表”,记录了外部Region对本Region的引用位置,好处是回收一个Region时不需要扫描整个堆,代价是维护记忆集本身有额外的内存和CPU开销。
对大堆场景,G1有明显的优势:它可以避免全堆扫描,停顿时间更可控,内存碎片的困扰也基本消失。如果你的服务内存超过4GB甚至达到几十GB,G1是首选。
4.3 ZGC与Shenandoah:迈向近乎无停顿的未来
如果说G1还在“尽量缩短停顿”,那ZGC的设计目标就是“把停顿时间压到几乎为零”,而且停顿时间不随堆大小线性增长。ZGC的关键技术是染色指针和读屏障。
染色指针(Colored Pointer)是一种很巧妙的设计,它把标记信息直接存储在指针的高位比特中,这样对象本身不需要额外的标记字段,GC可以通过读取指针值快速判断对象状态。配合读屏障,在业务线程读取引用时,如果发现对象被移动了,可以通过转发表自动修正指向。这让对象移动可以和业务线程并发进行,而不需要暂停全体线程。
Shenandoah的路线类似,同样使用读屏障实现并发移动对象,设计目标也是低停顿。不过ZGC的默认堆支持从16MB到16TB,可以说是为超大堆场景设计的。
选择建议很简单:如果你的线上服务延迟很敏感,堆内存又比较大,可以认真评估ZGC;如果堆控制在4GB到16GB之间,用G1通常就是最优解。JDK 11及之后版本对ZGC的支持已经相当成熟,不是试验特性了。
4.4 回收器参数速查与实践建议
实际配置时,不要无脑加参数,先理解每个参数背后的目的。下面这几个是我在项目中常用的:
| 参数 | 作用 | 建议 |
|---|---|---|
| -Xms / -Xmx | 设置初始堆大小和最大堆大小 | 生产环境建议设相同值,避免堆扩展抖动 |
| -XX:+UseG1GC | 使用G1回收器 | JDK 9+默认已开启 |
| -XX:MaxGCPauseMillis | 设置目标停顿时间 | 不要设太小,否则G1会为了迎合目标频繁回收 |
| -XX:ParallelGCThreads | 设置GC线程数 | 通常默认即可 |
| -XX:MetaspaceSize | 设置元空间大小 | 防止元空间频繁扩容 |
| -XX:+HeapDumpOnOutOfMemoryError | OOM时导出堆转储文件 | 强烈建议开启 |
| -XX:HeapDumpPath | 指定堆转储文件路径 | 配合上一条使用 |
G1的MaxGCPauseMillis默认是200ms。很多团队一上来就拍脑袋设成50ms,结果G1为了达到这个目标,会大幅提高Mixed GC的频率,把停顿拆成很多次小型回收,吞吐量反而下降。正确做法是观察真实业务的停顿分布,设定一个“可以接受但不过分激进”的值。
5. 从理论到实战:GC日志分析与调优案例
5.1 开日志看现象:GC日志怎么读
不谈日志的GC讲解都是纸上谈兵。生产环境JVM默认不打印GC日志,需要显式开启。推荐这样设置:
-Xlog:gc*:file=/opt/applogs/gc-%t.log:time,uptime,level,tags:filecount=5,filesize=20m在JDK 8及更早版本,使用-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log。JDK 9以后日志系统重构,统一用-Xlog语法。
G1日志中一段典型的Minor GC输出长这样:
[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0234567 secs] [Parallel Time: 20.0 ms, GC Workers: 8] [Eden: 512.0M(512.0M)->0.0B(486.0M) Survivors: 20.0M->24.0M Heap: 1.2G(4.0G)->700.0M(4.0G)]读日志最关键的是看三个指标:单次停顿时间、频率、吞吐量损失。如果单次停顿长,说明堆中对象太多或回收器配置与业务不匹配;如果停顿频繁,说明堆太小或对象分配速度太快;如果吞吐量损失大,说明GC占用的CPU比例过高。
5.2 一次线上Full GC频繁的真实排障
去年我们一个订单服务频繁报警,现象是接口P99延迟从50ms飙升到3秒,查看监控发现Full GC每五分钟一次,每次停顿接近2秒。
排查过程是这样的:
- 先看GC日志,确认Full GC确实由老年代空间不足触发。
- 用
jstat -gcutil <pid> 1000观察每秒钟Eden、Survivor、老年代使用率的实时变化,发现老年代一直以肉眼可见的速度增长。 - 用
jmap -dump:live,format=b,file=heap.bin <pid>导出堆转储,然后用MAT分析,发现一个名为OrderCache的静态HashMap占用了老年代将近70%的内存,而且key是永远不会重复的订单号。 - 最终定位为缓存未设置过期时间和最大容量限制,导致缓存无限增长。
修复方式很简单,给缓存加上容量上限和过期策略,Full GC立刻消失,接口延迟恢复到正常水平。
这个案例说明,GC调优不只是调参数。遇到Full GC频繁,第一件事永远是看堆里到底装了什么,而不是先调大堆内存。调大堆内存只会让“爆雷”的时间更晚,不会解决根因。
5.3 参数调优的思路:先定目标,再动参数
调优启动参数前,先明确你的服务是延迟敏感型还是吞吐量敏感型:
- 在线交易、实时推荐等服务对响应时间要求高,优先用G1或ZGC,把
MaxGCPauseMillis设成一个现实中能达到的值。 - 离线批处理、数据导入等任务更关心总耗时,用Parallel回收器,尽量增大吞吐量,容忍单次停顿时间长一些。
其次是堆大小设置。我见过不少团队把-Xmx设得很大,美其名曰“反正内存够”,实际导致GC耗时暴涨。原理很简单,堆越大,标记和整理需要遍历的对象越多。建议先按程序正常运行时的常驻内存乘以1.5到2倍来估算堆大小,不要一开始就给满机器内存。
还有一点容易被忽略:Metaspace的扩容也会触发Full GC。如果服务频繁加载新类,比如使用大量动态代理或热部署,元空间不够用时也会触发Full GC。可以观察GC日志中Metaspace的容量增长曲线,必要时用-XX:MetaspaceSize和-XX:MaxMetaspaceSize固定空间。
6. 常见问题与排查技巧实录
6.1 高发问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 频繁Full GC | 老年代空间不足 | 导出堆转储,用MAT分析对象分布 |
| GC后内存不降 | 静态集合持有引用或未回收缓存 | 检查全局静态变量、缓存组件配置 |
| 单次GC停顿过长 | 堆中有超大对象或大数组 | 检查是否一次性加载大量数据到内存 |
| 并发模式失败 | 业务线程分配对象速度超过CMS回收速度 | 换用G1或调整堆大小 |
| Metaspace频繁扩容 | 动态生成类过多 | 增加MetaspaceSize,检查类加载器是否泄漏 |
| 线程等待进入安全点 | 高并发循环密集代码无安全点 | 关注编译热点,适当调整安全点参数 |
6.2 几个常被忽视的细节技巧
设置JVM参数前先确认JDK版本。JDK 8和JDK 11的默认回收器不同,CMS在JDK 14中已经移除,很多网上资料已经过时。先执行java -version确认版本,再用java -XX:+PrintFlagsFinal -version | grep GC查看当前默认回收器。
谨慎使用System.gc()。这个方法只是“建议”JVM执行GC,但很多框架底层会调用它,比如NIO的堆外内存分配失败时。如果不想让业务代码触发Full GC,可以用-XX:+DisableExplicitGC禁用显式GC。
留意大页内存配置。如果操作系统启用了透明大页(THP),JVM的内存分配行为会受影响,可能导致偶发延迟。对延迟敏感的服务,建议在操作系统层面关闭THP,这个优化成本极低,但收益往往很明显。
学会用Arthas做在线诊断。Arthas的dashboard命令可以实时查看内存区域使用率、GC次数和耗时,不需要频繁重启服务。定位到具体问题后,再用heapdump导出快照做离线分析,整个排障效率会高很多。
6.3 关于GC调优的最终建议
从事Java开发这些年,我见过太多人一上来就追求“把GC日志优化成一张完美的图表”,结果陷入不断调参的死循环。实际上,GC调优要解决的永远是问题,不是数据。先确认业务是否真的有问题,比如延迟变高、CPU飙高、频繁OOM,然后再决定要不要调。
如果服务运行稳定,典型停顿在可接受范围内,即使GC频率偏高,也不要因为“强迫症”而改动参数。任何参数的调整都意味着一种新的风险,尤其在生产环境,没有充分验证的情况下,默认参数往往比自以为是的“优化”更可靠。
对于刚接触JVM的读者,我建议从打开GC日志开始,先好好观察你的应用每天发生了什么,再逐步理解每个回收器为什么那么设计。看得多了,你自然会知道什么时候该用G1,什么时候该考虑ZGC,哪些参数是救命稻草,哪些只是心理安慰。这种能力不是背八股文能得来的,是真的需要在实际项目中一点点积累的。