news 2026/10/12 7:21:30

JVM垃圾回收算法与收集器全解析:从Minor GC到ZGC

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM垃圾回收算法与收集器全解析:从Minor GC到ZGC

1. 为什么说算法是地基、收集器是算法的“工程实现”

先聊点实际的。我见过不少工程师把 GC 调优当成“玄学”,一问到什么 Minor GC、Full GC,能背出概念,但一旦线上频繁 Full GC,就只会把堆调大,或者换个收集器试试运气。这种状态其实很危险,因为 GC 排查最怕的就是“猜”。要真正把 GC 吃透,必须先建立一条清晰的认知链:对象存活判定 → 垃圾回收算法 → 垃圾收集器 → GC 类型(Minor/Major/Full)。这四层是层层递进的关系,很多人把收集器和算法混为一谈,把 Major GC 和 Full GC 混为一谈,根子就在于没有先把这条链路理清楚。

先从“算法”说起。算法解决的核心问题只有一个:如何把已经死掉的对象占用的内存腾出来。不同算法代表不同的取舍——是牺牲吞吐量换内存连续性,还是牺牲部分空间利用率换停顿时间。而收集器,则是把某一种或几种算法落地到某个具体代(新生代/老年代)的工程实现。同一个复制算法,在 Serial 里是单线程做,在 ParNew 里是多线程并行做,在 G1 里则是多个 Region 之间并行做。算法是思想,收集器是执行者,思想相同但执行者能力不同,表现自然不同。

至于 Minor/Major/Full GC,它们是按发生区域划分的事件类型,而不是独立的“算法”或“收集器”。很多人误以为“Full GC 就是一种收集器”,其实不是——Full GC 是某个收集器在特定条件下触发的一次“全堆回收”,它内部用的仍然是对应的算法。举个例子,Parallel Scavenge + Parallel Old 组合下的 Full GC,本质上是老年代使用标记-整理算法,新生代使用复制算法,两者共同配合完成一次全堆回收。

理解了这条链路,后面看任何 GC 日志、调任何参数,都会清晰很多。下面我按“算法 → 类型 → 收集器 → 实战”的顺序,一层层拆开讲。

2. 四种垃圾回收算法:看清它们的取舍逻辑

这一节是地基中的地基。四种经典算法分别是:标记-清除(Mark-Sweep)、标记-复制(Copying)、标记-整理(Mark-Compact)、分代收集(Generational Collection)。注意,分代收集算不算“第四种算法”在社区里有过讨论,我的理解是:前面三种是“具体策略”,分代收集是“组合策略”——它把堆划分为新生代和老年代,分别套用前三种算法中的合适方案,所以它更像一个宏观框架,而不是一个微观算法。你只要记住:前三种是手段,分代收集是组织手段的方案。

2.1 标记-清除:最朴素,但碎片化是硬伤

流程分两步:先遍历可达对象,把存活对象标记出来;再遍历整个堆,把未被标记的对象回收掉。这个算法最致命的问题是内存碎片。想象一下你在一张纸上反复涂改,涂掉的地方零散分布,时间一长,纸面到处都是小洞,想写一段连续的长句(分配一个大对象)就写不下了,只能触发下一次 GC 去腾挪空间。

在 JVM 里,标记-清除清除后的空闲内存是用空闲列表(Free List)来维护的,分配对象时要找一块足够大的连续空间,效率比连续规整的内存慢。这也是为什么很多收集器在标记-清除之后还会补一个“整理”动作,代价就是多一次对象移动。

2.2 标记-复制:用空间换时间,新生代的标配

把内存分成两块(或一块 Eden + 两块 Survivor),每次只用其中一块,GC 时把存活对象复制到另一块空区,然后整块清掉旧区。优点非常明显:存活对象少时效率极高,且复制后内存是连续规整的,完全不存在碎片问题。

代价也直接:空间浪费。所以 JVM 把新生代设计成 Eden:S0:S1 = 8:1:1,只有 10% 的空间会闲置,比简单的 1:1 划分聪明得多。回收时,Eden 和一块 Survivor 的存活对象都复制到另一块 Survivor,目标区放不下再晋升到老年代。

2.3 标记-整理:老年代的最优解之一

老年代对象存活率高,用复制算法会出现大量复制开销;用标记-清除又碎片严重。标记-整理的思路是:标记完存活对象后,把所有存活对象向一端移动,直接按顺序排列,然后清理掉边界以外的内存。

