☰
JVM垃圾回收器深度解析:从Serial到ZGC的演进与实战调优
2026/10/24 2:45:22 网站建设 项目流程

1. 从根上理解:为什么JVM非要搞垃圾回收

做了几年Java开发,如果只记住一个概念,那我建议你记住垃圾回收器。不是因为它面试常考,而是因为几乎所有线上问题——内存飙高、CPU打满、接口突然变慢、服务莫名其妙重启——最后都能追溯到GC这块。

先给还不熟悉的朋友捋一下背景。Java程序跑在JVM里,JVM把内存划分成几个区域,其中对象主要分配在堆(Heap)上。堆不是无限大的,你要是new一个对象就永远不扔,再大的内存也会被撑爆。C/C++的做法是程序员手动free/delete,但这玩意太反人性了:忘了释放就是内存泄漏,释放早了就是悬垂指针,程序直接崩给你看。Java的选择是引入自动垃圾回收机制,由JVM自己判断哪些对象“死透了”,然后把它们占用的内存回收掉,再分配给新对象使用。

听起来很美好对吧?但自动回收有个代价——你不清楚它什么时候动手,也不知道它动手的时候你的程序会卡多久。早期的垃圾回收器为了找到所有存活对象,得先把整个业务线程冻住,叫“Stop The World”(简称STW)。你想象一下,外卖平台高峰期,骑手端每秒钟都有大量订单进来,突然所有线程暂停300毫秒,那感觉就像你正排队付款,前面的人突然站着睡着了。更要命的是,如果堆内存特别大,这个暂停时间还会成倍上涨,业务完全不可接受。

所以现代JVM垃圾回收器的发展史,本质上就是一部跟STW搏斗的历史。从串行、并行,到CMS,再到G1和ZGC,每一代都在追求同一个目标:用尽可能小的暂停时间,处理尽可能大的堆内存。或者说,用可控的代价换回稳定的响应时间。

这篇文章我想把JVM里常见的那几款垃圾回收器从头到尾捋一遍,说说它们各自怎么工作、适合什么场景、有哪些坑,以及在遇到性能问题时怎么快速判断该换哪个。建议收藏,面试前和排查线上问题时都能用上。

2. 先建立坐标系:垃圾回收到底在“回”什么

2.1 堆内存的分代模型(年轻代和老年代)

理解垃圾回收器之前,必须先理解JVM对堆内存的布局。绝大多数商业JVM都把堆分成年轻代(Young)和老年代(Old)。年轻代又细分成Eden区和两个Survivor区(From和To),比例一般是8:1:1。

为什么要分代?因为JVM观察到一个统计规律:绝大多数对象“朝生夕灭”——比如循环里的临时对象、方法调用产生的中间对象,生命周期极短,活不过几次GC。而少量对象能扛过多次回收,进入老年代,活得久且数量少。分代的好处是:对不同特征的对象采用不同的回收策略,年轻代垃圾多、回收频繁但每次很快,老年代垃圾少、回收不频繁但每次比较重。

对象默认在Eden区分配。Eden满了就触发Minor GC(也叫Young GC),把存活对象拷贝到Survivor区之一,然后整体清空Eden和另一个Survivor。每熬过一轮GC,对象年龄加一(记录在对象头里),超过阈值(默认15,可配置)就晋升到老年代。大对象(比如超大的数组)会直接进老年代,避免在年轻代反复拷贝。

有一个点容易被忽略:Minor GC触发时,如果老年代空间不足,可能存在“分配担保失败”,JVM会提前触发Full GC。所以年轻代老年代的大小比例必须配好,否则会出现频繁Full GC,性能直接崩。

2.2 怎么判断一个对象“死透了”

回收的前提是判断哪些对象可以回收。主流有两种算法:

引用计数法:给每个对象记一个计数器,被引用一次就+1,引用失效就-1,归零就可以回收。听起来简单直接,Python早期就用的这个方案。但它有个致命缺陷——循环引用。A引用了B,B引用了A,外部的引用全部断开后,这俩对象的计数器依然不为0,谁也回收不掉,就泄漏了。

