
最近在帮团队做线上服务的GC优化时我发现很多同学对垃圾收集器的认知还停留在“Parallel还是CMS”的二选一阶段这其实有点可惜——G1从JDK 9起就成为默认收集器ZGC也在JDK 15后正式转正眼下这两个角色基本承包了绝大多数Java应用的GC场景。这篇文章就围绕垃圾收集器G1和ZGC把它们的核心机制、选型依据、实战参数和踩坑记录一次性讲透适合正在做JVM性能调优、或者准备把线上的老收集器迁移到G1/ZGC的后端开发同学参考。1. 为什么G1能成为默认收集器核心机制拆解1.1 从连续分代到Region布局G1改变的根本原因传统收集器Parallel、CMS都是把堆物理地分成连续的新生代和老年代新生代又分成Eden和两个Survivor。这种布局的优点是实现简单、回收路径清晰缺点也很致命年轻代必须连续老年代回收无法做增量一旦老年代碎片严重就得靠Full GC做全局压缩停顿随堆变大而线性上升。G1反着来。它把整个堆划分成若干等大小的Region每个Region在逻辑上扮演Eden、Survivor或Old角色角色可以在不同GC周期之间动态切换。Region的默认大小由堆容量推算通常把堆切分成2048个左右范围是1MB到32MB也可以手动指定。这是一次关键的抽象升级回收粒度从“整个代”细化到“单个Region”。这个设计带来的最直接收益是增量回收成为可能。G1每个GC周期只回收一部分Region而不是整个老年代从而把停顿控制在一个可预测的范围内同时Region之间允许自由分配与腾挪老年代不再需要一次全堆压缩碎片化问题天然缓解。注意Region只是逻辑上的抽象物理地址不要求连续。这也是后来ZGC敢玩大内存、GB级堆的关键前提。1.2 “可预测停顿”的秘密停顿预测模型与Garbage FirstG1全称是Garbage First这个名字不是白叫的。它的核心调度逻辑是每次回收前先根据历史统计数据估算回收每个Region所需时间和能够释放的空间然后优先回收“单位时间内回收收益最高”的Region集合。这一步由停顿预测模型完成模型依据是近几次GC的实际回收速率、存活对象占比、分配速率等指标通过加权平均和方差估算得出。配合-XX:MaxGCPauseMillis参数默认200msG1会尽量把一次停顿控制在目标值以内。注意“尽量”这个词——它并不是绝对保证而是基于统计模型的软目标。如果业务分配压力太大、存活对象太多G1可能完不成目标最终退化成Full GC。整个G1周期可以分成三类Young GC只回收Young RegionSTW停顿通常几毫秒到几十毫秒。Mixed GC在Young GC之后额外回收部分Old Region用来渐进式清理老年代。Full GC兜底方案当Mixed GC来不及或者Humongous分配失败时触发一次完整STW压缩。这套机制要落地还离不开两个辅助结构Remembered SetRSet和Card Table。RSet记录了哪些Region引用了当前Region的对象Mixed GC时不需要全堆扫描就能知道哪些外部对象指向待回收Region每次引用写入时通过写屏障把相关的Card标记到RSet里。这是G1能够“并发标记 集中回收”的重要前提。1.3 并发标记背后的算法三色标记与SATBG1的并发标记借鉴了CMS的思路用三色标记法描述对象状态白色代表不可达待回收、黑色代表已扫描完、灰色代表正在扫描。并发标记阶段应用线程和GC线程同时运行对象图在标记过程中持续被修改如果不加保护就会出现两类问题把本应回收的对象标成存活浮动垃圾可容忍或者把本应存活的对象标成可回收漏标不可容忍。G1使用**SATBSnapshot At The Beginning起始快照**来解决漏标。它在并发标记开始时就记录下存活对象的快照并用写屏障捕获并发期间的引用变化将变更记录保存。标记结束后的Remark阶段再处理这些变更保证在任何情况下都不会漏掉存活对象。和CMS对比CMS用的是“增量更新”思路是记录新增引用SATB则是“快照 记录删除引用”两者都能防漏标但SATB在并发对象图遍历上更稳这也是G1取代CMS的底气之一。1.4 G1的完整GC周期从Young到Mixed一个典型的G1周期大致如下Young GC回收全部Young Region存活对象晋升到Survivor或Old。并发标记周期Initial MarkSTW标记根对象并配合Young GC完成起始快照。Concurrent Mark并发遍历对象图应用线程不暂停。RemarkSTW处理SATB缓冲完成最终标记。Cleanup统计各Region存活率更新停顿预测数据这个阶段大部分是并发的。Mixed GC根据停顿预测模型选择一批存活率低的Old Region和全部Young Region一起回收。回收后的空Region回归自由池等待重新分配。这套流程让老年代清理变成“小步快跑”不再像CMS那样必须一次性搞定。但也引入了新的复杂度如果Mixed GC频率太低老年代持续增长最终还是会触发Full GC。1.5 G1的坑Humongous对象与大对象分配G1有一个特有的大对象概念Humongous Object指大小超过Region一半的对象。这类对象不进入普通Region而是直接占用一个或多个连续Region因为没有Region会单独容纳它。Humongous对象带来两个问题。第一它们不会在Mixed GC中被回收只有并发标记周期才能回收已死的Humongous Region第二如果Humongous连续Region分配失败会立即触发Full GC。大数组、大缓存、大对象图在G1下很容易踩这个坑排查时GC日志里出现Humongous Allocation时就要多留个心眼。2. ZGC为什么能做到“几乎零停顿”2.1 设计目标停顿时间不再随堆大小增长G1把停顿压到了几十毫秒但对某些场景还是不够。比如在线交易、实时推荐、量化风控这类超低延迟系统任何超过10ms的STW都可能引发超时或抖动。传统收集器的问题在于堆越大GC要处理的对象越多停顿就越长。ZGC的设计目标就是打破这个线性关系——GC停顿时间不随堆大小增长把一次停顿压缩到几毫秒甚至1ms以内。ZGC从JDK 11引入实验性JDK 15转正主线版本在JDK 21引入了分代ZGC。它的实现思路和G1完全不同不维护RSet不压缩连续空间而是通过染色指针和读屏障把几乎所有的GC工作并发化。2.2 染色指针在对象指针里藏元数据ZGC最反直觉的设计是染色指针。64位平台下对象地址本来只需要低几十位ZGC借用指针的高位比特来存放状态信息包括Marked0 / Marked1两个标记位用于并发标记阶段的存活标记。Remapped标记对象是否已经完成重映射搬迁后地址修正。Finalizable标记是否为终结器可达对象。应用线程读取引用时通过读屏障检查这些标记位根据状态决定走哪条路径是否需要修正地址、是否需要更新标记。这套机制最精妙的地方在于对象搬迁后不需要立即修改所有引用地址只修改指针的标记位让应用线程在下次访问时“自愈”——把旧地址重定向到新地址。这带来两个直接好处并发转移阶段应用线程不用停内存中的引用关系也不需要全局重写。ZGC把“地址重写”从GC线程迁移到了应用线程的读路径上以每次读操作多做一点CPU开销换取了全局低延迟。2.3 读屏障与并发整理没有RSet也能干活G1要维护RSet因为它要靠RSet来快速定位跨Region引用ZGC不需要原因在于它的指针状态可自洽——当线程访问一个已被搬迁的对象时读屏障能从染色指针中识别出来并自动完成地址修正。跨Region、跨代引用对ZGC来说是透明的它根本不维护这种记录。ZGC的整个GC周期也是并发流水线并发标记、并发转移准备、并发转移、并发重映射。以分代ZGC为例年轻代和老年代各自维护小型与中型记录回收颗粒度更细停顿进一步下降。实测在几十GB堆下ZGC的STW可以稳定在1ms级别相比G1代价是更高的CPU占用和更高的内存占用指针位被占用、并发缓冲更多。2.4 ZGC的局限与取舍ZGC并非银弹。它的最大代价是吞吐量下降读屏障给每一次引用读取都加了开销CPU密集型的低延迟场景还能接受但吞吐优先的批处理任务用ZGC往往是负优化。其次ZGC早期版本的内存占用比G1高不少因为并发结构需要额外空间分代ZGC落地之后这个开销有明显改善但在超大堆上的优势依然明显。还有一个容易被忽略的细节ZGC的背景线程很消耗CPU并发标记、转移、重映射线程数需要根据机器核数显式控制否则默认值可能开得过多把业务CPU挤爆。3. G1还是ZGC选型对比与决策建议3.1 核心参数对比速查维度G1ZGC默认启用JDK 9JDK 15JDK 21后用-XX:ZGenerational启用分代堆布局Region1MB~32MB染色指针 并发转移无Region概念停顿时间10ms~200ms级别受堆大小影响1ms~10ms级别受堆大小影响小吞吐量高接近Parallel中读屏障有额外CPU开销CPU占用低中高内存额外开销RSet/Card Table并发缓冲、指针元数据对象搬迁Mixed GC分批次搬并发搬引用自愈适用堆大小中小堆 ~ 几十GB大堆数十GB~TB级典型场景常规微服务、服务器应用在线交易、实时推荐、大堆低延迟3.2 业务场景匹配三原则我自己的选型经验是三个问题第一能不能接受几十毫秒的停顿能接受用G1完全不能接受用ZGC。大多数RPC服务、定时任务、异步处理G1默认参数甚至不需要怎么调就够用。真正需要ZGC的是那些把GC停顿直接换算成业务MTTR或SLA损失的系统。第二堆到底多大4G以下的小堆G1和ZGC差距不明显ZGC的CPU开销反而更扎眼16G以上、延迟敏感的老年代堆积严重的服务ZGC的优势才会被放大。堆越大、延迟越敏感越值得切ZGC。第三业务是CPU密集还是IO密集IO密集型大量网络等待、缓存访问应用线程CPU压力不大读屏障开销相对可接受纯计算型应用ZGC会明显拖慢吞吐还是G1更合适。3.2 一个真实切换案例供参考我在一个订单查询服务上做过G1到ZGC的实测8核16G容器堆设为6G请求模型是高频查询 少量写G1配MaxGCPauseMillis100时TP99约80msGC停顿均值25ms切换到ZGC后TP99降到40msGC停顿均值不到3ms代价是CPU占用从30%升到42%。这里要说明的是TP99的提升并不全来自GC停顿ZGC更短的停顿减少了线程阻塞也减少了应用线程在安全点上的等待。但CPU上涨是实打实的所以最终是否采用需要在业务方接受SLA的前提下让运维确认CPU余量充足。3.3 迁移到ZGC前必须检查的三件事确认JDK版本至少JDK 15生产环境建议JDK 17 LTS或更高JDK 11的ZGC不够成熟不要上生产。检查第三方库的对象分配模式如果业务大量创建大数组、大缓存的临时对象ZGC的内存开销可能比预期高。压测验证不要直接切先在压测环境跑满Load观察GC日志中的停顿分布和CPU趋势。4. 实操配置与GC日志排查从启动参数到问题定位4.1 可直接抄作业的启动参数模板G1生产参数示例java -XX:UseG1GC \ -Xms6g -Xmx6g \ -XX:MaxGCPauseMillis200 \ -XX:ParallelGCThreads8 \ -XX:ConcGCThreads2 \ -Xlog:gc*:filegc-%t.log:time,uptime,level,tags \ -jar app.jar说明-Xms与-Xmx相等避免堆自动伸缩带来的毛刺ConcGCThreads控制并发标记线程数一般按核数的12.5%左右设置GC日志用JDK 11的统一-Xlog语法输出到独立文件。ZGC生产参数示例java -XX:UseZGC \ -Xms6g -Xmx6g \ -XX:ConcGCThreads2 \ -XX:ZGenerational \ -Xlog:gc*:filegc-%t.log:time,uptime,level,tags \ -jar app.jar-XX:ZGenerational在JDK 21后启用分代ZGC对年轻代回收更频繁整体CPU开销比不分代低不少。如果你的JDK是21建议一定开启。4.2 读懂GC日志关键字段G1日志示例[0.518s][info][gc,start] GC(0) Pause Young (Normal) (G1 Evacuation Pause) [0.518s][info][gc,heap ] GC(0) Eden regions: 14-0(22), Survivor regions: 0-2(3), Old regions: 0-0 [0.518s][info][gc,stats ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 22M-9M(6144M) 3.214ms这里重点看最后一行22M-9M是回收前后堆占用6144M是总堆大小3.214ms是本次STW时长。如果日志中频繁出现Pause Young (Mixed)说明老年代Region也被回收Mixed GC周期正在进行出现Full GC时就要警觉它意味着G1的软目标已经失控。ZGC日志示例[1.042s][info][gc] GC(1) Garbage Collection (Proactive) [1.042s][info][gc] Phase Mark 3/3 completed in 4.1ms [1.042s][info][gc] Phase Relocation 1/1 completed in 1.2ms [1.042s][info][gc] Memory: 4096M-3072MZGC把每个Phase的耗时单独打印Phase Mark和Phase Relocation的耗时之和基本就是一次GC的停顿窗口。正常情况每个Phase都应该远小于一次G1 Young GC。如果Relocation时间持续飙升往往意味着对象搬迁量大需要关注分配速率和存活对象占比。4.3 日志排查三步法第一步看停顿分布汇总GC耗时确认P95/P99停顿是否符合预期。G1超过200ms、ZGC超过10ms的日志点一定要定位到具体Phase。第二步看堆变化趋势观察回收前后堆占用、腾出的空间是否持续下降。如果堆占用长期高位说明存活对象多、分配压力大这时候调参数不如改代码优先检查是否有不必要的对象缓存或大对象滞留。第三步看Full GC/Allocation StallG1的Full GC、ZGC下偶尔的Allocation Stall都是严重信号。它们通常意味着并发回收赶不上分配速率优先排查是否有大对象、过大的TLAB尺寸、或者ConcGCThreads设置过低。5. 常见问题速查与独家避坑经验5.1 常见问题速查表高频问题可能原因处理方向G1频繁Full GCMixed GC赶不上老年代增长调低MaxGCPauseMillis反而会恶化优先增大堆、降低存活率G1出现Humongous Allocation失败大对象连续Region分配失败调大G1HeapRegionSize或改造成分片数组ZGC内存占用比G1高并发缓冲与指针元数据开销开启分代ZGC默认情况下预留20%内存给GCZGC CPU飙升ConcGCThreads过多或分配速率过高显式设置ConcGCThreads排查对象分配热点GC日志文件无限增长未指定filesize与filecount-Xlog:gc*:filegc.log:filesize10m,filecount20容器内核数识别错误容器CPU限制与JDK默认值不匹配JDK 10默认支持容器感知但显式ActiveProcessorCount更稳线上频繁安全点STW大循环、JIT编译压力、偏向锁标记加上-XX:PrintSafepointStatistics排查安全点日志5.2 我踩过的几个坑第一个坑是给G1的停顿目标设太低。曾经为了追求TP99漂亮把MaxGCPauseMillis压到50ms结果Mixed GC频繁且每次回收的Region太少老年代越积越多两周后开始出现Full GC反而更伤延迟。后来回到200ms默认值Full GC消失整体表现更好。G1的停顿目标本质上是一个“软约束”设得太激进等于逼它频繁做小步回收效率反而低。第二个坑是迁移ZGC只看停顿没看CPU余量。有一次在4核小规格容器上切ZGC延迟确实降下来了但CPU打满业务线程纷纷排队最终整体时延甚至超过G1。后来这类低核数容器我基本不推荐ZGCG1在这个规模下反而是最优解。第三个坑是忽视GC日志的保留策略。生产环境的gc.log如果不限制大小两周能写满几个GB把磁盘塞满后引发一系列连带告警。现在我的日志参数固定带上filesize和filecount同时按天滚动既能追溯问题也不会拖垮磁盘。5.3 最后的调优建议我个人现在的操作习惯是新服务无脑G1默认参数上线跑一周采集GC日志再根据停顿分布决定要不要调参数、要不要换ZGC。不要一上来就追求“最强黑科技”G1默认值本身就非常耐用很多服务根本不需要额外调参。如果业务确认需要ZGC也不要一步到位精简参数。先在压测环境用默认配置跑通记录CPU基线和停顿基线再逐步收敛ConcGCThreads和堆大小。GC调优从来不是比谁参数设得炫而是比谁更懂自己服务的对象分配模型和延迟底线。这两款收集器各有脾气把它们的原理和日志读明白了你才能在“低停顿”和“高吞吐”这对天平上找准自己应用的位置。