这样内存是连续的,分配对象走指针碰撞(Bump the Pointer)就能完成,速度快。缺点也很明显——移动对象要更新所有引用,这一步必须暂停用户线程,否则一边移动一边有线程读写对象,数据就全乱了。所以在老年代 GC 里,标记-整理通常是 STW 的。

2.4 分代收集:为什么把堆分成新生代和老年代

很多人没有深想过一个问题:为什么要分代?直接对整个堆做一次 GC 不行吗?答案是:绝大多数对象“朝生夕灭”,命很短。IBM 的经典研究表明,新生代里 90% 左右的对象活不过第一轮 GC。如果对所有对象一视同仁地做标记,每次都要扫描大量注定要死掉的对象,纯属浪费。分代收集的本质是按对象存活时间分类,对不同类别的对象采用不同的回收频率和算法:

  • 新生代:存活率低,适合复制算法,回收频繁但单次极快;
  • 老年代:存活率高,适合标记-清除或标记-整理,回收频率低但单次耗时较长。

分代收集用一句话概括就是:让短命对象快速蒸发,让长命对象稳定存活,避免每次都“全量扫描”。

3. Minor/Major/Full GC:一次把概念理清

很多资料里 Minor GC、Major GC、Full GC 的定义并不完全统一,尤其是 Major 和 Full,经常被混用。如果你对概念本身就模糊,后面看日志、对指标,全都会打折扣。这里我按 HotSpot 官方定位和目前社区主流的理解,给大家一个能实际操作的标准版本。

3.1 三类 GC 到底发生在哪里

GC 类型发生区域触发条件特点
Minor GC / Young GC新生代(Eden + Survivor)Eden 区满频率高、速度快,一般几十毫秒以内
Major GC老年代老年代空间不足等通常伴随至少一次 Minor GC,耗时长
Full GC整个堆 + 方法区/元空间老年代满、元空间满、System.gc() 等停顿时间最长,是排查的重中之重

这里有第一个大误区:Major GC 是不是等于 Full GC?在老一代收集器(如 Serial、Parallel)里,Major GC 和 Full GC 确实经常等同——因为它们的老年代 GC 就是一次“老年代 + 新生代”的全堆回收。但在 G1、ZGC 这类新收集器里,Full GC 是单线程、串行、全堆的“兜底行为”,而 Major GC 这个概念被弱化了。所以更稳妥的建议是:看日志时以实际描述为准,讨论时尽量避免单独用“Major GC”,用“老年代 GC”更精准。

3.2 Minor GC 为什么会频繁发生

Minor GC 的触发条件只有一个:新生代的 Eden 区被新对象填满。注意,这里只说 Eden,不是整个新生代。哪怕 Survivor 区还有大量空间,只要 Eden 满了,就会触发。为什么这么设计?因为新对象绝大多数都分配在 Eden,Eden 就是排泄口,排泄口堵了,整个新生代就停了。

Minor GC 的过程可以分解为四步,结合一个例子来理解:

  1. 新生代当前有 Eden 3GB、S0 256MB、S1 256MB,Eden 已满;
  2. 触发 Minor GC,把 Eden 和 S0 里所有存活对象复制到 S1;
  3. 如果 S1 装不下,装不下的部分直接晋升(Promotion)到老年代;
  4. 清空 Eden 和 S0,S0 和 S1 角色互换。

这里有个非常重要的参数:-XX:SurvivorRatio,默认 8,也就是 Eden 占新生代的 80%,两个 Survivor 各占 10%。如果调成 4,Eden 占 66.7%,Survivor 占 33.3%。Survivor 越大,能容纳的“存活但还没老”的对象越多,晋升老年代的阈值就越不容易被触达;但 Eden 变小会导致 Minor GC 更频繁。这个平衡要根据对象存活率来定,后面实战部分我再展开。

3.3 Full GC 的触发条件和排查思路

Full GC 是所有 GC 事件里 STW 最长、最影响业务的一类。它的触发条件比 Minor 多得多,我列一个完整清单:

  • 老年代空间不足:晋升对象太多、大对象直接进老年代、老年代 GC 后仍不满足分配需求;
  • 元空间(Metaspace)达到阈值:类加载过多、动态生成代理类过多,导致元空间扩容甚至触发 Full GC;
  • System.gc() / Runtime.getRuntime().gc() 被显式调用:这种最坑,很多框架和老代码里会偷偷调,调用时若禁用了 DisableExplicitGC,就会引发一次 Full GC;
  • CMS 并发模式失败(Concurrent Mode Failure):CMS 在并发清理期间,老年代被新晋升对象再次填满,只能退化为 Serial Old 做 Full GC;
  • G1 的 Humongous 对象分配失败:超过 Region 大小一半的大对象分配不成功,且回收后仍不满足;
  • 晋升失败(Promotion Failed):Minor GC 后发现存活对象数大于 Survivor 和当前可用老年代空间,或老年代连续空间不足。