可达性分析:JVM主流的做法。从一组称为GC Roots的根对象出发,沿着引用链往下走,能走到的对象都是“存活”的,走不到的(没有被任何活着的东西引用)就是垃圾。GC Roots包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、静态属性引用的对象、常量引用的对象、JNI引用的对象、以及活跃线程等。

这里有个很重要的细节:GC Roots本身必须从线程栈和静态变量这些地方扫描,而这些数据是时刻变化的。为了保证分析结果的一致性,必须先把所有业务线程冻结——这就是STW的根源。不管哪款回收器,标记阶段都躲不开STW,区别只是把暂停控制到多短、控制到多小范围。

2.3 回收算法:标记清除、复制、标记整理

确定哪些对象是垃圾之后,具体怎么把内存“腾出来”呢?三种主流算法:

标记-清除(Mark-Sweep):先标记出所有垃圾对象,然后把它们的内存直接释放。缺点是会产生大量内存碎片——你想想房间里的东西东一件西一件搬走,剩下的家具之间全是大小不一的缝隙,后来想搬进来一个大家具,总空间够但就是塞不进去。所以标记-清除之后往往要配一个整理过程。

复制(Copying):把存活对象全部搬到另一块空闲区域,搬完后老区域整体清空。优点是内存规整、分配快,缺点是浪费一半空间。年轻代刚好合适,因为存活对象很少,浪费可控。

标记-整理(Mark-Compact):先标记再让存活对象向一端移动,最后把边界以外的空间全部清掉。没有碎片,也不会空间减半,缺点是移动对象本身耗时,且需要修改引用地址。

理解了这三个基础算法,后面的回收器就都好懂了——每一款无非是在“什么时候标记”“要不要整理”“怎么让暂停更短”这几个维度上做文章。

3. 第一梯队经典回收器:Serial、Parallel、CMS

3.1 Serial:单线程的“老实人”

Serial是最古老的回收器,年轻代用复制算法,老年代用标记-整理,全程单线程,回收时整个业务必须暂停。

优点就一个词:简单。单线程意味着没有线程切换和竞争开销,在单核CPU机器上反而效率最高,因为其他回收器就算开了多线程,也没多余的核跟它并行。它也是JVM在客户端模式下(比如一些桌面应用)的默认选择。

但放在服务器上,Serial就是灾难——堆稍微大一点,一次Full GC暂停几秒钟都正常,业务基本处于“停滞”状态。所以现在很少有人主动选它,只有在极端场景(单核小内存、必须不要额外线程干扰)才会考虑。

3.2 Parallel:多线程版的“能干型选手”

Parallel也叫吞吐量优先回收器,名字很直白——它在Serial基础上把回收线程变成多线程并行执行,目标是充分利用多核CPU,缩短GC暂停时间。年轻代复制、老年代标记-整理,逻辑跟Serial基本一致,区别只是并行。

Parallel有一个核心参数组合值得重点关注。-XX:MaxGCPauseMillis用来设置期望的最大暂停时间,-XX:GCTimeRatio用来设置吞吐量目标(默认99%,即GC时间占比不超过1%)。JVM会基于这些指标动态调整堆大小和各区比例,比如上次GC暂停超了,就适当调小年轻代让GC更快完成;反过来吞吐量不够,就调大堆减少GC频率。

说句实在话,Parallel在JDK 8里是默认回收器,适用面非常广。如果你做的是批处理、数据分析这类对延迟不敏感、但对总吞吐量有要求的任务,Parallel往往是最稳的选择。它追求的核心指标不是“响应快”,而是“单位时间内干活多”,年轻时吃亏在每次GC要全局停顿,老年代一旦大了暂停时间就很难看。

3.3 CMS:向“低延迟”迈出第一步

