虚拟机回收算法没有银弹,核心是在吞吐量、停顿时间和内存占用之间做权衡;选型时先看应用场景是延迟敏感型还是吞吐优先型,再决定用分代、G1还是ZGC。
虚拟机回收算法有哪些:从分代到低延迟
基础回收算法:标记-清除、复制与标记-整理
JVM的垃圾回收算法演进,本质上是在解决三个问题:哪些内存可回收、怎么高效回收、怎么减少回收时的停顿,早期基础算法奠定了整个体系的基础。
- 标记-清除(Mark-Sweep):先遍历对象图标记存活对象,再统一清除未标记对象,该算法实现简单,但会产生大量内存碎片,后续分配大对象时容易触发频繁的Full GC。
- 复制(Copying):把堆内存分成两块,只使用一块,GC时把存活对象复制到另一块,然后整块清空,内存分配效率高,不发生碎片,但可用内存空间被压缩一半,HotSpot虚拟机的新生代就采用这个思路,把Eden区与两个Survivor区按8:1:1比例划分。
- 标记-整理(Mark-Compact):标记阶段与标记-清除一致,但后续将所有存活对象向内存一端移动,然后清理掉边界以外的内存,避免碎片,但移动对象会更新引用,增加停顿时间,老年代常用该算法或其变体。
现代垃圾回收器:CMS、G1、ZGC和Shenandoah
算法是理论,垃圾回收器是工程实现,各大JDK版本演变,催生了针对不同场景的回收器。
- CMS(Concurrent Mark Sweep):主打低停顿,并发标记和并发清除,问题是并发模式失败时退化为Serial Old,且存在浮动垃圾和内存碎片,JDK 9开始被废弃,JDK 14正式移除。
- G1(Garbage First):把堆划分为多个Region区域,维护优先列表,优先回收价值最大的Region,在可控停顿时间内尽可能提高吞吐量,兼顾延迟和吞吐,成为JDK 11之后服务端默认垃圾回收器。
- ZGC(Z Garbage Collector):基于Region内存布局,染色指针和读屏障实现几乎全部的并发,停顿时间不随堆大小增长而增长,JDK 15进入正式版本。
- Shenandoah:与ZGC目标类似,JDK 12引入,采用转发指针和碰撞指针,后续版本不断优化,适配需要低延迟的应用。
g1和zgc怎么选:延迟与吞吐的权衡
这是现阶段最常被问到的对比,毕竟从JDK 17到JDK 21,G1和ZGC是最主流的两个选择。
从延迟上看,ZGC的设计目标是尽量把停顿压制在10毫秒以内,甚至更短,G1的停顿预测模型依赖历史数据,默认目标为200毫秒,实际调优后可降到50毫秒左右,但堆越大、对象分配越快,越难保证稳定低延迟。
从吞吐量上看,G1在多数业务场景下更占优势,ZGC引入了染色指针等额外开销,读屏障和并发处理会占用一定CPU资源,如果应用对停顿不敏感,且追求更高CPU利用率,G1往往比ZGC表现更好。
从堆大小与JDK版本看,堆小于4GB时可以优先考虑G1,堆大于16GB且对停顿敏感时,ZGC是更合适的选择,JDK 21中ZGC还支持了分代模式,进一步缩小了与G1在吞吐量上的差距。
选择建议如下:
- 交易系统、实时推荐、游戏服务端等要求毫秒级响应的场景,选ZGC或Shenandoah。
- 数据处理、离线任务、Web应用等追求高吞吐且能容忍秒级停顿的场景,G1仍是稳妥之选。
- 小堆内存且无需复杂调优的场景,可以用JDK默认的G1加上
-Xmx和-Xms启动参数,先跑起来再观察日志。
如何选择适合场景的回收策略:三步定位法
面对五花八门的算法和回收器,直接背参数没有意义,建议用下面的思路落地。
第一步:明确业务容忍的停顿标准
跟业务方确认两个问题:单次GC最长能接受多久?一分钟内能接受几次Full GC?如果答案是不能超过50毫秒且不允许Full GC,那么CMS就已经出局,G1需要精细调优,ZGC几乎不需要干预,如果业务是跑批任务,凌晨执行,一次Full GC停顿十几秒也无所谓,那G1甚至Parallel GC都完全够用。
第二步:观察当前GC日志与内存变化
在测试环境或灰度环境,用以下参数打印GC日志:
-Xlog:gc:file=gc.log:time,uptime,level,tags
多跑一段时间,观察Young GC的频率、平均停顿和Full GC的次数,如果新生代每次GC后存活对象比例波动大,说明对象生命周期不稳定;如果老年代持续增长且Full GC频繁,说明堆配置或回收器选择不合理。
进一步配合jstat -gcutil和jmap -histo查看各代占用和对象分布,这些是JDK自带的工具,不需要额外安装,操作路径为:启动应用后,在命令行直接执行jstat -gcutil <pid> 1000 10,每1秒采样一次,共采样10次。
第三步:根据场景特征确定回收策略
行业共识认为,没有最好只有最合适的回收器,判断依据可从以下维度展开。
- 对象存活率:新生代存活率低(小于10%)时,复制算法的效率极高,存活率超过50%时,复制算法会频繁把对象拷来拷去,不如标记-整理实在。
- CPU核心数:ZGC和Shenandoah需要较多CPU线程做并发标记和并发清理,如果服务器只有2核4线程,G1更省心,ZGC的CPU消耗可能挤占业务线程资源。
- 堆内存大小:大堆(几十GB甚至上百GB)遇到G1容易因并发标记周期长带来较长的停顿,ZGC在超大堆上的优势更明显。
- JDK版本与生态:老项目还在JDK 8上,只能选G1或CMS,ZGC根本没法用,新项目直接奔JDK 17,老老实实用G1起步,等真的遇到长停顿再切ZGC也不迟。
分代回收机制:新生代、老年代与元空间
不管选哪种回收器,理解分代模型能让调优更有章法。
新生代与老年代的回收路径
对象优先在Eden区分配,Eden区填满触发Minor GC,存活对象进入Survivor区,经历一定次数的Minor GC后,年龄达到阈值(默认15)的对象晋升到老年代,大对象直接进入老年代,避免在Eden和Survivor之间反复复制。
老年代空间不足时触发Major GC/Full GC,此时回收的代价远高于Minor GC,G1和ZGC都试图减少Full GC的发生,特别是ZGC,几乎不触发Full GC。
元空间与内存泄漏排查
JDK 8之后永久代被移除,改为元空间,元空间使用本地内存,触发回收的条件是类加载器及其加载的类无引用,频繁动态生成类的框架(如Spring、CGLIB)若使用不当,容易导致元空间溢出,排查时用jcmd <pid> GC.class_stats查看类统计信息。
以下表格对比主流回收器的核心维度,方便快速筛选:
| 回收器 | 适用的JDK版本 | 暂停目标 | 吞吐量 | 典型场景 |
|---|---|---|---|---|
| Parallel GC | JDK 8及以后 | 高 | 很高 | 批量计算、后台任务 |
| G1 | JDK 8u40+ | 可控 | 较高 | 通用服务端应用 |
| ZGC | JDK 15+ | 极低 | 中等 | 大堆、低延迟场景 |
| Shenandoah | JDK 12+ | 极低 | 中等 | 与ZGC类似,但实现不同 |
低延迟场景的回收策略实践
延迟敏感型应用怎么调
如果应用是K线行情推送或在线支付,核心诉求就是平均GC停顿在10毫秒以下,先把堆内存固定起来,-Xms和-Xmx设成相同值,避免扩容时触发Full GC,然后使用ZGC,加参数-XX:+UseZGC,再配套-XX:ConcGCThreads控制并发GC线程数,通常设置为CPU核数的四分之一到三分之一。
调完后,用线上流量压测观察P99延迟,如果延迟曲线在GC周期后出现尖刺,说明ZGC的并发周期与业务高峰重叠,可以错开GC时间或调大预留内存。
吞吐优先型应用怎么调
类似离线报表、数据清洗程序,跑得越快越好,直接使用Parallel GC,参数为-XX:+UseParallelGC,再设置-XX:ParallelGCThreads为CPU物理核数,同时适当增大新生代比例,比如-XX:NewRatio=1,让更多对象在新生代就消亡,减少晋升。
如果遇到老年代经常满了但堆内存还有不少空闲的情况,检查是否有大数组或缓存长期持有对象引用,这时候调GC参数不如修代码。
常见调优误区
- 盲目追求低延迟,给4G堆的应用强行上ZGC,可能比G1更慢。
- 堆内存设得越大越好,实际上超过物理内存会导致频繁的swap,反而拖垮性能。
- 忽略GC日志的保留时间,日志量很大但没留足够天数的历史,排障时无从下手。
聊聊R语言环境下的回收机制 (相关衍生话题)
不少统计建模或数据分析的朋友会问,R语言的GC是不是和Java一样,R语言采用一种简单的分代GC,主要使用标记-清除策略,并引入写屏障管理写入屏障,R中的gc()函数能强制触发GC,但多数情况下不需要手动干预,如果做大内存的向量计算,R的GC效率不如JVM的并发收集器,这也是R生态处理超大数据集不如Java高效的原因之一,至于R和Java的回收算法对比,可以简单理解为R重视简单可靠,Java重视可调优和低延迟。
Q&A:虚拟机回收算法常见疑问
为什么Full GC之后老年代内存还是缓慢增长?
可能是某个缓存对象始终被强引用,GC无法回收,用jmap -dump:format=b,file=heap.hprof <pid>导出堆快照,再用MAT或VisualVM分析,重点关注大对象和线程栈中的局部变量引用。
用了G1之后,还需要手动设置SurvivorRatio吗?
G1的Survivor区大小是动态调整的,手动设置-XX:SurvivorRatio效果不稳定,建议直接依赖G1的自适应逻辑,只关注-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent这两个参数来控制新生代占比。
生产环境从CMS切换到G1或ZGC,需要改动代码吗?
不需要改业务代码,但需重新验证GC参数和监控指标,CMS的一些参数在G1或ZGC下不生效,比如-XX:CMSInitiatingOccupancyFraction,切换前把旧参数清理干净,重新参考新回收器的推荐配置,并在压测环境跑一轮完整的排查。
垃圾回收没有万能答案,理解每种算法的取舍,再结合业务停顿标准和硬件资源做出选择,就能让JVM在绝大多数场景下安静工作,先跑起来采集日志,用数据驱动选型,比上来就堆参数更可靠。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/614355.html