排查 Full GC 的正确姿势,不是上来就看堆大小,而是先看 GC 日志里的触发原因(GC Cause)。JVM 在日志里会明确打出类似Full GC (Allocation Failure)、Full GC (System.gc())、Full GC (Metadata GC Threshold)这样的原因字段。我强烈建议你养成习惯:一切调优决策从 GC 日志的 Cause 字段开始,而不是从猜测开始。

4. 主流垃圾收集器全家桶:从 Serial 到 ZGC

收集器是大家最熟悉也最容易纠结的部分。我按照 HotSpot 的演进脉络来讲,你会发现收集器的变化其实是在解决同一个矛盾:如何用更少的时间、更少的空间开销,把更大的堆打扫干净。每一步演进都是针对前一个方案的缺陷,而不是凭空出现的。

4.1 Serial / Serial Old:单线程 GC 的鼻祖

Serial 是最古老的收集器,新生代用复制算法,老年代用标记-整理,全程单线程执行,GC 时必须暂停所有业务线程(STW)。单线程听起来很落后,但真正用过的人都知道:在小堆、低并发、客户端模式下,Serial 反而是最稳定的选择。

举一个实际场景:一台只有 4GB 堆的某内部工具型服务,并发量低,GC 频次本身就不高,一次 Minor GC 也就 10ms 级别。这种情况下用更复杂的新生代收集器(比如 G1)反而得不偿失,因为它需要维护 Region、记忆集、卡表等额外结构,白白吃掉一部分 CPU 和内存。所以直到今天,Serial 依然是-XX:+UseSerialGC可以显式启用的合法选项,只是默认不再使用。

Serial Old 作为老年代收集器,在 JDK 8 里还有另一个身份——CMS 并发模式失败的兜底收集器。CMS 一旦 Concurrent Mode Failure,就直接退回 Serial Old 做一次全堆串行 GC,停顿可以长达数秒。这也是 CMS 时代最让人头疼的场景。

4.2 ParNew + CMS:多线程并发收集的经典组合

ParNew 是 Serial 的多线程版本,新生代复制算法,用多个 GC 线程并行处理,适合多核机器。它本身没有太多故事,但它是CMS 唯一可用的新生代搭档(在某些版本里)。CMS 才是这段故事的主角。

CMS(Concurrent Mark Sweep)的设计目标很明确:追求最短 STW 时间。它把老年代回收拆成四个阶段:

  1. 初始标记(Initial Mark):只标记 GC Roots 直接引用的对象,STW,但极短;
  2. 并发标记(Concurrent Mark):从已标记对象出发遍历整个对象图,与业务线程并发执行,不停机;
  3. 重新标记(Remark):修正并发标记期间业务线程新产生的引用变化,STW,但比初标长一些;
  4. 并发清除(Concurrent Sweep):清理死亡对象,与业务线程并发执行。

看起来很美,但 CMS 有三大硬伤,我一个个说,因为理解了这三点,你才能真正理解为什么后来 G1 要取代它:

第一,CPU 敏感。并发阶段要抢占 CPU 时间,在低核数机器上,GC 线程和业务线程互相抢占,吞吐量掉得很厉害。CMS 默认并发线程数是(CPU核数 + 3) / 4,四核及以下机器里 GC 线程占比很高,业务会被明显拖慢。

第二,浮动垃圾和并发模式失败。并发标记和并发清除阶段,业务线程仍在运行,会不断产生新垃圾,这些“浮动垃圾”只能留到下一次 GC。更危险的是,如果并发阶段老年代空间又被占满,CMS 直接退化到 Serial Old 做 Full GC,停顿时间爆炸。这就是为什么 CMS 触发阈值不能设太高,-XX:CMSInitiatingOccupancyFraction一般建议 68%~75%,留出浮动垃圾的空间。

第三,碎片问题。CMS 用的是标记-清除,没有整理,长时间运行后老年代碎片化严重,分配大对象会直接触发 Concurrent Mode Failure。CMS 通过-XX:+UseCMSCompactAtFullCollection在 Full GC 时做碎片整理,但这又增加了停顿时间。CMS 很聪明,但也很脆弱,它本质上是用更多操作换来的低停顿,每个动作都有代价。

JDK 9 之后 CMS 被标记为废弃,JDK 14 正式移除。但直到今天,线上仍有大量 JDK 8 应用跑在 CMS 上,它依然值得你深入理解。