CMS(Concurrent Mark Sweep)是JVM历史上第一款主打低延迟的回收器,全名“并发标记清除”。理解CMS的关键是理解“并发”——它把回收过程拆成好几个阶段,其中大部分阶段可以和业务线程同时运行,不再需要整段整段的STW。

看它一次GC的完整流程:

  • 初始标记:只标记GC Roots直接引用的对象,很快,但需要STW。
  • 并发标记:从直达对象往下遍历所有引用链,这个过程耗时长,但可以和业务线程并发跑,不暂停。
  • 重新标记:因为并发标记期间业务线程可能改了引用关系,CMS需要再扫一遍,修正变化的部分。这个阶段也需要STW,但比整段标记快很多。
  • 并发清除:清理垃圾对象,和业务并发执行。

CMS全程的STW被压缩到了只有初始标记和重新标记两小段,这对延迟敏感型应用的提升是巨大的。不过CMS的问题同样突出:

  • CPU敏感:并发阶段占用了CPU资源,本身业务就重的话会影响吞吐量。
  • 内存碎片:CMS用的是标记-清除,天生会产生碎片。碎片攒多了,即使总空间够,分配大对象也可能失败,触发一次Serial Old的Full GC兜底——憋个大招直接让你傻眼。
  • 浮动垃圾:并发清除阶段业务线程还在产垃圾,这些垃圾只能等下次GC处理。
  • 无法处理“并发失败”:如果回收速度赶不上对象分配速度,就得退化到STW的Serial Old,暂停时间直接飙升。

CMS在JDK 9开始被标记为废弃,JDK 14正式移除。但它的思路影响深远:并发标记、增量回收、让GC和业务并行工作——后面G1、ZGC身上都能看到CMS的影子。可以说国内大量存量系统还在跑CMS,你在老项目里遇到的“Concurrent Mode Failure”日志,十有八九就是CMS内存碎片或者回收速度跟不上导致的。

4. 中间力量:G1——区域化回收的一次成功实践

4.1 从连续分代到Region布局

G1(Garbage First)是JDK 9之后的默认回收器,也是目前生产环境使用最广泛的选择。它最大的变化是放弃了物理上的连续分代,把堆划分成一个个大小相等的Region(默认约2048个),每个Region独立标记角色:Eden、Survivor、Old、Humongous(大对象专属)。

你可以把G1理解为把小区分成好几栋楼,每栋楼自己独立管理,有新用户住就标为Eden栋,有老人就标为Old栋,需要整块大空间的就单独划一栋Humongous楼。Region可以动态调整角色,比如某个Eden Region回收干净后,下次可能就变成Survivor或者Old,灵活度比传统连续分代高得多。

G1名字里的Garbage First也透露出它的核心策略:回收时优先处理“垃圾密度最高”的Region。它维护一个优先级列表,每次GC挑选垃圾最多的若干个Region进行回收,这样能以最小的代价释放最多的空间。整体上G1是基于Region的标记-整理,回收过程中会把存活对象从一个Region复制到另一个Region,不仅清空了原Region,而且每个Region内部都是紧凑的,不存在CMS那种碎片问题。

G1还是第一款真正把“可预测暂停”做成产品卖点的回收器。你可以用-XX:MaxGCPauseMillis指定期望暂停上限(默认200ms),G1会通过动态调整Region数量、回收批次和年轻代大小,竭尽全力把每次GC控制在目标以内。注意这是“软目标”,不保证100%达成,但它会让JVM自动微调,一般都能接近。

4.2 G1的工作流程

G1的GC分两大类:Young GC和Mixed GC。

Young GC发生在年轻代Region装满时,跟传统Minor GC差不多,把Eden和Survivor里存活对象复制到新的Survivor Region,满足晋升条件就进Old Region。这个阶段纯STW,但因为G1每次只处理一部分Region,暂停时间可以被压得比较短。

