线上服务每隔两三小时就出现一次数秒停顿,报警刷屏成一片。我远程登录上去,先敲了那句Java程序员都背过、但排障时总想不起来的命令:jstat -gcutil。输出里Full GC连续飙升,老年代占用接近100%。那一刻,曾经背过的JVM垃圾回收器八股文才真正有了实感:垃圾回收器决定了服务生死,而不是一道面试选择题。
这篇文章我想按自己排查线上问题、调各种参数的思路来写,不搞面试大全。从垃圾回收到底在回收什么,到七代回收器各自的设计哲学,再到今天主流的G1和ZGC原理,最后落到选型和调优排查。适合有一定Java基础、被Full GC困扰过、或者想系统理解垃圾回收器的读者。
1. 垃圾回收到底在回收什么:先厘清JVM内存与对象的生死判定
1.1 堆内存分代设计与对象的一生
很多刚接触GC的同学,第一个困惑就是:垃圾回收器不是一个"东西"吗,为什么又Serial、Parallel、CMS、G1这么多名字?原因得从堆内存结构说起。
JVM的堆内存并不是一块均质的空间,而是被分割成多个区域。默认情况下分为新生代(Young Generation)和老年代(Old Generation),新生代里又细分出一个Eden区和两个Survivor区(S0、S1)。之所以要分代,背后是多年对象存活统计得出的结论——绝大多数对象"朝生夕灭",活不过几轮Minor GC;真正能存活到老年代的,只有少数被长期持有的对象。
这个统计规律直接决定了回收策略的设计:新生代适合复制算法,因为存活率低,复制成本小,回收效率高;老年代适合标记-整理或标记-清除,因为对象存活时间长,不能频繁复制。每次看到有人把堆分区比例乱调一通,我都会先问一句:你清楚自己的对象存活曲线吗?不清楚的话,调参就是瞎猜。
一个对象从创建到回收的典型路径是这样的:先在Eden区分配,经历第一次Minor GC时,如果还存活,就被拷贝到S0;再经历一次GC时,从S0拷贝到S1,同时对象年龄加1;当年龄达到阈值(默认15,可通过-XX:MaxTenuringThreshold调整),就从Survivor晋升到老年代。还有两种情况会直接进老年代:一是大对象直接分配,二是Survivor放不下时由JVM动态决定。
1.2 可达性分析与GC Roots:判断对象死亡的唯一标准
引用计数法是最直观的判定思路——每个对象被引用一次就计数加1,引用失效就减1,计数归零就是垃圾。但它有个致命缺陷:循环引用。A引用B、B引用A,两边的计数器都不归零,但对外而言这对对象已经没有意义了。所以主流的JVM实现都用可达性分析算法(Reachability Analysis)。
可达性分析的思路很朴素:从一组称为GC Roots的根节点出发,顺着引用关系往下走,能走到的对象就是"活的",走不到的就是垃圾。GC Roots包括这样几类:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,也就是方法里还在用的局部变量
- 方法区中静态变量引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法引用的对象
- 被synchronized持有的锁对象
这里有个容易忽略的细节:GC Roots的枚举本身需要暂停所有业务线程,这就是"Stop The World"(STW)——所有调优者最头疼的东西,从最古老的Serial一直伴随JVM到今天。回收器之间比拼的核心能力,本质上就是"怎么让STW更短、更少、更可控"。很多人以为垃圾回收的优化目标是快,其实准确的说法是:在尽量少停顿的前提下,把回收这件事做干净。
还有一点值得单独拿出来说:不是只有"内存不够了"才会触发GC。System.gc()可以主动请求,JVM不保证一定执行;元空间不足时,也会触发Full GC来尝试回收废弃的类元数据。所以有时候你看GC日志,堆明明很空,却莫名其妙出现了Full GC,先排查一下是不是有代码在随手调System.gc(),或者元空间被什么动态生成类撑满了。
1.3 四种引用类型对回收行为的影响
有些同学会问:既然对象生死由可达性分析决定,那Java里的软引用、弱引用又是干什么的?这里要区分两个层面:引用类型影响的是"垃圾回收器认为对象应被回收的优先级",而不是"对象是否死亡"的判定本身。
- 强引用(Strong Reference):平时最常见的
Object obj = new Object()。只要强引用还在,GC永远不回收这个对象,哪怕OOM。 - 软引用(Soft Reference):内存充足时不回收,内存将要溢出时才回收。适合做缓存,比如把大图片对象用软引用包装,避免缓存把堆塞爆。
- 弱引用(Weak Reference):下一次GC就被回收。ThreadLocal的ThreadLocalMap键就用了弱引用,这也是ThreadLocal内存泄漏问题的根源之一——key被回收了,value还被人为引用着,一直清理不掉。
- 虚引用(Phantom Reference):最弱的一种,甚至通过get()都拿不到对象,主要用途是在对象被回收时收到一个系统通知,比如NIO中DirectByteBuffer的回收通知。
理解这四种引用类型,对排查内存泄漏非常有帮助。比如一个服务内存缓慢增长,用MAT分析堆快照时看到大量软引用对象堆积,那就需要考虑是不是缓存的设计没有考虑清理策略。记住一句话:引用类型改变的是回收的"优先级",而可达性分析决定的是"生死"。
2. 七代回收器演进全景:从单线程Serial到分代并行再到G1
2.1 新生代三剑客:Serial、ParNew与Parallel Scavenge
Serial回收器是所有回收器里资格最老的,名字也直白——串行。它工作时只有一个GC线程,回收期间所有业务线程全部暂停。在单核CPU时代这不是问题,反正只有一个核心;但在现在的多核服务器上,用Serial回收一个几GB的堆,停顿时间可以到秒级甚至分钟级。它的价值是简单、可靠、没有并发开销,适合客户端型应用,也适合资源受限的边缘节点。
ParNew提升了一点——它把新生代的回收线程从1个变成多个,利用多核并行回收,所以叫Parallel New。但它本质上还是复制算法,还是STW,只是停顿时间被并行分担了。ParNew在历史上最大的意义不是有多快,而是它是CMS在新生代的搭档——CMS只负责老年代,新生代必须搭配一个收集器,没有比ParNew更合适的。这也解释了为什么JDK 8时代ParNew + CMS是那么经典的组合。
Parallel Scavenge则是另一个独立分支。它和ParNew都支持多线程并行回收新生代,但设计目标完全不同:ParNew追求"暂停时间越短越好",Parallel Scavenge追求的是"吞吐量最大化"。什么叫吞吐量?就是"用户代码运行时间 /(用户代码运行时间 + GC时间)"。假设服务跑了100分钟,其中1分钟在GC,吞吐量就是99%。Parallel Scavenge还提供了自适应调节策略(-XX:+UseAdaptiveSizePolicy),JVM会自动调整Eden和Survivor的比例来适配当前负载,这对"懒得手动调参"的团队很友好。
2.2 老年代回收器:Serial Old、Parallel Old与CMS的取舍
新生代回收器解决不了所有问题,老年代同样需要回收器。Serial Old是Serial的老年代版本,用标记-整理算法;Parallel Old是Parallel Scavenge的老年代版本,同样用标记-整理。这两对组合很好理解:Serial + Serial Old、Parallel Scavenge + Parallel Old,是经典的"吞吐量优先"和"稳妥"配置。
CMS(Concurrent Mark Sweep)是Java历史上第一款真正以"低停顿"为目标的回收器。它的名字已经自带了算法——标记-清除。CMS把整个回收过程拆成四个阶段:
- 初始标记(Initial Mark):STW,但只标记GC Roots直接关联的对象,非常快
- 并发标记(Concurrent Mark):和业务线程一起跑,从GC Roots往下遍历所有引用,耗时长但不停顿
- 重新标记(Remark):STW,修正并发标记期间业务线程修改引用导致的遗漏
- 并发清除(Concurrent Sweep):和业务线程一起跑,把标记为死亡的对象内存回收
这个设计让"最长的STW"只出现在初始标记和重新标记两个阶段,其余时间都在并发,停顿显著下降。但CMS的代价也很实在:标记-清除会产生内存碎片,碎片积累到一定程度时就不得不触发Full GC来整理;并发阶段本身会占用CPU,在CPU资源紧张的服务上反而可能拖慢吞吐量;还有一个著名的"浮动垃圾"问题——并发清除期间业务线程新产生的垃圾对象,只能留到下一次GC再处理。JDK 9开始CMS被废弃,JDK 14正式移除,现在新项目基本不建议再碰它了。
2.3 G1的出现:区域化堆与可预测停顿模型
G1(Garbage First)在JDK 7u4发布,JDK 9里被设成默认回收器。它最大的设计革命,是把整个堆不再简单分成新生代和老年代两块,而是划分成一个个大小相等的Region(区域)。这些Region在逻辑上仍然分别属于新生代、老年代,但物理上不再连续,而且Region的数量和大小可以动态调整。
为什么要这样做?因为"按区域回收"让回收器有了全局视角的调度能力:它不需要每次都全量回收整个堆,而是优先回收"垃圾最多、回收成本最低"的那些Region,配合-XX:MaxGCPauseMillis参数,在目标停顿时间内尽量多回收垃圾。这就是G1名字里Garbage First的由来——"哪里的垃圾多,就先收拾哪里"。G1的出现也标志着JVM垃圾回收从"物理分代"走向"逻辑分代+区域化",为后面的ZGC、Shenandoah铺平了道路。
如果你去对比CMS和G1的GC日志,会发现一个明显差异:CMS的日志里几乎每次都是"整堆回收",而G1的日志里是"某个集合的Region回收"。这个细节体现了两种回收器在调度粒度上的本质区别。
3. 现代回收器的核心机制与原理拆解:G1与ZGC深度剖析
3.1 G1的两大核心机制:Region分区与SATB
G1的Region是理解整个G1工作机制的第一把钥匙。默认情况下,堆被分成约2048个Region,每个Region大小在1MB到32MB之间,具体由堆大小决定:堆越大,Region越大,大概是"堆大小 / 2048再取2的幂"。比如一个4GB的堆,每个Region约2MB。G1还专门设计了Humongous区域,用来存放超过Region大小50%的大对象,这类对象直接被分配到Humongous区,在GC时单独处理。
Region物理不连续,但G1怎么知道某个Region里的对象被哪些其他Region引用了呢?这就引出了第二个核心机制——Remembered Set(RSet)。每个Region都维护一个RSet,记录"有哪些其他Region引用了本Region里的对象"。回收一个Region时,只需要扫描它的RSet就能找到所有外部引用,不必全堆扫描。RSet本质上是把"跨Region引用"的信息用空间换时间地预计算好,代价是读写引用时会有额外的维护开销。这也是为什么G1在GC频率很高时,RSet的维护开销会成为性能瓶颈之一。
SATB(Snapshot-At-The-Beginning)是G1并发标记阶段的关键算法。这个算法看着抽象,核心就一句话:并发标记开始时给堆拍一张"快照",之后业务线程把某个对象引用改掉了,只要在快照里这个对象是活的,就依旧把它当作存活对象处理,不会漏标。为什么必须这样?因为在并发标记期间,GC线程和业务线程同时在跑,如果业务线程恰好把一个引用删掉,而新引用指向的对象还没来得及被GC线程看到,就可能出现"漏标"——把该活的对象当垃圾回收了,这是绝对不允许的事。
SATB的代价是"浮动垃圾":快照之后新创建的对象,或快照里本可以回收但被业务线程重新引用的对象,这次GC都收不掉,只能留给下一次。所以你会发现,G1堆里偶尔有一些"看着是垃圾但没被清走"的对象,这不是bug,而是SATB算法的预期成本。
3.2 G1的回收流程:Young GC、Mixed GC与Full GC
G1日常的回收分为几种场景。Young GC是最频繁的:新生代Region满了之后触发,同样会STW,但回收区域只涉及新生代,停顿通常很短。G1会从空闲Region中划拨新的Region给新生代,把存活对象拷贝到Survivor或晋升到老年代。
当老年代占用达到阈值(默认是堆大小的45%,由-XX:InitiatingHeapOccupancyPercent控制),G1会启动一个并发标记周期。这个周期和CMS很像:初始标记、并发标记、重新标记、清理。清理阶段之后,G1知道了每个Region里垃圾的占比,于是进入Mixed GC阶段——它不再只回收新生代,而是把"新生代Region + 垃圾占比最高的老年代Region"混在一起回收,尽量在-XX:MaxGCPauseMillis的目标内多清老年代垃圾。
如果G1的回收速度跟不上对象分配速度,老年代持续膨胀,最后还是会触发Full GC。G1的Full GC退化为单线程的标记-整理,停顿时间相当可观,这通常是最危险的信号。所以说,G1调优的核心场景,其实就是"如何让Mixed GC跑得足够勤快,让Full GC永不发生"。这句话值得贴在所有GC调优文档的封面。
3.3 ZGC是如何做到毫秒级停顿的:染色指针与读屏障
ZGC在JDK 11发布实验版本,JDK 15转正。G1已经把停顿压到几十毫秒级别,ZGC的目标更激进:不管堆有多大,停顿时间都稳定在个位数毫秒。
ZGC的核心机密之一叫染色指针(Colored Pointer)。它在指针本身的64位空间里"借"了几个比特位,用来记录对象的三色标记状态(白灰黑)、是否可重映射等信息。GC线程修改对象颜色时,不需要访问对象本身,直接改指针比特位即可,这让并发标记和并发整理可以非常高效地协作。
ZGC的另一个关键组件是读屏障(Load Barrier)。每次Java线程读取对象引用时,都会经过一道屏障检查:如果发现指针的颜色和当前GC阶段不一致,就执行修正——例如把对象拷贝到新地址后更新指针。由于读屏障只在读路径上做廉价判断,绝大多数时候只是一个位运算级别的操作,对吞吐量的影响被控制在极低水平。配合并发整理(把存活对象搬迁到新页并重映射),ZGC把STW压缩到只剩下极短的并发阶段开始和结束时的同步点。
ZGC的代价也在这些激进设计里:染色指针意味着早期的ZGC无法同时使用压缩指针,后续版本才逐步放开;读屏障和并发算法对内存带宽有一定消耗;GC线程与业务线程并行工作,对CPU核数有要求,核数太少时反而可能不划算。不过从JDK 21开始,ZGC引入了分代模式,进一步降低了并发GC的CPU消耗,让超低延迟回收器在普通服务器上变得更实用。
4. 选型判断与调优实战:不是所有业务都需要ZGC
4.1 如何给业务选一个合适的回收器
很多同学一看到ZGC停顿只有几毫秒,就想给线上服务全部换ZGC。我的态度一直是:先看业务,再看硬件,最后才是情怀。
选型的基本逻辑可以这么梳理:
- 如果主要诉求是吞吐量,比如离线计算、批处理任务,用户不会因为多停顿几百毫秒而感知到延迟,那么Parallel Scavenge + Parallel Old依然是最稳的选择,吞吐量高、实现简单、参数成熟。
- 如果是典型的在线Web服务,对延迟敏感但要求不算极致,G1是默认且稳妥的选择。绝大多数Java服务在默认配置或轻微调参下,用G1就能把停顿控制得很好。
- 如果业务对延迟极度敏感,比如高并发交易、实时竞价这类场景,堆又很大甚至几十GB,才值得换ZGC。注意ZGC对CPU核数有要求——并发阶段需要空闲CPU资源,核数太少时GC线程和业务线程互相抢时间片,收益会打折扣。
- 客户端小应用、单核低配机器,Serial反而最省事。
判断依据很简单:先量一量你的服务对GC的敏感度。如果一次Full GC停顿2秒,只有日志告警而业务无感,那没必要折腾;如果一次500ms的停顿就会导致调用方超时雪崩,那ZGC才值得投入精力。调优最大的错误,是"为了调优而调优"。
4.2 GC日志与常用参数:看不懂日志就别谈调优
排查GC问题的第一步永远是看日志,而不是猜。JDK 8及以后推荐用统一日志体系开启:
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags这条命令会在指定路径输出GC日志,包含时间戳、运行时长、级别和标签。JDK 8之前的写法(-XX:+PrintGCDetails -XX:+PrintGCDateStamps)也能用,但新项目直接上Xlog。日志文件建议按天滚动,别让单个文件无限增长,用-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=20m控制轮转。
常用参数整理成一张表:
| 参数 | 作用 |
|---|---|
-Xms/-Xmx | 初始堆/最大堆,生产上通常设为一致,避免运行时扩容带来的抖动 |
-XX:NewRatio | 新生代与老年代比例,默认2表示老年代是新生代的2倍 |
-XX:SurvivorRatio | Eden与Survivor比例,默认8,即Eden占新生代的8/10 |
-XX:MaxGCPauseMillis | G1目标停顿时间,默认200ms |
-XX:ParallelGCThreads | GC并行线程数,默认随CPU动态调整 |
-XX:+UseG1GC/-XX:+UseZGC | 切换回收器 |
-XX:+HeapDumpOnOutOfMemoryError | OOM时自动dump堆快照 |
-XX:InitiatingHeapOccupancyPercent | G1触发并发标记周期的老年代占比阈值,默认45 |
给一段典型的GC日志解析:
[GC (Allocation Failure) [PSYoungGen: 189432K->15478K(189440K)] 189432K->180892K(501248K), 0.0312341 secs]这段日志的意思是:年轻代发生了一次垃圾回收,原因是分配新对象时空间不足。回收前新生代用了约189MB,回收后还剩15MB,新生代容量为189MB;整个堆回收前用了189MB,回收后还有180MB——因为大量存活对象晋升到了老年代;本次回收耗时31ms。看完这一段你就知道:新生代回收正常,但老年代占用在快速增长,如果持续这样,下一步就是Full GC。
4.3 常见性能问题的排查链路
我按实际踩过的坑,整理了三个最典型的场景。
场景一:频繁Full GC,老年代持续打满
先别急着调参数。用jstat -gcutil PID查看各区域占用和GC次数,重点看老年代(O列)是否一直90%以上。如果老年代打满,优先做堆内存dump(jmap -dump:format=b,file=heap.hprof PID),再用MAT等工具分析GC Roots路径,看看是哪些对象占着内存不放。绝大多数情况下,Full GC背后都藏着内存泄漏或集合类对象无界增长,调大堆内存只是把问题往后推迟。我自己处理过一个案例,某服务每天凌晨Full GC一次,dump出来发现是一个定时任务把查询结果全部塞进了一个静态HashMap,从来没有清理。
场景二:G1频繁触发Full GC,但堆占用看起来并不高
这个坑很隐蔽。G1的Full GC不只是老年代满了才会触发,Humongous区域(大对象区)满了同样会触发。比如业务代码里频繁分配超大byte数组(超过Region大小的一半),这些大对象直接进Humongous区,而Humongous区回收条件苛刻,很容易积累到满。遇到这种情况,可以先用jcmd GC.heap_info看一下Humongous区域的占用。解决思路有两条:一是用-XX:G1HeapRegionSize把Region调大,让"大对象"的判定阈值提高;二是在业务侧检查是否真的有必要分配这么大的对象,很多时候用缓冲区池化或分段处理就能解决。我在压测环境就因为这个坑复现过线上Full GC,好在改完Region大小后,整个测试周期再没出现过Full GC。
场景三:ZGC上生产后CPU飙升
先确认是不是GC线程占的CPU太多。ZGC没有传统的GC次数统计,需要用jcmd GC.heap_info或ZGC自带的统计信息查看。ZGC的并发标记和读屏障都会消耗一定CPU,如果服务本来的CPU余量就不足10%,上ZGC可能让吞吐量明显下降。这时候要么换回G1,要么给ZGC留足CPU余量。记住:低延迟不是免费的,它是拿CPU和内存带宽换的。如果一个服务本身平均负载就在80%以上,你还想上ZGC,那大概率会踩到新的坑。
最后分享一个我自己的习惯:每次调GC参数,只改一个变量,跑足一个业务周期,对比前后GC日志里的GC次数、平均停顿、最坏停顿和吞吐量,然后再改下一个。参数调优最忌讳的就是一次改五六项,出问题了都不知道是哪个参数引起的。所有线上GC参数调整都必须灰度验证,先在压测环境把负载压到峰值的1.5倍,确认没有异常再推线上。有一次我就是在压测环境发现了G1的Region过小导致Humongous频繁的问题,避免了一场线上事故。这个习惯,比任何回收器选型都重要。