4.3 Parallel Scavenge / Parallel Old:吞吐量优先的最强代表

Parallel 系列的定位和 CMS 完全相反:目标是最大化吞吐量(单位时间内业务线程执行时间占比最大化),而不是最小化停顿。它同样是多线程并行,但把每一次 GC 的停顿时间拉长一些,换来更少的 GC 次数和更高的总处理能力。

Parallel Scavenge 有个独特的参数体系:-XX:MaxGCPauseMillis和-XX:GCTimeRatio。前者是期望最大停顿时间,后者是期望 GC 时间占吞吐量的比例,默认 99,即允许 1% 的时间用于 GC。二者是 JVM 自动调优的“方向盘”:设置后,JVM 会动态调整堆大小和代比例,试图在满足停顿目标的同时最大化吞吐量。这里要提醒一句:这两个参数是“尽力而为”的,系统调整能力有限,不要指望它完全满足极端目标。

在 JDK 8 里,默认收集器组合就是-XX:+UseParallelGC,即 Parallel Scavenge(新生代)+ Parallel Old(老年代)。这也是为什么很多 JDK 8 应用即使什么都不配,跑得也还不错——默认值就是为吞吐量场景准备的。如果你的业务是批处理、离线计算、对 GC 停顿不敏感但对总时长敏感,Parallel 就是最合适的选择。

Parallel Old 的算法是标记-整理,它在老年代回收时做对象移动,天然没有 CMS 的碎片问题,而且并行执行,在 JDK 8 默认组合里表现相当稳。多数情况下,如果你不追求低延迟,根本不需要换收集器。我见过太多团队一上来就换 G1,完全没有分析当前停顿时间是不是真的有问题,属于典型的盲目跟风。

4.4 G1:服务端默认收集器,Region 化是最大创新

G1(Garbage First)从 JDK 9 开始成为默认收集器,它把整个堆分成一个个大小相等的 Region,每个 Region 在逻辑上可能扮演 Eden、Survivor、Old、Humongous 等角色,用一张可变的“棋盘”代替了物理上连续的新生代/老年代结构。

G1 的核心设计有两个创新点,理解了它们,G1 的所有行为都能推导出来。

第一,预测式停顿模型(Pause Prediction Model)。G1 的回收单位不是“代”而是“Region 集合”,每次 GC 根据历史统计,估算回收若干个 Region 带来的停顿时间,从而动态决定回收多少个 Region。这让 G1 能一定程度上做到“可预测停顿”:设置-XX:MaxGCPauseMillis(默认 200ms),G1 会尽量把单次 GC 停顿控制在这个范围附近。

第二,Remembered Set(RSet)和写屏障。跨 Region 引用是 G1 最头疼的问题。如果老年代 Region 里的对象引用了新生代 Region 里的对象,Minor GC 扫描新生代时,必须知道这些外部引用,否则就会误判。G1 用 RSet 记录“谁引用了我这个 Region”,通过写屏障(Write Barrier)在对象引用被修改时同步更新每一份 RSet。代价是:写屏障带来额外开销,RSet 占用额外内存,这是 G1 比 Parallel 占用更多资源的原因。

G1 的老年代回收分两个模式:并发标记周期(Concurrent Marking Cycle)和混合回收(Mixed GC)。

  • 并发标记周期:和 CMS 的流程类似,有初始标记、并发标记、最终标记;
  • 混合回收:在标记完成后,回收的 Region 集合不只有新生代,还包括一部分“垃圾最多”的老年代 Region,所以叫 Mixed GC。

G1 相比 CMS 最大的优势是没有伴随性碎片问题(回收时做复制整理)、没有并发模式失败那么频繁(因为有停顿预测模型),以及能处理大堆(默认建议堆大小 4GB 以上)。但 G1 在小堆上表现反而不如 Parallel,因为 Region、RSet、并发标记的维护开销在小堆上是纯负担。这也是“默认收集器”不等于“所有场景最优收集器”的典型例证。

G1 还有一个特殊的对象:Humongous Object(巨型对象)。大于等于 Region 大小一半的对象被称为巨型对象,会直接分配到老年代的巨型 Region(Humongous Region)里,不参与新生代回收。巨型对象区域回收时,如果对象是死的,在并发标记周期会被回收;但分配失败时,会触发一次 GC。巨型对象过多是 G1 场景下多老年代碎片和高停顿的常见原因。

4.5 ZGC / Shenandoah:迈向亚毫秒停顿