Mixed GC是G1的特色。当老年代占用达到阈值(-XX:InitiatingHeapOccupancyPercent,默认45%)时,G1会触发并发老年代回收,这个流程跟CMS很像:初始标记(STW)→ 并发标记 → 重新标记(STW)→ 复制清理(STW)。复制清理阶段它会把选中的老年代Region和所有年轻代Region一起回收,所以叫“Mixed”。选中的老年代Region通常是最有回收价值的那一批——垃圾多、存活对象少的Region优先处理,其他Region暂时不动,下次再选。

还有个细节:G1里所有Region都有一个Remembered Set(简称RSet),记录其他Region的对象对被本Region对象的引用。跨Region引用靠这个集合快速判断,避免全堆扫描。代价是写屏障(Write Barrier)会让每次对象引用赋值多一层开销,但换来的收益是回收时可以只扫局部Region,逻辑上仍然等价于全局标记。

4.3 什么时候该用G1,什么时候该谨慎

G1适合的典型场景:堆内存较大(4GB以上甚至几十GB)、业务对延迟有一定要求但又不至于苛刻、服务端应用且机器有多核CPU。目前绝大多数互联网后端应用跑在G1上都没问题,默认参数通常就够用。

但有几个坑是要注意的。

大对象分配会直接触发Humongous Region分配。如果大对象很多,每个都占有至少1个Region(Region的50%以上就算大对象),而大对象又不会被复制回收,可能把堆占得比较死。所以代码里尽量避免超大数组和超大缓存单对象。

另一个比较容易踩的坑是:不要随便把-XX:MaxGCPauseMillis设成很小,比如设到50ms。G1为了完成这个目标会不断缩小年轻代,导致对象还没活多久就被频繁回收集,晋升率猛增,老年代迅速膨胀,反而更容易触发Full GC。实际项目里我见过为了追求低停顿把参数调到20ms,结果GC次数翻了五六倍,CPU打满,线上事故。停顿目标要结合真实业务设定,比如200ms对绝大多数接口都够用,不用追求极致。

再一个就是-XX:InitiatingHeapOccupancyPercent这个阈值。默认45%意味着老年代占堆45%时就开始并发标记,如果业务对象分配极快,可以适当调低(比如35%),提早触发回收以避免Mixed GC来不及处理;但调太低会导致回收太频繁,额外增加CPU负担,需要压测验证。

5. 新一代扛把子:ZGC与Shenandoah

5.1 ZGC:把暂停时间打进个位数毫秒

ZGC是JDK 11引入的实验性回收器,JDK 15开始转为正式特性。它的目标非常激进:让GC暂停时间不随堆大小增长,无论堆是2GB还是200GB,暂停都控制在10ms以内。你没看错,这就是它跟G1最本质的区别。

ZGC做到这点的核心技术之一是染色指针(Colored Pointers)。它把64位指针的高几位拿来做状态标记(比如标记对象当前处于哪个GC阶段、是否被移动过等),这样JVM在做并发标记和并发转移时,不需要在对象头上加锁就能知道对象当前的状态,也就不需要STW来保证一致性。

另一个关键技术是读屏障(Load Barrier)。业务线程读对象引用时,会触发一个轻量级检查,如果发现对象在被移动,就自动转向新地址。整个过程对业务无感。迁移还是并发做的,跟CMS一样,但区别在于ZGC的并发迁移比CMS彻底得多——它设计目标就是不让全局STW出现。

ZGC的GC过程依然有标记、转移、重映射这些环节,但几乎所有阶段都能和业务并发。真正STW的只剩极少数超短停顿,时间在微秒到几毫秒级别,几乎可以忽略。

适合ZGC的场景很明显:超大堆、延迟极度敏感的服务,比如证券交易系统、在线游戏的排行榜服务、大数据实时处理链路。我见过有人给线上16GB堆的服务换ZGC后,P99延迟从120ms降到30ms以内,GC日志里几乎看不到大于5ms的停顿。不过ZGC也不是银弹,它的代价是CPU占用偏高(因为读屏障每次引用访问都有额外判断),并且JDK 11/17上有些第三方框架(比如某些版本的Netty和热部署工具)跟ZGC一起跑会出兼容问题,生产上线前必须做足压测和灰度。

