如果你是个写了几年 Java 的开发者,面试时大概率被问过“JVM 垃圾回收是怎么工作的”。很多人能背出“可达性分析”“G1 回收器”“CMS 回收器”这些名词,但真要解释清楚“为什么可达性分析能准确判断对象存活”“G1 的 Region 和 RSet 到底怎么配合”“ZGC 凭什么把停顿压到毫秒级”,就卡壳了。
这篇内容就是冲着“拆穿原理”来的。我会从 JVM 为什么非要引入垃圾回收讲起,到可达性分析的完整算法逻辑,再到 Serial、Parallel、CMS、G1、ZGC、Shenandoah 这几类主流回收器的内部执行机制逐层拆解。每个环节都会讲清“它解决了什么问题、核心机制是什么、代价是什么”,而不是只给你一张对比表背答案。
如果你是准备面试、做 JVM 调优、或者纯粹想弄懂垃圾回收底层逻辑的开发者,这篇文章可以帮你把零散的知识串成一条线。内容偏原理向,但我会用大白话和实际例子来拆,尽量不让它变成枯燥的源码解读。
1. 垃圾回收存在的根源:手动管理内存这条路为什么走不通
先别急着看算法,得先回答一个问题:JVM 为什么要花这么大力气搞垃圾回收? C、C++ 选手可能会说,手动 malloc/free 不也挺好?确实,早期很多系统就是手动管理内存,但这件事的本质问题是:人工判断“这块内存什么时候能释放”这件事,出错率极高。
1.1 引用计数法为什么被主流 JVM 抛弃
最容易想到的自动内存管理方案是引用计数——给每个对象记一个数,被引用一次就加一,引用失效就减一,计数归零就回收。听起来很完美,但它有一个绕不过去的硬伤:循环引用。
举个例子,两个对象互相持有对方,A 指向 B,B 指向 A,但外部已经没有变量引用它们了。按引用计数逻辑,A 和 B 的计数都是 1,永远不可能降到 0,于是它们占用的内存就成了“死内存”,谁也无法回收。典型场景就是双向链表、父子节点互指这类结构。
除了循环引用,引用计数还有一个性能问题:每一次赋值操作都要同步修改计数器,而且多线程环境下还得保证计数器的原子性,这个开销在 Web 应用这种高并发场景里会被放大到不可接受。所以主流 JVM 并没有把引用计数作为核心方案,而是选择了另一条路——可达性分析。
1.2 可达性分析的核心逻辑:从“根”出发找活对象
可达性分析的思路和引用计数完全相反。它不去算“这个对象被谁引用了”,而是问一个更朴素的问题:从一组明确“活着”的根节点出发,沿着引用链走,能不能走到这个对象?
我把这个逻辑打个比方。想象一个下过雨的停车场,地上有很多积水坑,每个坑代表一个对象。现在从停车场出入口(GC Roots)倒进去一批荧光染料,染料会顺着地面上的水流通道(引用链)流向各个坑。凡是流到的地方,这个坑就是“活的”;没流到的坑,就是“死”的。垃圾回收本质上就是把没流到染料的水坑抽干。
这里有一个关键点容易被忽略:可达性分析找的不是垃圾,而是活对象。 它先把所有活对象标记出来,剩下的统统当垃圾处理。所以要判断一个对象生死,不需要去验证“对象是否真的没用了”,只需要确认“它能不能被根节点访问到”。
1.3 存活性判断的微妙边界:finalize 带来的“死而复生”假象
很多人以为“不可达 = 立即回收”,其实中间还隔着一道坎。JVM 给每个对象留了一个“临终告白”的机会——finalize() 方法。如果对象覆写了 finalize,并且之前没被调用过,JVM 在第一次标记为不可达后,会把对象放进一个低优先级队列,等 Finalizer 线程去执行 finalize。在这个方法里,对象有可能重新把自己赋值给某个静态变量,从而重新变得可达,逃过一劫。
不过我的建议是:永远不要依赖 finalize 来做资源清理。 它是 JVM 兜底机制,执行时机不确定,线程优先级极低,甚至可能永远不执行。JDK 9 开始已经明确标记 deprecated,Java 18 里更是彻底移除了 finalize。理解它的存在是为了看懂 JVM 早期设计,但业务代码里千万别碰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可达性分析的深层机制:GC Roots、三色标记与内存屏障
可达性分析的“分析”两个字,背后是一套严谨的图遍历算法。JVM 在真正执行标记时,要考虑的事情比想象中多得多。
2.1 GC Roots 到底包含哪些节点
GC Roots 不是一张固定的清单,而是 JVM 在特定时刻从各个“活水源头”收集来的引用集合。主要包含这几类:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象:正在执行的方法里,局部变量直接指向的对象。
- 方法区中的静态变量引用:静态字段指向的对象。
- 方法区中的常量引用:字符串常量池、类常量等引用的对象。
- 本地方法栈中 JNI 引用的对象:Java 调用 native 方法时,native 代码持有的 Java 对象引用。
- JVM 内部的引用:基本类型对应的 Class 对象、常驻的异常对象(如 NullPointerException 的类对象)、系统类加载器等。
- 所有被 synchronized 持有的对象(monitor 锁对象)。
- JNI Global References。
这里有个很重要的补充:GC Roots 本身不是固定不变的,而是动态收集的。 比如在分代回收里,新生代的 GC Roots 还会包含老年代中对新生代对象的引用。这一点在处理跨代引用时非常关键,后文讲卡表(Card Table)的时候会细说。
2.2 三色标记算法:把“可达性”变成可并发的状态机
可达性分析的朴素实现是深度优先遍历:从根节点开始,一路往下走,走过的对象算存活。但实际 JVM 在标记时,会把对象标记成三种颜色,这就是著名的三色标记算法:
| 颜色 | 含义 | 标记阶段的状态 |
|---|---|---|
| 白色 | 尚未被访问到 | 可能是垃圾,标记结束后若仍是白色则回收 |
| 灰色 | 被访问到,但其引用的对象还没被全部扫过 | 正在处理中的中间状态 |
| 黑色 | 自身和它引用的对象都已被扫描完毕 | 确认存活,不再被追踪 |
处理过程非常像 BFS(广度优先搜索):先把根节点染成灰色,然后取一个灰色节点,遍历它的引用,把引用的对象染成灰色,处理完后把自己染成黑色,再取下个灰色节点。循环往复,直到没有灰色节点为止。最后剩下的白色对象,就是不可达的对象。
为什么要把“简单的遍历”拆成三种颜色?因为三色标记让标记过程可以被并发执行。并发场景下,业务线程和 GC 线程同时在跑,一个对象可能在扫描过程中被修改引用。如果只区分“存/亡”两种状态,就没法判断当前扫描到哪一步了。有了灰色作为“中间态”,GC 才能做到边标记、边允许应用线程继续运行。CMS 和 G1 都靠这个机制实现并发标记。
2.3 并发标记的致命陷阱:漏标与错标
并发标记有一个经典问题:如果一个黑色对象(已经扫描完)被业务线程改动了,新增了一条指向白色对象的引用,那这个白色对象就被“漏标”了。 因为黑色对象不会再被扫描,这条新引用没人看得到,结果就是这个对象明明存活,却被当成垃圾回收掉了,程序运行时直接宕机。
这里有两种主流的防守方案:
- 增量更新(Incremental Update):记录黑色对象新增的引用,把这些黑色对象重新置为灰色,后续再扫描一遍。CMS 采用这种策略。
- 原始快照(SATB,Snapshot At The Beginning):记录被删除的引用,在标记开始时保存一个“引用关系快照”,如果一条引用在本轮标记中被删除了,那就把这个信息记录下来,保证所有在标记开始时存活的对象都会被保留。G1 采用这种策略。
两种方案本质上都是在“记录变化”,但记录的对象不同:CMS 记新增的引用(记黑变灰),G1 记被删的引用(把曾经有关系的白色对象保护起来)。理解了这个区别,面试时被问到“CMS 和 G1 的并发标记差异”就不再是背概念,而是真正明白它们各自的取舍。
2.4 为什么必须引入写屏障与内存屏障
有了增量更新或 SATB 策略,还需要一个东西来“捕捉变化”——写屏障(Write Barrier)。Java 代码里每次给对象字段赋值,编译器都会插入一段额外的逻辑,检查“这个赋值会不会影响正在进行的标记”。这个插入的逻辑,就是写屏障。
注意,这里的“屏障”和并发编程里的内存屏障(Memory Barrier)不是一个东西。写屏障是 JVM 在赋值操作前后加的钩子,由编译器生成,作用是让 JVM 能感知引用变化;内存屏障是 CPU 指令层面解决指令重排序和可见性的。两者经常被混为一谈,面试时最好区分开。
另外,读屏障(Load Barrier)也有应用。ZGC 的染色指针就依赖读屏障来修正对象地址。后面讲 ZGC 时会再展开。
3. 经典回收器执行机制:Serial、Parallel、CMS 逐个拆
JVM 里的回收器经历了好几代演进。从最早的 Serial,到追求吞吐量的 Parallel,再到为了低延迟而生的 CMS,每一代都是为了解决上一代的某个短板。这一节把经典回收器的执行机制拆开讲透。
3.1 Serial 回收器:单线程但可靠的“清道夫”
Serial 是最古老的回收器,特点是单线程,收集时必须暂停所有工作线程(Stop The World,STW)。
新生代用 Serial 时采用的是复制算法,执行过程很直接:
- 暂停所有业务线程。
- 把 Eden 区和 Survivor 区里存活的对象,复制到空的 Survivor 区。
- 清空 Eden 和原来的 Survivor。
- 恢复业务线程。
由于 Survivor 空间通常不大,复制成本可控。老年代用 Serial Old 时,采用标记-整理算法:标记存活对象,然后让存活对象向一端移动,最后清理掉边界以外的空间。这个整理动作能避免内存碎片,但代价是移动对象需要更新所有引用,成本更高。
Serial 真的没用了吗? 其实是客户端模式下 JVM 的默认回收器,因为它简单、稳定、没有线程切换开销,在单核 CPU 或小内存场景下反而比并行回收器更高效。服务端场景已经很少直接用,但在理解其他回收器时,Serial 是最好的入门模型。
3.2 Parallel 回收器:先保证吞吐量
Parallel 回收器可以理解为 Serial 的多线程版本。它关注的核心指标是吞吐量——单位时间内,业务代码执行时间占比。
吞吐量 = 业务线程执行时间 ÷ (业务线程执行时间 + GC 时间)。比如系统运行 100 分钟,GC 用了 1 分钟,吞吐量就是 99%。
执行流程和 Serial 基本一样,只是把复制、标记、整理这些动作拆给多个线程并行做。关键在于怎么让多个 GC 线程协同工作而不互相干扰。HotSpot 的实现里,Parallel 用的是“并行标记-并行整理”的流程,GC 线程间通过任务队列来划分工作,用自旋等待和特定的同步协议保证线程之间不会重复处理同一个对象。
Parallel 适合后台批处理、离线计算这类对停顿不敏感、但对吞吐量有高要求的场景。如果你用 JDK 8 而且没有特别配置 GC,默认用的就是 Parallel,它的目标函数是让吞吐量达到最高。
3.3 CMS 回收器:首次把“并发”引入 GC
CMS(Concurrent Mark Sweep)是划时代的产物,它第一次实现了业务线程和 GC 线程并发执行,目标是减少停顿时间。
CMS 整个过程分为四个阶段,其中两个阶段是 STW,两个阶段是并发的:
- 初始标记(Initial Mark,STW):极短停顿,只标记 GC Roots 直接引用的对象。
- 并发标记(Concurrent Mark):从初始标记的对象出发,并发遍历所有引用,这个阶段业务线程不停。
- 重新标记(Remark,STW):修正并发标记期间因业务线程修改引用而产生的变动,这个阶段会再次停顿。
- 并发清除(Concurrent Sweep):并发清理白色对象。
这里有几个值得展开的细节:
为什么初始标记要 STW? 因为 GC Roots 本身是“活水源头”,如果一边标记一边有新的栈帧压栈或出栈,根集合会变化,标记就开始在一个不稳定的地基上跑。所以初始标记哪怕只标记一小撮对象,也必须暂停业务线程,保证根集合固定。
为什么重新标记还要 STW? 因为增量更新(Incremental Update)需要把并发标记期间变动的对象重新扫描一遍,这个修正过程涉及大量引用扫描,并发执行会在业务线程和 GC 线程之间产生复杂的竞态,CMS 选择在修正阶段暂停业务线程,换取实现上的确定性。
CMS 的最大问题是并发清除阶段不整理内存,老年代会积累大量碎片。碎片化严重到一定程度,老年代没有连续空间分配大对象,会触发“并发模式失败”,此时 JVM 会退化成 Serial Old 的 STW 全量整理,停顿时间直接爆炸。这也是 CMS 后来被 G1 取代的重要原因之一。
3.4 回收算法与回收器之间的关系:别再把它们混为一谈
很多初学者把“复制算法”“标记-清除”“标记-整理”和回收器混在一起。这里理顺一个概念:
- 回收算法是方法论:怎么找垃圾、怎么回收垃圾。
- 回收器是具体实现:在特定场景下,组合了哪些算法、并发策略、触发条件。
比如 Serial 新生代用复制算法,老年代用标记-整理;CMS 老年代用标记-清除;G1 整体看是标记-整理,局部看是复制。
分代假设是另一个关键前提:大部分对象朝生夕灭(线程内创建的临时对象),少数对象活得很久(缓存、单例、连接池对象)。所以新生代和老年代才用不同的回收策略:新生代频繁回收但只需复制少量存活对象;老年代不频繁回收,但每次回收都要处理大量长生命周期对象。
4. G1 回收器:分代收集的集大成者与执行机制
G1(Garbage First)是 JDK 9 之后服务端默认的回收器。名字很有意思——“垃圾优先”,意思是优先回收垃圾最多的区域,这样可以花尽量少的时间腾出尽量多的空间。
4.1 Region 模型:打破物理分代,保留逻辑分代
传统回收器的堆是一整块连续空间,新生代、老年代物理隔离。G1 把堆划分成若干个大小相等的 Region(默认最多 2048 个,每个 Region 大小从 1MB 到 32MB 不等,由堆大小决定)。每个 Region 在逻辑上可以是 Eden、Survivor 或 Old,而且这个身份是动态变化的,不是写死的。
Region 化带来两个好处:
- 回收粒度变小:可以只回收一小部分 Region,而不是整个新生代或老年代。
- 可预测停顿:G1 会维护每个 Region 的回收价值和成本,优先回收“垃圾多、回收快”的 Region,每次都控制在用户设定的停顿目标(如 200ms)内。
不过 Region 也带来一个新问题:跨 Region 引用。A Region 里的对象可能引用了 B Region 里的对象。如果不处理这种跨区引用,扫描时就得把整个堆都扫一遍,那 Region 化就失去意义了。
所以 G1 引入了 RSet(Remembered Set,记忆集)。每个 Region 都维护一个 RSet,记录“谁引用了本 Region 里的对象”。这样回收某个 Region 时,只需要看自己的 RSet 就知道哪些外部引用了它,不需要全堆扫描。
4.2 值得深入看的关键机制:RSet、Card 与 SATB
RSet 的实现依赖 Card Table(卡表)。JVM 把堆空间划分成一张张卡片(Card),每张 Card 大小通常是 512 字节。当一个对象引用了其他 Region 的对象时,JVM 会把这张 Card 标记为 Dirty。Region 的 RSet 本质上就是一个 Card 的集合。
引入了写屏障后,业务线程对引用的每次赋值,都会触发写屏障去更新 RSet。所以 RSet 的维护是有运行时开销的,这也是 G1 吞吐量在某些场景下可能不如 Parallel 的原因。
G1 的三色标记用了 SATB(Snapshot At The Beginning,原始快照)方案。它的核心思想是:在标记开始时,所有可达对象都拍一张“快照”,并发标记过程中,即使某个引用被业务线程删除了,SATB 也会记录下这个“删除事件”,保证被删引用指向的旧对象仍然存活到本轮标记结束。这么做的代价是可能产生浮动垃圾(本来已经没人引用的对象,因为被快照保留而多存活了一轮),但换来了并发标记的稳定性。
4.3 G1 的完整执行流程:从 Young GC 到 Mixed GC
G1 的回收过程不是一个单一流程,而是两种不同规模的 GC 协同工作:
Young GC:当 Eden 区满时触发。把 Eden 区和部分 Survivor 区的存活对象复制到新的 Survivor 区,年龄增长到阈值就晋升到 Old Region。这个阶段是 STW 的,但由于 G1 可以动态调整年轻代大小,停顿是可预测的,通常几十毫秒。
Mixed GC:当老年代占用达到阈值(默认 45%,由 -XX:InitiatingHeapOccupancyPercent 控制)时触发,进入并发标记周期。完整周期包含:
- 初始标记(STW):标记 GC Roots 直接可达的对象,这个阶段和 Young GC 是合并执行的,所以停顿成本很小。
- 并发标记:从根对象出发遍历整个堆的引用图,和业务线程并发执行。这个阶段会记录 SATB 快照、更新 RSet。
- 最终标记(STW):处理 SATB 队列里剩余的变动。
- 清理阶段:统计每个 Region 的存活对象数量,排序回收价值,更新 RSet,这个阶段大部分是并发的。
- 转移阶段(混合收集):把选中的 Region 中存活对象复制到空 Region,然后释放旧 Region。这一步是 STW 的,但 G1 会通过选择合适数量的 Region,把停顿控制在目标值以内。
Mixed GC 之所以叫“混合”,是因为它既回收年轻代,也回收部分老年代 Region。它不会一次回收所有老年代,而是分批进行,避免单次停顿过长。
4.4 大对象与 Humongous Region:一个容易忽略的细节
超大对象(超过 Region 大小的一半)会直接分配在称为 Humongous Region 的特殊区域。大对象分配很昂贵,因为需要连续的多个 Region;回收大对象也很费劲,因为复制大对象成本极高。所以 G1 对大对象采用“直接分配、不复制”的策略。写代码时尽量避免创建超大数组或超大对象,这是很多 G1 GC 停顿异常的隐藏原因。
5. 迈向大堆低延迟:ZGC 与 Shenandoah 的并发整理机制
如果说 G1 让停顿降到几十毫秒,那 ZGC 的目标就是把停顿压到 10 毫秒以内,关键是堆大小对停顿影响很小。这意味着即使堆有 100GB,GC 停顿也基本不变。
5.1 ZGC 的染色指针(Colored Pointer):把信息藏进指针里
ZGC 的核心突破之一是染色指针。ZGC 在 64 位 Linux 平台上,把对象的地址拆成了多个段。用其中几位比特来存储 Marked0、Marked1、Remapped、Finalizable 等标记信息,而不是像传统 JVM 那样把信息存在对象头里。这样在读指针的时候,CPU 就能直接拿到“该对象的标记状态”,不需要为了读标记去访问内存中的对象头。
三色标记里颜色信息可以“藏”在指针里,这个设计打通了并发整理的一条路。因为 JVM 可以通过修改指针的标记位来改变一个对象的状态,而不需要真的去动对象的内容。
5.2 读屏障与转发指针:让业务线程帮忙“搬家”
ZGC 的并发整理依赖一个关键机制:对象搬移后,业务线程读到旧地址时,要通过读屏障找到新地址。
整理开始时,ZGC 会把对象从原来位置复制一份到新位置,然后更新对象的“转发指针”(Forwarding Pointer)指向新地址。正常情况下,业务线程读取对象时,从指针里读出的是旧地址,会发现标记位显示“已重映射”,于是通过转发指针跳到新地址去取数据。
这个过程中,业务线程实际上在帮助 GC 完成对象迁移——它可能顺手就把读到的旧地址修正为新地址,减少后续访问次数。ZGC 巧妙地把“找新家”这件事分摊到了业务线程的每次读取上,而不是像传统 GC 那样在 STW 阶段集中修正所有引用。
染色指针也有代价:它依赖 64 位系统、依赖虚拟内存映射。而且因为用了几位比特存元数据,实际可用的地址空间范围有限。另外,它也不是所有平台都能用(比如 Windows 的 ZGC 实现就走的是不同路径)。但概念上理解染色指针 + 读屏障 + 并发整理,是理解 ZGC 的钥匙。
5.3 Shenandoah:不用染色指针也能并发整理
Shenandoah 是另一个追求低延迟的回收器,但它的实现路径跟 ZGC 不同。它不使用染色指针,而是用读写屏障 + 转发指针来实现并发整理。
Shenandoah 延续了 GC Roots 扫描 + 并发标记的做法,但在转移阶段,它也引入了一个并发转移(Concurrent Evacuation)阶段,把存活对象从当前 Region 复制到新 Region,同时通过读屏障让业务线程访问对象时自动转发到新地址。它把原本 STW 才做的对象移动工作,拆成并发动作,从而缩短了停顿。
Shenandoah 和 ZGC 的适用性差异:ZGC 在大堆场景表现得极其亮眼,Shenandoah 的推进也很强势。两者在其他方面的权衡(CPU 占用、跨代处理等)不一样,但如果你所在的应用是堆大、延迟敏感,两个都值得做压测实验。
5.4 为什么说“低延迟”不等于“高吞吐”
很多架构师把低延迟和吞吐量混为一谈,其实这是两个独立的维度。ZGC 追求的是每次 GC 停顿极短,但整个 GC 过程依赖更多并发开销(读屏障、转发指针),业务线程的总工作量可能增大,极端情况下吞吐量会低于 Parallel。
这就引出一个调优建议:不要盲目跟风换最新回收器。 如果你在跑一个离线批处理任务,吞吐量优先,Parallel 可能就是最优解;如果你在跑的是一个对延迟极其敏感的在线交易系统,堆又很大,ZGC 可能是更合适的选择。JVM 提供这么多回收器的本质想法是:没有银弹,按场景匹配。
6. 从原理到实战:GC 日志、参数调优与常见误区
原理如果落不了地,就只是面试素材。这一节把原理转化为可操作的排查手段和配置方法。
6.1 怎么快速看懂 GC 日志:找出关键信息
现在 JDK 8 以上推荐用统一日志(Unified Logging)。启动时加上参数:
bash复制-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=20m
这行配置的作用是输出到文件、按时间和运行时长打点、限制文件数量和大小,防止日志写爆磁盘。
一份典型的 G1 日志片段长这样:
log复制[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs]
[Parallel Time: 10.0 ms, GC Workers: 8]
[Eden: 512.0M(512.0M)->0.0B(512.0M) Survivors: 8192.0K->8192.0K Heap: 1.2G(4.0G)->860.0M(4.0G)]
解读几个要点:
GC pause (G1 Evacuation Pause) (young):这次是年轻代转移停顿。Eden: 512.0M(512.0M)->0.0B(512.0M):Eden 从用了 512MB 变成 0,容量保持 512MB,说明 Eden 被清空了。Heap: 1.2G(4.0G)->860.0M(4.0G):整个堆从 1.2GB 降到 860MB,释放了约 340MB。
如果看到 Concurrent Mode Failure 或 To-space exhausted 这类关键词,说明堆空间无法容纳并发转移的存活对象,GC 会退化到 Full GC(STW 整理)。这种时候不要急着调 GC 参数,先看是不是堆配置本身就不合理。
6.2 常见调参策略与适用场景
| 目标 | 建议 | 参数示例 |
|---|---|---|
| 服务端默认,追求较低停顿 | 直接用 G1,设置停顿目标 | -XX:+UseG1GC -XX:MaxGCPauseMillis=100 |
| 后台批处理,追求吞吐量 | 用 Parallel GC | -XX:+UseParallelGC |
| 大堆低延迟(>16GB) | 尝试 ZGC | -XX:+UseZGC |
| 打印 GC 日志便于排查 | 开启 Unified Logging | 见上文日志配置 |
注意几个容易踩的坑:
-XX:MaxGCPauseMillis不是越短越好。目标定得太小(比如 20ms),G1 会拼命缩小年轻代大小来满足停顿目标,结果就是频繁 GC,吞吐量大幅下降。-XX:InitiatingHeapOccupancyPercent(默认 45%)决定 G1 何时开始并发标记。如果堆很大但并发标记启动太早,会频繁做并发标记,浪费 CPU;如果启动太晚,可能老年代快满了才触发,导致转移压力大。- 年轻代大小不要手工固定得太死。G1 的动态调整能力需要空间,强制
-Xmn设置年轻代大小会削弱 G1 的动态能力。
6.3 一个真实的 Full GC 排查案例:别急着换回收器
之前帮一个团队看过一个线上案例。应用用的就是 G1,但高峰期频繁出现接近几秒的 Full GC 停顿。第一反应可能是“G1 不行”,把回收器换成 ZGC?我先让他们抓了 GC 日志,发现了一个细节:每次 Full GC 触发前,日志里都出现了大量 Humongous Allocation。
查代码后发现,某服务在高峰时会把一张大表全量加载到内存里做排序,一次分配了几百 MB 的连续对象,只能放进 Humongous Region。连续分配大对象不仅占用多块 Region,还会加速并发标记触发频率,最终导致 Full GC。
修复方案根本不是换回收器,而是把大表查询改成分批加载,限制单次分配对象大小,同时把堆从 8GB 调整到 12GB,给 Humongous 对象留出更多 Region。调整后 Full GC 再没出现过,停顿稳定在 50ms 以内。这个案例说明:GC 参数只是最后一道防线,程序本身的分配行为才是根因。
6.4 JVM 调优的经典误区:遇到性能问题就先调 GC
很多同学一遇到系统卡顿就想着调 JVM 参数,这其实是本末倒置。常规的排查链路应该是:
- 先看是不是代码层面的问题:大循环、无锁竞争、连接泄漏、线程池耗尽。
- 再看有没有明显的内存分配问题:大对象分配、频繁创建临时对象、集合扩容。
- 然后看 GC 频率和停顿:抓日志,看 GC 耗时和堆变化趋势。
- 最后才是调整 JVM 参数:按观测到的瓶颈去做针对性调整,而不是拍脑袋调大堆内存或改回收器。
GC 调优的核心度量指标就三个:吞吐量、延迟(停顿时间)、足迹(使用的内存大小)。三者只能取其二,调优的过程就是在这三个指标之间做权衡。G1/ZGC 都是 JVM 精心设计的作品,但它们解决的是“低延迟+大堆”的问题,不代表它能解决所有性能问题。
写代码的时候,顺手做两件小事,很多时候比调参更管用:一是尽量避免在循环里创建大对象或数组,二是合理设置集合的初始容量,减少扩容时的复制和引用修改。这些微观层面的分配行为,最终都会反映到 GC 的压力上。
JVM 垃圾回收这套体系,表面上是一堆回收器和参数的排列组合,底层其实是“如何在不打断业务的前提下,安全高效地管理内存”这个永恒命题的演进史。从 Serial 的单线程 STW,到 CMS 的并发标记,再到 G1 的 Region 化可预测停顿,最后到 ZGC 的近乎无感回收,每一步都是在权衡吞吐量、延迟和复杂度。你把原理看透了,再去配参数、看日志、查问题,心里就有底了,不会再被面试官抛出的回收器名词绕晕,也不会在线上出问题时被 GC 日志吓到手忙脚乱。