ZGC 是 JDK 11 引入、JDK 15 转正的实验性低延迟收集器,目标是把 GC 停顿控制在 10ms 以下甚至亚毫秒级。它的核心手段是着色指针(Colored Pointer)和读屏障(Load Barrier)。

着色指针是 ZGC 最大的创新:把 64 位指针的高 4 位用于标记状态(Marked0、Marked1、Remapped、Finalizable),JVM 通过指针本身就能判断对象是否被标记、是否被移动过,不需要像 G1 那样访问对象头。读屏障则在每次指针读取时做一次检查,如果发现对象被移动,立刻转发到新地址。这让 ZGC 可以把标记、整理的大部分工作与业务线程并发执行,把 STW 时间压缩到极小。

但 ZGC 也有非常现实的门槛:

  • JDK 11+,并且到 JDK 15 释放后才基本稳定;
  • 内存占用更高,着色指针和读屏障都需要额外开销;
  • 实现依赖 Linux;macOS 和 Windows 的支持后来才完善;
  • 对逻辑核心数非常敏感,低核数机器收益不显著。

我对 ZGC 的建议是:如果业务要求极致的低延迟(比如高频交易类、在线交互响应),并且已经跑在较新版本的 JDK 上,那么在足够大的堆和足够强的硬件上,ZGC 值得认真评估;否则,现阶段先把 G1 吃透,是性价比最高的路径。

5. 收集器与算法、GC 类型的对应关系

这一节是对前面所有内容的一次归拢,帮你建立一张“总览表”。很多人收藏了一堆收集器介绍,但一到实际场景就不知道用哪套组合,本质上是因为脑子里没有这张对应表。

5.1 收集器与算法的配套关系

收集器新生代算法老年代算法并发性默认搭配的 JDK
Serial标记-复制标记-整理STW,单线程JDK 8 客户端模式
ParNew标记-复制标记-整理(搭配 CMS 时除外)STW,多线程配合 CMS 的经典组合
Parallel Scavenge + Parallel Old标记-复制标记-整理STW,多线程JDK 8 服务端默认
CMS标记-复制(搭配 ParNew)标记-清除老年代并发标记/清除,其余 STWJDK 8 低延迟场景
G1分区复制(Region 内移动)分区复制 + 并发标记周期部分阶段并发,整体较短 STWJDK 9+ 默认
ZGC着色指针 + 分区着色指针 + 读屏障几乎全并发JDK 15+ 实验性

这张表最核心的一个信息是:同一区域的回收算法在不同收集器里并不相同。老年代在 CMS 里是标记-清除,在 Parallel Old 里是标记-整理,在 G1 里是“复制到其他 Region + 并发标记“。所以你不能笼统地说“老年代 GC 一定用什么算法”——必须绑定具体收集器。

5.2 收集器如何映射到 Minor / Major / Full GC

每种收集器在执行“Minor GC”“老年代 GC”“Full GC”时,动作细节也不太一样:

  • Parallel Scavenge 系列:Minor GC 是新生代复制;老年代 GC(Major/Full)是标记-整理,STW;
  • CMS 系列:Minor GC 是 ParNew 的 STW 复制;老年代 GC 是并发标记-清除(多数时间与业务并发);但如果 Concurrent Mode Failure,则退化为 Serial Old 的 Full GC;
  • G1 系列:Young GC 只回收新生代和部分未被整理的 Region;Mixed GC 回收新生代 + 部分老年代 Region;真正的 Full GC 是串行 STW 的兜底行为;
  • ZGC 系列:ZGC 不区分年轻代/老年代,只有一个逻辑意义上的堆,GC 目标是一次全堆的极短停顿回收。

所以,当你看到一份 GC 日志写着Full GC,千万不要一上来就归结为“老年代满了”。在 G1 里,可能是 Humongous 分配失败;在 CMS 里,可能是 Concurrent Mode Failure;在 Parallel 里,才大概率是单纯的老年代不足。定位问题前,先确认收集器类型,再用 Cause 字段辅助判断,这是最基本但最容易被忽略的专业素养。

5.3 一个最容易踩的坑:把 NewRatio 和 SurvivorRatio 搞反

这里我想专门讲一个和“GC 类型”强相关的参数坑,因为它直接决定了 Minor GC 和 Full GC 的触发频率,很多人在这里翻过车。

  • -XX:NewRatio:老年代 / 新生代的比例。默认 2,即老年代:新生代 = 2:1;
  • -XX:SurvivorRatio:Eden / Survivor 的比例。默认 8,即 Eden:S0:S1 = 8:1:1。