5.2 Shenandoah:另一个低延迟方案

Shenandoah跟ZGC几乎同时期诞生,目标也是低延迟,但实现路径不一样:它没有用染色指针,而是通过**转发指针(Forwarding Pointer)**配合读屏障实现并发迁移。简单说,每个对象在移动时会在原位置留下一个“新地址”的指针,业务线程访问时自动跳转过去。

Shenandoah在JDK 12开始被合入OpenJDK主线,不过在OpenJDK官方构建里它默认不启用(需要-XX:+UseShenandoahGC显式打开),而在某些发行版的JDK里是直接可用的。跟ZGC相比,Shenandoah更早支持压缩指针(Compressed Oops),在传统配置下对内存占用更友好,但整体生态和调优资料没有ZGC那么丰富,社区热度也差一截。

如果你只是普通业务系统,我建议优先尝试ZGC;如果遇到压缩指针兼容性或者一些特殊GC调优场景,再考虑Shenandoah作为B计划。

5.3 低延迟回收器的选型判断

选低延迟回收器前,先想清楚一件事:你的业务到底需不需要。如果接口P99本来就在200ms以上,GC停顿100ms根本无感,那换成ZGC的收益很小,反而要承担CPU占用上升的风险。低延迟回收器的价值,只有在“GC暂停已经成为主要瓶颈”时才凸显得出来。

可以先做一次模拟压力测试,把GC日志里每次GC的暂停时间拉出来看看。如果80%的GC停顿都超过80ms甚至更高,且堆内存确实有几个GB,那ZGC值得一试。如果堆只有1GB,G1默认参数跑得挺好,别折腾。

另外一个容易被忽略的是:换回收器不是万能的,先检查自己的代码。我记得有个服务GC非常频繁,堆始终居高不下,团队一度考虑换ZGC,最后发现是一个静态Map缓存了所有用户登录态且永不清理,说白了就是内存泄漏。回收器只能回收“无人引用”的对象,有人引着不放,神仙回收器也没辙。先看代码再看GC。

6. 实战:通过GC日志快速定位问题

6.1 打开GC日志的正确姿势

线上排查GC问题,第一步永远是看日志。不开日志的优化全是盲人摸象,这也是我踩过最大的坑之一:早期有个项目出问题,愣是没法定位,因为线上没开GC日志,只有证据不足的猜疑链,浪费了两天。

现在推荐的JDK 8+日志参数组合:

-Xlog:gc*:gc.log:time,uptime,level,tags:filecount=10,filesize=100m

这是JDK 9之后的新写法,日志会滚动保留,单文件上限100MB,共10个文件。JDK 8用这种老写法:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:/path/to/gc.log

最好再加一个-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heapdump.hprof,万一OOM能留下堆快照用于离线分析。

6.2 GC日志怎么读

以G1为例,一段典型的日志长这样:

[GC pause (G1 Evacuation Pause) (young) 1024K->512K(4096K), 0.0123456 secs] [GC pause (G1 Humongous Allocation) (young) (initial-mark) 1.5G->1.2G(4.0G), 0.2345678 secs]

关键看几处:GC类型(是Young还是Mixed还是Full)、前后的堆占用变化、耗时。如果Full GC频繁出现,说明老年代都在CMS或者G1已经Hold不住了,多半是老年代满了或者碎片严重。如果Mixed GC频繁但这类GC本身耗时很短,可能是年轻代晋升太猛,检查-XX:MaxGCPauseMillis和-XX:InitiatingHeapOccupancyPercent的配置。

还可以配合jstat -gcutil <pid> 1000实时观察各代容量和GC次数,或者用jmap -heap <pid>快速查看堆配置和各代使用率。再高级一点就用GCViewer或者JMC(JDK Mission Control)把日志可视化,能直观看到GC暂停的分布趋势,是排查性能瓶颈的利器。

