
先唠个比较有画面感的场景某天线上服务突然CPU飙升到90%以上接口全部超时报警登录跳板机敲jstat一看Full GC一分钟十几次每次停顿好几秒。这时候如果你对JVM垃圾回收GC只是停留在“知道有这回事”的程度大概率只能重启服务救火问题根源根本没找到过两天又炸一次。这个标题看起来是JVM三大块之一的基础知识但它其实是线上稳定性事故最集中的根源之一。这篇文章我打算掰开揉碎聊清楚三件事GC背后的机制到底是什么、主流的垃圾收集器怎么选怎么配、线上出了GC问题怎么定位怎么解决。适合刚接触JVM调优的后端工程师也适合那些写了几年Java但一直没系统梳理过GC细节的同学。不夸夸其谈理论尽量用实际场景说话。1. 项目背景与整体思路1.1 这个标题背后到底在说什么JVM垃圾回收核心任务就是自动管理堆内存找到那些已经没有任何引用的对象回收它们占用的空间同时整理内存碎片保证后续分配内存的速度。听起来很简单但真正的复杂度在于三点什么时候判定一个对象“该死”、用什么算法去回收、停顿时间能不能接受。GC从进JVM那天起就不是单一实现而是演进出了Serial、Parallel、CMS、G1、ZGC等一堆收集器。每一代收集器都在解决上一代的问题但也带了新的限制。比如CMS追求低停顿却带来了碎片和并发失败的问题G1试图兼顾大堆和可控停顿却在某些模式下吞吐量不如Parallel。选型这件事没有最好只有最合适而合适的判断依据是业务特征。另外GC参数的调整在业界其实是非常敏感的操作因为JVM默认参数在大多数场景下都经过了充分测试盲目去调反而容易把系统调坏。所以我不建议一上来就复制网上各种“神级JVM调优参数”我们要做的是理解原理之后针对实际瓶颈做小步调优。1.2 为什么GC值得专门去研究很多业务同学觉得GC是Java运行时自带的能力写代码时不用管。但实测下来线上应用百分之八十以上的卡顿、超时、雪崩背后都能找到GC的影子。特别是那种响应时间要求高的系统一次长时间的Full GC足以让调用方大批量超时。换个角度说GC是Java相比C/C最核心的红利开发者不用手动释放内存。但红利的另一面是管理成本被推迟到了线上——你不能像C那样在崩溃现场malloc/free排查只能通过日志和工具去复盘。这套排查能力是JVM工程师的基本功也是做技术深度成长的必由路径。我见过不少团队在业务高峰期把堆内存压得死死的美其名曰“充分利用内存”结果一场流量波峰直接打挂了应用。我也见过把堆内存给到几十G然后频繁Full GC的案例堆太大反而成了负担。GC调优的核心目标是让垃圾回收的节奏和业务的分配速率匹配而不是一味调大某一块区域。2. GC核心机制与内存模型拆解2.1 堆内存结构与分代假说理解GC必须有堆内存的分区概念。JVM堆划分成新生代Young Generation、老年代Old Generation和元空间Metaspace。新生代内部又分成Eden区和两个Survivor区默认比例8:1:1。对象出生在Eden区经历Minor GC后如果存活就进入Survivor区反复熬过几轮(默认15次)后就晋升到老年代。这个设计的依据是IBM等公司统计出的“弱分代假说”绝大多数对象都是朝生夕死活过几轮GC的对象大概率存活得更久。分代回收可以避免每次GC都对整个堆做全量扫描只用轻量的Minor GC清扫新生代就够了。反过来如果只有少数大对象和长期存活的老对象进入老年代那么老年代的GC频率自然很低成本可控。如果搞不清这个机制就容易出现两种典型的误操作一种是新生代调太小导致Minor GC过频对象来不及攒够晋升年龄就被频繁复制另一种是晋升阈值设得太低大龄对象提前进入老年代老年代GC频率随之升高。我测过一个极端案例把晋升阈值从15调到2之后老年代GC次数直接翻了三倍。2.2 对象什么时候会被判定为垃圾对象存活的判定核心是GC Roots可达性分析。主流JVM都用了可达性算法而不是简单的引用计数。所谓GC Roots包括栈帧中的局部变量、静态变量、常量引用、JNI引用、以及处于lock状态的对象等。从这些根出发能遍历到的对象视为存活遍历不到的直接回收。这个算法有个经典问题循环引用下的两个对象互相引用但不被根引用引用计数法无法破解可达性算法能轻松识别。因此JVM不需要像早期语言那样考虑环形引用问题也是我们常说“Java只要释放了所有外部引用内部连环引用会自动被回收”的原因。顺带说一句finalize()方法在可达性分析中还有一次“自救”机会但实际工程中强烈不建议依赖这个方法做资源清理。它执行时机不确定且只有一次机会Java 9之后已经被标记废弃。我在项目里见过有人把文件句柄关闭逻辑写在finalize里结果是系统运行几天后文件描述符耗尽排查起来极其痛苦。2.3 回收基础算法标记清除、复制、标记整理三个基础算法需要理解透。标记-清除算法先标记垃圾对象再清除回收。它的优点是实现简单但缺点是会造成内存碎片导致后续大对象分配失败时提前触发Full GC。标记-整理算法在标记之后把存活对象往一侧搬运解决碎片问题但搬运动作的成本很高。复制算法是新生代采用的方案将Eden一个Survivor中的存活对象复制到另一个空的Survivor中然后一次性清空整个Eden和原Survivor。因为在新生代存活对象少复制的开销很小而且整个清理过程不会产生碎片。复制算法的缺点是空间利用率不高需要预留一块存活区所以Survivor区在内存中占了可观的比例。从宏观视角看三种算法没有绝对哪个更好都是空间与时间的博弈不同收集器只是在不同区域以不同方式组合它们。G1把堆划分为Region之后又多了局部复制与全局并发标记结合的玩法本质还是这几个基础算法的变体。3. 主流垃圾收集器选型与参数详解3.1 收集器家族一览先整理一张表把不同收集器的适用场景和关键特点拉出来方便选型时对照参考收集器回收模式核心优势明显短板适用场景Serial单线程新生代老年代实现简单客户端默认停顿时间长单核小内存开发调试Parallel多线程并行吞吐优先吞吐量高后台任务友好暂停期间业务线程全停数据分析、批量计算CMS并发标记清除并发回收低停顿碎片多并发失败问题对响应时间敏感的中小堆G1分区化可预测停顿大堆下平衡吞吐与停顿调参复杂RSet内存开销大JDK9默认应用最广ZGC并发为主停顿不随堆规模增长内存占用偏高JDK版本要求高超大堆、超低延迟场景JDK 8时代Parallel Scavenge Parallel Old是默认组合当时的默认目标是吞吐优先导致很多没有调优过的业务服务在GC停顿上表现一般。JDK 9之后G1成为默认收集器目标是能够在指定期望停顿时间内完成回收。需要注意的是CMS虽然历史地位很高但JDK 14之后已经正式移除。如果你还在维护用了CMS的老项目建议尽早规划升级迁移。G1在绝大多数场景下能替代CMS并且没有碎片问题。3.2 选型思路与实践参数选收集器不是追新而是先问业务对延迟的容忍度。如果是离线批处理、日志分析这类对吞吐量要求极高的任务Parallel依然是极优解它牺牲暂停时间换来单位时间的处理能力。如果是面向用户的在线服务响应时间敏感G1或者ZGC是主流方向。G1的核心参数是-XX:MaxGCPauseMillis它表示期望的GC停顿目标默认值是200毫秒。但期望只是目标G1会通过动态调整年轻代大小来尽力满足。我实际测试过把值压到50毫秒的场景G1会把年轻代调得非常小导致Minor GC频繁整体吞吐反而下降所以不要盲目追求极低的停顿目标。在线服务里比较常见的三个参数组是-Xms和-Xmx配置堆大小建议固定相等避免运行期扩缩容触发额外的系统调用和内存操作-XX:UseG1GC显式指定G1-XX:MaxGCPauseMillis设置停顿目标-XX:ParallelGCThreads设置并发GC线程数。如果业务中大量使用了大对象比如超过Region一半的对象可以关注-XX:G1HeapRegionSize参数。Region大小默认是堆大小开根后按2的幂取值一般不需要改。但超大堆并且大对象很多时把Region调大能减少大对象跨Region分配的开销。3.3 新一代收集器ZGC的关注要点ZGC把停顿时间压到亚毫秒级在JDK 15之后越来越多团队尝试引入。它的实现和之前的收集器完全不一样着色指针、读屏障、多重映射等一堆底层技术回收过程几乎不停业务线程只在线程栈扫描时有极短停顿。ZGC有个特点是内存占用比G1高因为它要额外保存一些与着色指针相关的元数据。同时它要求堆至少预留一部分内存用于回收过程的缓冲区如果物理内存紧张不建议硬上。JDK 21之后ZGC还能动态调整堆大小这进一步降低了运维负担。也有人问是否产生代际区分JDK 21带来了分代ZGC的增强这次改动把年轻对象和老对象分开处理进一步提高了回收效率。但分代ZGC的复杂度明显上升除非业务真的压榨到极限否则普通团队还是建议先用成熟的G1稳定跑。4. 实操过程与核心环节实现4.1 GC日志的打开与关键字段解读一切调优和排查都要从日志出发。Java 8及之前版本打开GC日志的参数是-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGCJDK 9之后统一改为-Xlog:gc*。统一日志是个大变化老参数在JDK 9之后会直接报错很多迁移到JDK 17的项目重新启用GC日志时都会踩这个坑。看一段典型的G1日志[GC pause (G1 Evacuation Pause) (young), 0.0123016 secs] [Parallel Time: 8.9 ms, GC Workers: 8] [Ext Root Scanning: 0.1 ms] [Update RS: 2.2 ms] [Merge RS: 0.5 ms] [Clear CT: 0.1 ms]这一段说明发生了新生代转移暂停总耗时12毫秒。关键要看Parallel Time和GC Workers是否匹配如果Workers很多但时间依然很长可能说明有分配压力或者RS更新耗时过高。RSRemembered Set是G1用来跟踪跨Region引用关系的数据结构它在高并发场景下维护成本不可小视。老年代混合GC日志里如果出现To-space exhausted或者Humongous Allocation就需要重点关注大对象问题。Humongous对象直接分配在连续的大Region中如果频繁创建大数组或大缓存对象会导致老年代很快被塞满即使新生代还有很多空间也没用。4.2 用jstat定位GC频率与耗时打开日志只是第一步线上排查最常使用的命令是jstat。贴两个最常用的参数组合# 查看GC概况每一秒输出一次共输出20次 jstat -gcutil pid 1000 20 # 查看堆内存和Metaspace使用量 jstat -gccapacity pid-gcutil输出中最核心的字段是YGC、FGC、YGCT、FGCT。YGC表示Minor GC次数YGCT是总耗时。如果YGCT/FGC的比值过大说明单次GC停顿过长如果FGC次数每分钟超过1次基本可以判定异常。还有一种典型情况是YGC次数很多但每次耗时都很短这类问题多半与分配速率有关比如业务代码频繁创建大数组或者无界缓存对象。我遇到过最典型的场景是某订单系统在活动大促期间FGC从每小时几次飙升到每分钟几十次排查后发现是某段代码用了一个静态的HashMap当缓存业务把大量过期数据丢进去每次请求都往里面塞结果很快把老年代撑满。4.3 用jmap与MAT做堆转储分析日志定位到GC频率异常后下一步通常需要看是哪些对象占用了大量内存。JDK自带的jmap可以生成堆转储快照jmap -dump:live,formatb,file/tmp/heap.bin pid注意加live关键字表示只保留存活对象文件会小很多。但是生成转储文件本身会暂停应用一段时间对线上服务有影响建议在业务低峰期操作。生成后可用MAT或者VisualVM分析。MAT分析时最常用的是Leak Suspects报告和Dominator Tree。Leak Suspects会直接给出最可能的内存泄漏嫌疑点而Dominator Tree能看到一个对象持有的整棵引用树找出真正意义上的“大头”。排查原则是不要被业务对象的大对象数量吓到先看它的GC Roots引用路径确认是否被全局静态或者ThreadLocal持有。4.4 一个小型GC问题排查实操案例我用一个模拟项目为例完整演示排查过程。某服务在发布新版本后接口P99延迟从100ms飙升到2秒jstat输出FGC次数从每小时2次变成每10分钟一次每次Full GC耗时超过800ms。第一步查看GC日志确认到底是G1还是Parallel日志显示该服务还在用Parallel Old。这说明项目升级JDK版本后收集器配置没有同步更新。第二步jmap导出堆转储打开MAT发现一个订单状态缓存类实例总数超过600万通过引用链定位是静态Map被业务线程写入但没人清理。修复方向很清晰改成带过期策略的本地缓存组件并将缓存键设计为可回收的短格式。第三步修复后复测YGC次数明显上升但FGC归零P99回到120ms。这里有个值得说的点FGC归零不代表优化结束需要再观察YGC的耗时是否增加了因为堆内缓存减少后可能导致对象更频繁在新生代回收。5. 常见问题与排查技巧实录5.1 GC问题高频场景速查整理一个排查速查表按现象分类给出思路和常规解法现象常见原因排查切入点解决建议Full GC频繁老年代占满堆转储看存活对象大小修复泄漏、加大老年代或调整晋升阈值GC停顿过长堆过大或GC线程少看GC日志中GC Workers适当增加并行GC线程或迁移到G1/ZGC新生代GC频繁分配速率过高jstat看YGC趋势优化对象分配缩短生命周期大对象导致老年代增长快大批量数组、缓存未限流搜索Humongous对象压缩对象体积调整Region大小Metaspace不断增长类加载器泄漏用jcmd查看类加载统计排查动态生成类修复自定义类加载器Metaspace的问题在动态代理、CGLIB、大量反射场景特别常见。如果元空间设置了最大值一旦撑满会直接引发Full GC甚至OOM。要结合JVM参数-XX:MaxMetaspaceSize是否存在过小的情况来排查。5.2 线上GC排查的独家心得这些年在生产环境折腾GC有几个常规文档里不会写得很透的实操点第一绝对不要直接在线上随便改堆大小然后重启。我会建议先在压测环境复现流量同时打开GC日志用同样的参数验证再上线。只有低于0.5%的极端情况才临时用jcmd动态修改部分参数比如-XX:MaxGCPauseMillis可以通过jcmd修改的部分属性有限多数核心参数还是只能重启生效。第二GC日志和应用程序日志要分文件输出。如果都打到同一个文件排查时会耗费大量时间做时间线对齐。有的公司内部会把GC日志统一收集到日志平台这是很值得借鉴的做法能在多个节点并行出问题时快速做全局分析。第三注意JNI和堆外内存导致的GC假象。有些系统用了堆外缓存或者本地内存这种内存不受GC管理但Java堆的GC频率看似正常整体内存却持续涨到机器扛不住。用top观察进程常驻内存和堆内存的差值往往能暴露这类问题。第四如果经常出现GC后CPU还是很高别急着调GC参数先确认代码有没有死循环或锁竞争。GC只负责回收内存不负责解决高CPU把GC锅背上就浪费时间了。常见情况是“为了处理GC停顿的一堆重试逻辑”实际性能瓶颈是某处MySQL慢查询。5.3 调优起步建议最后给一套还算稳妥的调优流程保证基础指标可观测打开GC日志、设置堆转储路径-XX:HeapDumpPath和OOM时自动转储-XX:HeapDumpOnOutOfMemoryError建立GC基准记录高峰期每分钟GC次数、平均停顿时间、Full GC频率形成基线数据按瓶颈调整优先解决代码层制造垃圾的问题其次才是调整GC参数每次只改一个参数方便追溯效果改完压测复测观察两天再决定下一步大堆场景多留意超过16G的堆用G1时要注意 -XX:G1HeapRegionSize 与RSet开销的关系必要参数用文档记录下来交接给下一位维护者。6. 结尾别把GC调优神话我在实际工作中遇到很多同事把GC调优当成玄学复制一堆自己都没验证过的参数。其实绝大多数线上GC问题不是靠调参数解决的而是靠清理代码里的过度对象分配、无界缓存和错误的生命周期设计。参数只是帮你把问题后延代码层面的问题才是起点。另外记得每次调整GC配置后做一次完整的性能回归重点看吞吐量和P99的变化。因为GC参数有时候把停顿压低了但吞吐量掉得厉害对混合负载的系统反而不利。最后分享一个小技巧如果怀疑对象分配过多可以在JDK 11用-Xlog:gcphasesdebug输出更为详细的回收阶段信息观察哪个阶段耗时最高再针对性地往代码层找原因。GC问题不可怕可怕的是在一片模糊的数据里瞎猜。把日志、观察工具和分析思路都建立起来问题基本能一步一步解开。