我在一个某支付网关项目里见过一个“调优反例”:为了减少 Full GC,把 NewRatio 调成了 1,想让新生代更大、让对象多活一会、晚点进老年代。结果 Minor GC 确实少了,但 Eden 和 Survivor 都变大,存活对象复制成本上升,每次 Minor GC 的时间变长;更糟糕的是,因为新生代太大,老年代被压缩到很小,稍微来一个高峰期,一大批对象晋升直接撑爆老年代,Full GC 反而更频繁了。这个案例告诉我们:调整代比例必须看对象存活率和对象晋升速率,不能只看单一指标。

6. 收集器选型与关键参数配置建议

选型不是“哪个新用哪个”,要结合业务特点、堆大小、硬件配置、延迟要求四个维度来做。下面是我的实战选型框架,直接可以抄作业。

6.1 四个维度的选型判断

业务特征堆大小延迟敏感度推荐配置原因
批处理 / 离线计算 / 日志分析任意低Parallel Scavenge + Parallel Old吞吐量优先,GC 次数少
Web 服务 / API 网关(响应时间敏感)4GB~32GB高G1可预测停顿,Mixed GC 灵活
小堆低并发,单机小应用<4GB低Serial 或 Parallel维护成本低,单次停顿可接受
高并发低延迟 + 大堆>32GB极高ZGC 或后续版本的新方案亚毫秒停顿,但需新 JDK
老年代碎片明显,分配大对象频繁任意中从 CMS 迁移到 G1避免 Concurrent Mode Failure

这个表只是一个起点,真正的决策要用 GC 日志说话。我个人的经验是:先跑一两天现网数据,分析 Minor GC 频率、每轮晋升速率、老年代占用曲线,再对照上表决定。不要一上来就改配置,那是瞎优化。

6.2 一套可直接落地的 G1 参数模板

如果你的业务已经决定用 G1,下面是一套我经过多轮线上验证的基准模板,可以直接用,但要根据实际堆大小调整:

-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=45 -XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=60 -XX:ConcGCThreads=2 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/logs/heapdump.hprof -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/logs/gc.log

几个关键参数的解释:

  • -XX:G1HeapRegionSize=4m:Region 大小,堆 8G 时默认约 1MB~4MB,设置成 4m 有利于减少大对象跨 Region 的频率。如果你有大对象频繁分配,可以考虑调大到 8m,但要警惕大 Region 带来的回收粒度变粗问题;
  • -XX:InitiatingHeapOccupancyPercent=45:整个堆占用率到 45% 时启动并发标记周期。这个值默认是 45,很多人误以为它越高越好,设到 70 以上;但如果并发标记周期启动太晚,会导致老年代在标记完成前被填满,反而触发更频繁的 Mixed GC。45 是一个在多数场景下比较稳的值;
  • -XX:ConcGCThreads=2:并发标记线程数。默认是 CPU 核数的 1/4 左右。在 8 核以下的小型机器上,手动调低可以减少对业务线程的挤占。

这套模板跑起来后,再根据日志里的停顿分布、Mixed GC 次数微调,一般一两轮就能稳定下来。

6.3 CMS 迁移到 G1 时最容易忽略的差异

如果你还在 JDK 8 上跑 CMS,想迁到 G1,有几个差异一定要提前评估:

  • 新生代大小策略不同。CMS 的-XX:NewSize和-XX:MaxNewSize在 G1 里不再直接适用,G1 靠G1NewSizePercent和G1MaxNewSizePercent动态控制新生代占比。迁移后你会看到新生代大小会频繁波动,这是正常的;
  • 大对象处理不同。CMS 里的大对象直接进老年代,且是连续空间;G1 里超过 Region 一半的对象进 Humongous Region,而 Humongous Region 分配失败会触发一次特殊的 Full GC。所以 G1 场景下,避免分配超大数组和超大缓存结构是老年代健康的重要前提;
  • 触发 Full GC 的隐蔽原因不同。CMS 的 Concurrent Mode Failure 在 G1 里不太常见,但 G1 的 Humongous 分配失败、晋升失败、元空间扩容等原因增多。迁过去后,排障路径差异很大,别拿 CMS 的排查经验直接套。

7. 实战排查:一次从 GC 日志到配置落地的完整过程

理论讲得多扎实,不如真实排查一次。我拿一个虚构但非常有代表性的某在线报表服务来复盘完整的排查链路,这个场景 90% 的团队都会遇到,你可以照着步骤在自己系统里复现。

7.1 症状与初始数据:不是所有卡顿都是 GC 造成的