6.3 常见问题速查表

整理一张排查速查表,覆盖我实际遇到的高频问题。

现象可能原因优先排查方向
Full GC频繁老年代空间不足堆是否合理、是否有内存泄漏、晋升阈值是否偏低
GC暂停远超预期堆太大且回收器不合适换G1/ZGC,检查Region数量、并发线程数
年轻代GC极其频繁Eden过小适当调大年轻代,检查是否创建大量短命对象
CPU高但不GC业务本身循环、锁竞争线程栈分析,用jstack抓线程
CMS并发模式失败碎片/回收速度跟不上换G1,或调大老年代提前触发阈值
ZGC后CPU上涨读屏障额外开销评估业务是否值得,或用Shenandoah对比
大对象过多Humongous Region占用检查缓存数组、大JSON解析、序列化大对象

这张表不是万能药,但它能帮你把排查方向收敛到两三个候选上,再往下用堆转储分析、线程转储分析就能一步步锁死根因。

7. 调参经验:那些参数真的有意义吗

7.1 常用参数及合理取值

很多文章喜欢列一长串参数,但实际生产环境值得动的手就那么几个。

堆大小是最基础的。建议-Xms和-Xmx设成一样的值,避免堆自动伸缩带来的额外开销和性能抖动。不少团队为了省内存把-Xmx设很小,结果GC极其频繁,CPU还高,反而是捡了芝麻丢西瓜。

年轻代大小也是高频调整项。G1一般不需要显式指定-XX:NewRatio,它自己会动态调整;但如果你发现Minor GC次数明显偏多,可以考虑用-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent限制年轻代上下限,给它一个更合理的初始范围。

GC线程数-XX:ParallelGCThreads和-XX:ConcGCThreads默认就能匹配当前CPU核数,绝大多数情况不用动。只有一种情况建议改:机器是虚拟机,宿主机核数很多但分配给你的vCPU很少,或者反过来,JVM检测到的核数与实际情况不符,导致并发线程数过多互相抢CPU。这种场景才需要手动指定。

有个容易踩坑的细节是-XX:+UseCompressedOops。JDK默认在堆小于32GB时会开启压缩指针,把普通对象引用从8字节压缩到4字节,能省不少内存。但ZGC在某些版本上跟压缩指针的配合并不完美,如果你用ZGC且内存占用偏高,检查一下是不是压缩指针没生效,必要时显式开启。

7.2 调参的黄金法则

我见过太多同事一上来就抱着参数文档逐条调,结果越调越差,最后全部回退。这里分享一条我在实战中反复验证的路径:

  1. 保持默认参数跑基线,观测GC日志和业务指标至少一个发布周期。
  2. 先解决代码层面的问题:内存泄漏、大对象高频分配、不合理的缓存策略,这些占GC优化收益的70%以上。
  3. 如果需要调参,每次只改一个参数,压测对比前后GC停顿、吞吐量和CPU变化。
  4. 改完观察至少两到三天的线上指标,不要当天就下结论。GC是概率性行为,短期内波动本身就大。
  5. 所有调优都以真实业务数据为依据,不要轻信网上流传的“最佳实践参数”。每个系统的水位、分配速率、峰值流量都不一样,抄作业常常翻车。

7.3 堆大小核算的粗略模型

有人问,堆到底设多大合适?没有标准答案,但可以估算。先统计数据:每分钟new出来的对象总量(可以用JMX监控或者APM工具看),乘以对象平均存活时间(粗略估计),得到“稳定存活对象总量”,这基本上就是老年代的底线占用。再加一个高峰期的临时对象缓冲,以及一定的余量(比如30%),就可以推出一个比较靠谱的堆大小。

举个例子:系统每分钟分配2GB对象,大部分对象寿命在10秒以内,那么活跃对象量大约2GB(10秒约等于1/6分钟),老年代至少给3GB才算稳,整体堆给6~8GB就相对宽裕。用这个模型至少能避免“凭感觉设置堆大小”的玄学问题。

8. 选型决策:一篇讲透什么时候用哪款

到这一步,你对每款回收器的优缺点应该有大致的感知了。我直接给一张选型决策表,供参考。

场景推荐回收器理由
单核小内存桌面应用Serial最简单,无并发开销
批处理、离线任务、吞吐优先Parallel多核并行,总吞吐量最大
JDK 8存量服务、堆4-8GBCMS或G1CMS成熟但碎片问题;G1未来兼容性好
堆8GB以上、延迟敏感G1可预测停顿,回收灵活
超大堆(几十GB+)、极低延迟ZGC暂停时间不随堆增大,10ms以内
需要压缩指针兼容的低延迟场景Shenandoah对比ZGC的备选方案

补充一个很多团队踩过的坑:JDK 8升JDK 11或17之后,GC日志格式、参数写法、默认回收器都变了,旧脚本全部失效。升级前一定要看清楚迁移文档,Production环境先做全量回归。我们当时升级后发现之前写死的CMS参数直接报“Unknown VM option”,排查半天才发现是参数拼写被废弃了。升级务必先把所有GC相关启动参数过一遍。

选择回收器本质上是在三个指标——吞吐量、延迟、内存占用——之间做权衡。Parallel牺牲延迟换吞吐,CMS牺牲吞吐换延迟,G1在这两者之间找平衡,ZGC进一步砸钱砸CPU换极致的低延迟。没有任何一款是绝对最优的,只有“最适合当前业务水位”的。

9. 那些踩过的坑与最终建议

最后聊几个我在实际项目里踩过的坑,希望能帮大家少走弯路。

第一个坑是“某参数调优帖直接照抄”。网上很多文章开头就是“生产环境JVM调优实战:设置堆内存8G,年轻代3G,CMS回收器”,但代码、流量、机器配置都不一样,直接照搬基本都会翻车。我见过照搬一个大对象参数导致Full GC频率翻倍的例子,后来发现那个帖子的场景是短连接高并发,自己这边是长连接流式处理,完全不是一回事。

第二个坑是“GC日志开了但不看,遇事再翻”。GC日志不贵,但一定要有自动化监控。建议把GC暂停时间、GC频率、Full GC次数接入告警,超过阈值直接通知到值班群。否则日志就是一堆无用的文本,真出事了翻半天找不到规律。

第三个坑是“以为回收器能解决一切”。再好的回收器也只负责“回收”,不负责“分配”是否合理。代码层面频繁创建大对象、缓存永不清理、死循环积累引用,这些问题是回收器无论如何都优化不掉的。排查GC问题请先怀疑代码,再怀疑参数,最后才是换回收器。

第四个坑是“堆太小还指望低延迟”。堆设置1GB,对象分配速率又高,任何回收器都救不了你。该扩堆就扩堆,该优化分配速率就优化,别指望ZGC在1GB堆上创造奇迹。

我个人在实际操作中的体会是:JVM的垃圾回收器知识绝对不是面试背几个名词就够的。真正掌握了分代模型、回收算法和每款回收器的工作流程之后,你会发现排查线上问题的效率提升不止一个档次。先从默认参数的G1开始跑,看日志、找规律、做对比实验,一步一步打磨。等你养成了“先看GC再猜代码”的习惯,很多疑难性能问题都能在半小时内定位到根因。

最后再分享一个小技巧:每次升级JDK版本前,先在本机构建一个模拟业务压测环境,用新旧两个JVM分别跑同一套压测脚本,重点对比GC暂停和P99延迟。这个办法我用了很多年,几乎每次都能提前发现升级后才会暴露的GC兼容问题。毕竟垃圾回收器是JVM里跟业务关系最微妙的一块,多一点实测,少一点想当然,才是正道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询