某报表服务,JDK 8,堆 16G,默认 Parallel 组合。业务高峰期(每天上午 10 点)出现明显的接口响应变慢,平均 RT 从 120ms 涨到 3s+,偶尔出现超时。运维同事的第一反应是“部署两台机器扛一下”,但规模扩大后问题依旧。我们介入后,第一步不是调参,而是先抓 GC 日志和线程栈。

jstat -gcutil <pid> 1000 20

输出样例:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 68.12 82.45 14.50 12.20 1253 2.482 0.000 0.000 2.482

这个输出透露了几个关键信息:

  • O(老年代占用率)82.45%,这已经很高了,接近触发 Full GC 的边缘;
  • FGCT = 0,说明还没有发生 Full GC,但老年代持续增长的趋势很明显;
  • YGCT = 2.482 秒,YGC = 1253 次,平均每次 Minor GC 只要约 2ms,这个速度本身可接受。

如果是老年代不足导致的 Full GC,FGCT 早就会涨起来。现在 FGC = 0,说明当前的卡顿大概率不是 Full GC 引起,而是老年代占用持续高位 → 对象晋升路径变窄 → Minor GC 后半段晋升失败(Promotion Failed),晋升失败本身会尝试先触发一次老年代 GC,进一步拖长停顿。

7.2 用 GC 日志定位根因:Cause 字段是灯塔

继续打开 GC 日志,重点搜三个关键词:

grep "Full GC" gc.log grep "Promotion Failed" gc.log grep "Allocation Failure" gc.log

真实日志里会看到类似这样的关键行:

2024-01-15T10:05:23.123+0800: 12345.678: [GC (Allocation Failure) [PSYoungGen: 524288K->58982K(614400K)] 1048576K->589982K(1572864K), 0.0234567 secs] 2024-01-15T10:05:24.456+0800: 12346.789: [Full GC (Ergonomics) [PSYoungGen: 58982K->40960K(614400K)] [ParOldGen: 799539K->810000K(1392640K)] 858521K->850960K(2007040K), 6.5321098 secs]

第一行是 Minor GC,速度不错;第二行 Full GC 的 Cause 是Ergonomics,意思是JVM 自适应调整认为需要做一次 Full GC 来帮助系统“校准”代际比例。这种情况在 Parallel 收集器里很典型:当老年代并发增长率异常时,Parallel 的 Ergonomics 会自动触发 Full GC。而根本原因往往是:新生代晋升速率突然升高,即大量对象从新生代快速进入老年代。我们需要找到是谁制造了大量“长命对象”。

7.3 找到晋升速率升高的元凶:从堆转储里看大对象

用jmap -histo:live <pid> | head -50看老年代里占用最多的类。这里常见的问题有两种:

  • 一种是缓存类对象大量常驻,比如把数据库查询结果整表放进本地缓存,且缓存没有过期淘汰策略;
  • 另一种是大数组/大集合被频繁创建,比如报表服务把一次查询的所有明细一次性加载到内存,在计算完再释放——这些对象生命周期跨越了多次 Minor GC,被晋升到老年代。

在我们的案例中,-histo:live输出的前几名都是某报表查询结果对象,占用老年代 60% 以上。进一步看代码,发现某接口里有一个 N+1 查询模式:先查一批订单,再逐个查明细,每次查询都构造一个几十万大小的ArrayList,处理完即弃。这种对象往往能撑过至少一轮 Minor GC,因为它们在函数执行期间一直被引用,执行完多轮 GC 前就可能被晋升到老年代。对象晋升的路径是:在 Eden 分配 → 撑过第一轮 Minor GC → 复制到 S0 → 撑过第二轮 → 复制到 S1 → 第三轮如果 S1 放不下,直接晋升老年代。那份 80 万元素的大 List,只要活过一到两轮 Minor GC,就会因为 S1 容量不足直接被晋升。

7.4 修复方案:代码优先,参数最后

我们当时的修复分三步走:

  1. 代码层:把明细查询改成批量查询,减少临时大对象的构造;给报表结果加一个带 TTL 的本地缓存,避免重复计算和重复分配。这是收益最大的一步,GC 频率直接降了一半以上;
  2. 参数层:把堆从 16G 调整为 24G,并把新生代比例从默认的 1:2 调整为 1:1(-XX:NewRatio=1),让新生代能容纳更多短期对象,降低晋升速率;但这一步必须在观察完对象分配速率后做,否则就像前面提到的那样适得其反;
  3. 验证层:改完后用jstat持续观察,确认老年代占用率稳定在 50% 以下,Full GC 不再出现,RT 恢复。

整个过程耗时两天,但真正动手调参数的时间不到一小时。更多的时间花在分析对象流向上——这就是为什么我反复强调:GC 调优的第一生产力,是理解对象生命周期,而不是会背参数。

7.5 总结这张排查地图

你可以把这张地图保存下来,遇到任何 GC 问题,按序执行:

  1. 抓 GC 日志,看 Cause 字段(是 Allocation Failure、Ergonomics、System.gc() 还是 Metadata GC Threshold);
  2. 看 YGC/FGC 次数和耗时分布,判断问题在新生代还是老年代;
  3. 用jstat -gcutil看各代占用率趋势,确认是分配速率过高还是晋升速率过高;
  4. 用jmap -histo:live或堆转储分析工具看大对象、长命对象;
  5. 修复代码或调整参数,优先代码,其次参数,最后才是堆大小;
  6. 改完后持续观察至少一整天,确认稳定性。

8. 我的几点实操体会

文章写到这里,回到开头那个判断:GC 从来不是玄学,而是一套可以完全用数据驱动的系统工程。我自己的体会是,最难的其实不是理解某一个收集器,而是建立“对象生命周期 → 分区 → GC 事件 → 调优动作”这条完整的思维链条。

有一点想重点提醒:不要盲目追求“不 GC”。有些团队为了让 GC 日志好看,把堆无限调大,结果对象全部堆积到老年代,GC 没发生,但内存占用持续高位,一旦发生一次 GC,停顿时间反而爆炸。GC 的真相是:合理的 GC 频率和停顿时间,才是健康的指标,而不是“越少越好”。

另一条经验是:GC 排查一定要结合业务峰值。同一个服务,凌晨三点和上午十点的对象分配模式完全不同,只看低峰期的日志,会得出完全错误的结论。我建议在任何调优动作完成后,至少经过一个完整业务周期的验证,再确认生效。

最后分享一个小技巧:在排查阶段,我习惯把 GC 日志里的Cause字段按天统计一次,做成一个小表格。比如一天内Allocation Failure出现多少次、Ergonomics出现多少次、System.gc()出现多少次。这个统计表几乎能一眼看出问题的大方向,比盯着复杂曲线有用得多。你可以在自己的系统里也尝试一下,我相信你会发现一些之前完全没注意到的规律。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 7:21:28

Qwen3.8-27B部署实战:量化策略与硬件协同优化指南

1. 为什么“部署Qwen3.8-27B”这件事&#xff0c;现在突然变得既迫切又棘手最近两周&#xff0c;我连续接到三类不同背景的咨询&#xff1a;某高校自然语言处理方向的硕士生A同学&#xff0c;在复现一篇新论文时发现&#xff0c;原作者用的是Qwen3.8-27B做指令微调基座&#xf…

作者头像 李华
网站建设 2026/10/12 7:20:57

claude-mem实战:给Claude Code外挂一个长期记忆库

1. 从"每次都重新自我介绍"说起如果你用过 Claude Code 这类命令行 AI 编程工具&#xff0c;大概率经历过一种"既爽又憋屈"的体验&#xff1a;爽的是它真的能撸代码、改 bug、写测试&#xff0c;憋屈的是——它记性太差。上午刚跟它讨论完项目的模块划分&a…

作者头像 李华
网站建设 2026/10/12 7:20:49

SAM-3 Lite-Text模型ONNX到TensorRT推理部署与优化实战

1. 拆解这个标题背后的真实需求1.1 标题里藏着的三个关键词“SAM-3 Lite-Text onnx/tensorrt模型推理”这个标题&#xff0c;乍一看像是某个开源项目的部署记录&#xff0c;但拆开来看&#xff0c;它其实包含了三层信息。第一层是模型本身——SAM-3 Lite-Text&#xff0c;这是一…

作者头像 李华
网站建设 2026/10/12 7:15:45

5ire 开源桌面 AI 助手:MCP 客户端配置与 TaoToken 接入实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 7:14:35

C/C++ JSON实战:字符串转JSON、数组解析与序列化避坑指南

1. 从一段字符串说起&#xff1a;为什么C/C里处理JSON总让人头疼如果你写过C或者C&#xff0c;大概率遇到过这样的场景&#xff1a;设备上报了一串数据&#xff0c;格式是JSON&#xff0c;你需要把它解析出来&#xff0c;取出某个字段做判断&#xff1b;或者反过来&#xff0c;…

作者头像 李华
网站建设 2026/10/12 7:10:10

SQL Server数据库质疑与损坏修复:DBCC CHECKDB实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华