1. 一次线上卡顿排查,让我决定把JVM垃圾回收彻底搞明白
接手一个基于Spring Boot的订单服务后,我第一次被JVM垃圾回收(GC)上了一课。线上接口P99从80ms一路飙到2.3秒,业务日志干干净净,数据库连接池没有任何告警,唯一能对上号的,是GC日志里每隔几分钟就出现一次超过800ms的Young GC暂停。从那天起我就认死了一个理:不懂JVM垃圾回收算法,Spring Boot调优就是隔靴搔痒。
这篇文章我会把GC从原理到实战讲透:分代收集为什么存在、CMS跟G1到底怎么选、生产环境怎么用jstat定位问题、Spring Boot该配哪些启动参数,以及我踩过的几个真实坑。适合正在写Java、跑微服务的同学,也适合那些对GC只有模糊印象、准备面试时总被问倒的人。看完你会发现,调优这件事没有玄学,只有可复现的排查链路和可量化的参数组合。
1.1 事故现场:P99从80ms飙到2.3秒
那是一个普通的晚高峰,订单服务3个Pod,4C8G规格,压测时2000 QPS稳稳的。结果某天20:30开始,网关监控里P99突然冲到2.3秒,P50反而几乎没变。这个信号很关键:只有一部分请求被拖慢,典型的停顿型问题,而不是服务整体过载。
当时的第一反应是查业务链路:SQL慢日志、Redis耗时、下游调用超时,全部没异常。CPU在容器里只有35%,内存看着也够,就是不知道时间去哪儿了。直到我打开GC日志,才看到那句让我浑身发冷的话:[Times: user=0.86 sys=0.01 real=0.82 secs]——一次Young GC暂停了820ms。再往前翻,这类长时间暂停在晚上8点到9点之间出现了17次。
事后复盘,根因并没有多复杂:订单服务在晚高峰大量创建临时对象(DTO、集合、日志上下文),默认堆参数下的新生代被瞬间灌满,每一次Eden区填满都触发一次较长的Young GC。暂停期间业务线程全部阻塞,P99自然就崩了。
1.2 排查路径:先排除业务问题,再锁定GC
那次之后我给自己立了个排查规矩:线上慢,永远先看三件套——数据库慢日志、Redis/MQ耗时、JVM GC状态,而且顺序不能乱。为什么先把业务和中间件排除掉?因为GC暂停是"隐形凶手",如果一开始就扎进代码里找死循环、查SQL,大概率浪费两小时。
查GC最直接的命令是jstat -gcutil <pid> 1000,意思是每秒打印一次内存使用率。输出里重点看四列:E(Eden使用率)、O(老年代使用率)、YGC(Young GC次数)、FGC(Full GC次数)。我当时发现YGC一分钟内涨了20多次,FGC也在缓慢增加,基本可以确定问题出在GC上,剩下的工作就是用日志还原细节。
这里想提醒一句:不要等到出问题才开GC日志。生产环境默认不开日志的话,事故发生时你连证据都没有,只能靠猜。这个习惯一定要从项目第一天就养成。
1.3 GC调优的本质:吞吐量与延迟的取舍
很多人把GC调优当成"让垃圾回收不发生",这是最大的误解。垃圾回收必然发生,调优的目标是让它在合适的时间、用合适的方式发生,并且把对业务的影响压到可接受范围。
衡量GC有两大指标:吞吐量和暂停延迟。吞吐量=非GC运行时间/总运行时间,比如GC占了总时间的2%,吞吐量就是98%;暂停延迟指的是单次STW(Stop The World)造成的停顿,上面案例里820ms就是典型的暂停延迟问题。
这两者常常是矛盾的:堆内存调大,GC频率降低、吞吐量变好,但每次回收的存活对象变多,单次暂停变长;堆内存调小,单次GC很快,但频率升高,总暂停时间可能反而增加。所以不存在"最优参数",只存在"最适合你业务特征的参数组合"。理解了这一层,后面所有的参数选择都能找到依据。
2. 垃圾回收算法底层拆解:标记、复制、整理与分代设计
面试题总爱问"JVM有哪几种垃圾回收算法",但很多人背完答案还是不知道这些算法为什么长这样。我这部分不打算背书,而是把设计逻辑讲透:为什么要有可达性分析?为什么新生代用复制算法?为什么老年代用标记-整理?搞清楚这些"为什么",你调参的时候脑子里才有完整的图。
2.1 可达性分析:从GC Roots出发的关系网
判断一个对象是否存活,主流JVM用的都是可达性分析,而不是引用计数。原因很简单:引用计数没法解决循环引用,A引用B、B引用A,两边计数都不归零,这俩对象就永远清不掉。可达性分析没有这个问题。
思路是选取一组确定的"根"——叫GC Roots,然后从这些根出发做图遍历,能走到的对象就是活着的,走不到的统统是垃圾。GC Roots包括:线程栈帧里的局部变量、静态变量引用(类变量)、JNI引用、正在被使用的锁对象(monitor)、以及JVM内部关键数据结构。
注意一个关键点:这个遍历过程中,引用关系不能突然变掉,否则会出现"刚才还活着、下一秒引用没了"的脏数据。所以枚举根节点和标记阶段必须STW,把所有业务线程冻结住,保证标记结果一致。这也是GC暂停的根本来源。现代收集器一直在努力缩短STW,但一致性标记这个底线谁都不能破。
2.2 三种基础回收算法如何取舍
历史上沉淀下来三种基础算法,后来所有高级收集器都是它们的组合变种:
| 算法 | 思路 | 优点 | 缺点 | 典型用途 |
|---|---|---|---|---|
| 标记-清除 | 先标记存活对象,再统一清除未标记对象 | 实现简单,不搬动对象 | 产生内存碎片;标记和清除都要扫全堆 | CMS的并发清除阶段 |
| 标记-复制 | 把存活对象复制到另一块空区,原区整体清空 | 无碎片;分配只要移动指针 | 内存有浪费,存活率高时复制成本大 | 新生代收集 |
| 标记-整理 | 标记后把存活对象往一端移动,再清理边界外内存 | 无碎片,内存连续 | 移动对象开销大,STW时间长 | 老年代完整回收 |
标记-清除最大的坑不是慢,而是碎片化。内存被切成无数小块后,你明明空闲总量够,却可能分配不出一块连续空间来放一个大对象,最后只能触发Full GC整理一遍。标记-复制的代价是空间:如果直接对半分成两块"From"和"To",可用内存直接少一半,所以它只适合用在存活对象极少的区域。
标记-整理解决碎片靠的是"搬动",但搬动意味着对象地址变化,所有引用它的地方都得更新,这一步需要遍历整个堆的引用关系,代价很高。所以它一般只用来兜底老年代,不常跑。
2.3 分代收集:为什么新生代用复制、老年代用整理
绝大多数Java对象"朝生夕灭"——创建出来转手就变成垃圾。IBM等机构早年做过统计,超过98%的对象活不过第一轮GC。基于这个事实,JVM把堆分成新生代和老年代两代。
新生代里,Eden区负责接收新对象,两个Survivor区(S0、S1)轮流接住GC后存活下来的对象。默认-XX:SurvivorRatio=8,Eden和两个Survivor的比例是8:1:1。比例这么设计,是因为每次Young GC后存活对象通常只有10%左右,用一个Survivor兜住绰绰有余,另一块空着备用——复制算法用10%的空间换无损压缩,性价比极高。
对象在Survivor区每躲过一轮GC,年龄+1,达到-XX:MaxTenuringThreshold(CMS默认6,G1里是可动态调整的15)后晋升到老年代。还有两种情况会直接进老年代:一是大对象(大到超过区域一半,G1里叫Humongous Object),二是有动态年龄判断——同一轮GC里存活对象达到Survivor空间的一半,较大年龄的对象会被提前晋升,防止Survivor空间被挤爆。
老年代里存活对象比例高、生命周期长,还总想着复制会亏到姥姥家,所以用标记-整理或者"标记-清除+碎片整理兜底"的策略。
2.4 对象晋升规则与动态年龄判断
这块我多说几句,因为很多调优问题都出在对晋升机制的理解偏差上。
JVM并不是"年龄满了才晋升",还有个动态判定:当某轮Young GC后,Survivor中同龄对象的总大小超过Survivor空间的50%,从该年龄开始的对象就会被提前晋升。设计意图是防止Survivor空间被持续存活的对象打满,如果它满了,剩余对象直接进老年代,可能引发老年代提前Full GC。
理解了规则,你就能解释一个很常见的现象:明明代码没大问题,老年代却在缓慢增长。很可能是-XX:SurvivorRatio设置不当,Survivor太小,大量本可以在新生代熬过几轮的短命对象被提前推到老年代,老年代淘汰不了它们,只能靠Full GC清理。这类问题在日志里的表现,就是部分对象晋升年龄永远达不到阈值。
3. 主流垃圾收集器对比:Serial、Parallel、CMS、G1、ZGC
算法解决的是"怎么回收",收集器解决的是"回收时整个线程怎么协作"。选错收集器,参数调得再花哨也白搭。这一节我把几个主流收集器按演进顺序讲清楚,附带选型建议。
3.1 Serial与Parallel:最朴素但别小看
Serial是最古老的收集器,单线程回收,回收期间STW完全停住。Serial不是废物——在小堆、单核、客户端场景下,停顿几百毫秒根本无所谓,但实现简单、没有线程协作开销,反而稳定。只是放现在的Spring Boot微服务里基本用不上。
Parallel在JDK8时代是服务端默认选择,核心追求吞吐量。它用多个GC线程并行执行回收,让STW的绝对时间大幅缩短,但仍然全程STW。如果你有一个批处理任务、离线计算服务,不太在意单次停顿,只关心单位时间能处理多少任务,Parallel会比G1更合适。记住一个判断:吞吐优先选Parallel,延迟敏感选G1或ZGC。
3.2 CMS:并发先驱与碎片难题
CMS的全称是Concurrent Mark Sweep,它是"消灭长时间STW"理念的先行者,第一次让大部分标记和清除工作与业务线程并发执行。整个流程分四步:初始标记(STW,只标GC Roots直接引用的对象,很快)、并发标记(和业务线程一起跑)、重新标记(STW,修正并发期间引用变化,时间较短)、并发清除(和业务线程一起回收)。
CMS的优点一目了然:真正长的步骤都在并发,总暂停时间大量减少。但它有两个著名的病:一是内存碎片,它用标记-清除不动对象,碎片攒到一定程度,触发Full GC时反而用Serial Old做串行整理,卡顿比不用CMS还猛;二是Concurrent Mode Failure——并发标记期间老年代空间不够,CMS一边回收集一边还要应对新晋升对象,赶不上就直接"并发模式失败",弃疗式地回退到Serial Old全面STW。
CMS在JDK9被标记废弃,JDK14正式移除。如果你在维护老项目还看到它,我的建议是趁早规划迁到G1,别等到JDK升级被强制切换。
3.3 G1:分区模型解决了什么
G1在JDK9之后成为默认收集器,核心思想是把整堆划分为许多大小相等的Region(默认约2048个,单Region大小从1MB到32MB动态选取),不再严格按新生代/老年代物理隔离,而是逻辑上由Region拼凑。
G1的两大设计亮点:一是可预测停顿,通过-XX:MaxGCPauseMillis目标值,G1会动态调整每次回收的Region数量,尽量把停顿压到目标以下,这是一个软目标而非硬保证;二是混合回收,Young GC之外,G1根据老年代Region的垃圾占比排列回收优先级,每次挑垃圾最多的Region回收集,不用一次性清空整个老年代。
每个Region还维护了一张RSet(Remembered Set),记录"有哪些其他Region的老对象引用了我这块Region的对象",这样回收单个Region时不用全堆扫描,代价是RSet本身有内存开销。G1对大堆、碎片化严重的中大型应用很友好,这也是它成为现代Spring Boot默认选择的原因。
3.4 ZGC与Shenandoah:低延迟的另一条路
ZGC的核心卖点是暂停时间不随堆大小线性增长。它通过染色指针(Colored Pointers)和读屏障,把标记、对象移动的大部分工作并发化,即便堆到几十GB甚至TB级别,目标也只是把STW控制在毫秒级。JDK15开始ZGC在Linux/x64上生产可用,JDK21引入了分代ZGC,进一步提升了吞吐。
Shenandoah走的路径不同,它用"转发指针"配合并发整理,也做到了低暂停。这两者不是替代G1的存在,而是"延迟敏感到极致"场景的备选——比如证券交易撮合、风控实时决策,停顿超过几十毫秒就算事故。对绝大多数业务,G1已经够用,不必盲目追新。
3.5 不同场景下的选型参考
| 场景 | 推荐收集器 | 理由 |
|---|---|---|
| 单机小堆、低并发老系统 | Serial或Parallel | 实现简单,吞吐稳定 |
| 批处理、离线任务 | Parallel | 吞吐优先,可接受较长STW |
| 在线微服务、中等堆(4G~32G) | G1 | 吞吐与延迟均衡,默认支持 |
| 超大堆、延迟敏感 | ZGC | 暂停时间可预测,毫秒级 |
| 老旧技术栈且不想动配置 | 原默认(多为Parallel或CMS) | 迁移成本收益需评估 |
选型建议的原则:不要因为G1是默认就迷信它,也不要因为ZGC新潮就强行上。先想清楚你的业务最怕什么,是怕吞吐上不去,还是怕单次请求被卡几百毫秒。
4. 诊断先行:GC日志、jstat、jmap的正确打开方式
不会看袠,调优就是瞎猜。我见过太多人上来就改-Xmx,连GC日志长什么样都不知道。这一节给出一套完整的诊断工具箱,从日志开启到命令解读,全部是可复现的操作。
4.1 从JDK8到JDK17的GC日志开启姿势
JDK8和JDK9+的日志参数格式差异很大,经常有人在迁移JDK时踩坑。
JDK8下,建议这样配:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -Xloggc:/data/logs/gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=50MJDK9开始统一日志框架,老参数全废弃,改用-Xlog:
-Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tagsgc*代表所有gc相关的日志级别,time输出时间戳,uptime输出JVM启动后的秒数,这两个对关联业务时间点很有用。生产上建议按天滚动备份,配合定时清理,否则日志文件会吃掉磁盘。
4.2 jstat、jmap、jcmd常用命令与输出解读
诊断线上JVM,我的常用组合是这样一套:
# 每秒输出一次内存使用率和GC统计,连续20次 jstat -gcutil <pid> 1000 20 # 查看具体各区域容量 jstat -gc <pid> # 查看堆配置和区域分布 jmap -heap <pid> # 打印堆中对象实例数Top前30 jmap -histo <pid> | head -30 # JDK8之后推荐用jcmd,更安全 jcmd <pid> GC.heap_info jcmd <pid> GC.class_histogramjstat -gcutil输出里,每一列都有意义:S0/S1是两块Survivor的使用率,E是Eden,O是老年代,M是Metaspace,CCS是压缩类空间,YGC是Young GC累计次数,YGCT是Young GC累计秒数,FGC和FGCT对应Full GC。我最先看的一定是YGC的频率和FGC的趋势,其次看O区是否在持续缓慢上升。
这里有个生产环境的纪律:jmap -dump:format=b,file=heap.hprof <pid>会触发一次Full GC再导出堆,线上高负载时要谨慎使用,更稳妥的做法是配置-XX:+HeapDumpOnOutOfMemoryError,让JVM在OOM时自动落盘。另外,Arthas的dashboard命令也能在线看内存和GC线程情况,适合不想敲jstat命令的场景。
4.3 从一段真实GC日志反推堆配置问题
拿一段G1日志做完整解码:
[GC pause (G1 Evacuation Pause) (young), 0.0472131 secs] [Eden: 812.0M(820.0M)->0.0B(820.0M) Survivors: 24.0M->24.0M Heap: 832.0M(8.0G)->240.0M(8.0G)] [Times: user=0.12 sys=0.00 real=0.05 secs]Eden从812M降到0,说明这一轮年轻代对象几乎全被回收;Survivor保持24M不变,说明有对象留在Survivor但没触发晋升;Heap从832M降到240M,整体回收量很大,单次暂停47ms,看起来正常。但如果你看到Eden平均两秒就满了,一小时YGC上千次,说明分配速率远超回收速率——要么业务创建对象太凶,要么堆给得太小。
判断分配速率的公式很简单:一次Young GC回收的Eden大小÷两次GC的间隔秒数。比如一次回收800M,间隔3秒,那分配速率就是约270MB/s。拿这个数字去反推:如果堆是4G,Eden约2.4G,那每9秒一次Young GC就是常态。这时候就不该急着调参,而应该看代码里为什么每秒要分配270MB的对象。
5. Spring Boot实战调优:从启动参数到代码层面的完整方案
理论讲完,进入正题。这一节是纯实操,我会给出一套生产基线参数,然后通过两个高频故障场景展开完整的调优过程,最后补上代码层面的优化习惯。每一步都有明确目的,不让你"照抄但不知道为什么"。
5.1 生产基线启动参数模板与逐行解释
下面是我在新项目里常用的基线配置,8G堆内存、G1收集器、现代化JDK(17及以上):
JAVA_OPTS="-Xms8g -Xmx8g \ -XX:MaxMetaspaceSize=512m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/heapdump.hprof \ -XX:+ExitOnOutOfMemoryError \ -Xlog:gc*:/data/logs/gc-%t.log:time,uptime,level,tags"逐条解释设计意图:-Xms和-Xmx设为相同值,避免JVM动态调整堆大小引起的性能抖动;MaxMetaspaceSize=512m给Metaspace设上限,防止类加载器泄漏把内存吃光(后面避坑章节细说);MaxGCPauseMillis=200告诉G1我们的延迟底线;HeapDumpOnOutOfMemoryError和HeapDumpPath是出事后的"黑匣子";ExitOnOutOfMemoryError让JVM在OOM时直接退出,配合容器自动重启,比半死不活地挂着更健康。
Spring Boot应用不需要把参数写死在代码里,统一走环境变量,用启动脚本传入:
#!/bin/bash export JAVA_OPTS="-Xms8g -Xmx8g ..." exec java $JAVA_OPTS -jar order-service.jar或者用容器编排的JAVA_OPTS字段。注意别在application.properties里配置堆大小,那是JVM层面的东西,Spring Boot管不到。
5.2 场景一:Young GC频繁导致接口响应不稳定
这是我最常遇到的问题。现象:jstat -gcutil看到Eden几乎每秒都在接近100%,YGC每两三秒一次,单次Young GC暂停100~400ms,接口P99跟着波动。
处理顺序我建议严守:先看分配速率,再做业务优化,最后才动参数。为什么?如果代码在循环里疯狂创建字符串和集合,你把-Xmx从8G加到16G,只会让GC频率从2秒一次变成4秒一次,暂停还可能更长,治标不治本。
代码层第一刀:循环内对象复用。比如批量处理订单时,很多人这样写:
for (Order order : orders) { StringBuilder sb = new StringBuilder(); // 拼一行日志或者组装缓存key sb.append(order.getId()).append("-").append(order.getStatus()); cacheKey = sb.toString(); // 使用cacheKey... }每轮循环新建StringBuilder看似没什么,但百万级循环就是百万次对象分配。优化后把StringBuilder提到循环外,sb.setLength(0)清理复用。
第二刀:集合指定初始容量。ArrayList默认容量10,你往里塞几千条,扩容就要反复搬数组。声明时就给定new ArrayList<>(expectedSize),能省掉大批临时数组分配。
如果业务代码已经压得很干净,分配速率还是高,才考虑调参。8G堆下,G1的年轻代是动态调整的,可以通过-XX:G1MaxNewSizePercent(默认60%)微调;也可以直接增大整个堆,给Eden更多空间。注意一次只改一个参数,压测对比前后的GC频率和P99再决定去留。
5.3 场景二:Full GC次数持续上升且内存不释放
FGC不断上升,通常意味着老年代里有对象"该走不走"。先看jmap -histo的Top对象,如果清一色是byte[]、char[]、HashMap$Node,再配合堆转储分析,基本能定位到泄漏源头。
我印象最深的一个坑是ThreadLocal。用线程池跑任务时,线程是复用的,ThreadLocal的值存在Thread对象自己的threadLocals字段里,任务跑完不remove(),那条线程就永远背着这个大对象。池里200条线程,每条背一个10MB的缓存,就是2G老年代内存,FGC不找你找谁。
修复方式很机械但必须养成习惯:
private static final ThreadLocal<BigFeatureContext> CTX = new ThreadLocal<>(); // 任务结束finally里一定清掉 try { CTX.set(loadContext(taskId)); doBiz(task); } finally { CTX.remove(); }第二个常见元凶是无界缓存。有人喜欢用静态Map做本地缓存,只put不淘汰,老年代就一路涨。正确姿势是堆内存有限的缓存框架(Caffeine带maxSize和过期策略),或者用WeakHashMap做"弱引用键"的临时缓存。记住一个判断标准:老年代使用率呈阶梯形上升,每次FGC后降一点但底部越来越高,基本就是泄漏;呈锯齿形但底部稳定,说明是对象生命周期正常,只是堆偏小。
5.4 代码层面的"隐式垃圾"与优化习惯
除了线上救火,日常编码里还有一些不容易注意的分配习惯。一是字符串拼接,JDK9之后的字符串拼接虽然由StringConcatFactory优化,但在循环里还是优先用StringBuilder;二是大数组一次性申请,比如byte[] buffer = new byte[1 << 20];如果只为了几K数据,纯属浪费;三是逃逸分析并不是万能药,HotSpot的栈上分配确实能把部分非逃逸对象放到栈上,减少堆分配,但对于大多数集合和跨方法返回的对象,它帮不上忙——所以不要指望JVM魔法兜底,代码层面该省还是得省。
另外强烈建议给Spring Boot接入可观测指标。Actuator自带jvm.gc.pause、jvm.memory.used等Micrometer指标,接入Prometheus和Grafana后,我可以直接看GC暂停的秒级趋势,再也不用等线上事故才去翻日志。这不是锦上添花,而是调优闭环里不可或缺的一环。
6. 避坑指南:那些反直觉的调优教训
最后一节,把我在真实项目里踩过的、以及身边团队反复掉进去的坑集中列一遍。这些坑之所以坑,就是因为它们违反直觉——你总觉得自己在做正确的事,结果越调越糟。
6.1 堆内存不是越大越好
一个非常反直觉的事实:堆给得太大,GC暂停反而不可控。回忆一下GC暂停的构成——复制存活对象、更新引用、扫描RSet,跟"总堆有多大"没有必然关系,更受"活对象有多少"影响。但堆越大,每次GC可能涉及的Region数量就越多,年轻代越大,单次Evacuation Pause的存活对象总量也越大。
更实际的风险在容器环境:K8s Pod限制8G内存,你给-Xmx8g,还要留Metaspace、线程栈、JIT、直接内存的余量,结果物理内存直接被OOM Killer干掉,服务莫名其妙重启。堆大小建议控制在容器内存的70%~75%,剩下的留给堆外。
6.2 Metaspace不设上限的后果
Metaspace存的是类的元数据。Spring Boot项目里如果做动态类加载、Groovy脚本、热部署,或者中间件动态生成代理类,Metaspace会持续增长。不设上限时,它看起来"够用",直到某天撑爆容器内存。
设了-XX:MaxMetaspaceSize=512m之后,问题提前暴露为OutOfMemoryError: Metaspace,这个错误能被监控捕捉,比直接触发Linux OOM好处理得多。如果你发现Metaspace持续上升,优先检查类加载器泄漏:是不是每个请求都创建了新的类加载器?是不是反射调用没有缓存Class对象?
6.3 调优不是一次压测改个参数就结束
我把调优看成循环:建立基线→压测验证→改一个参数→再压测→对比→保留或回滚。一次只改一个参数这条纪律太重要了,同时改堆大小和GC目标值,出了问题你根本不知道是哪一步造成的。
另一个顽固误区是"追求零GC"。总有同学看到Young GC很频繁,就想把年轻代调到超大好让GC变少。实际上,只要对象还在产生,GC就不可能消失;合理的频率远胜于"憋大招"。我一般只看三个数——Young GC间隔、平均暂停、FGC频率,这就是我的"参数调优三件套",三个数都落在预期内就收手,绝不过度优化。
6.4 容器环境下的JVM参数坑
JDK8u191之前,JVM不认cgroup的内存限制,拿的是宿主机内存来算默认堆。你在4G容器里跑JVM,它可能以为自己有64G可用,直接按物理机内存比例分配堆,结果秒被OOM Killer。老版本一定要手动加-XX:+UseCGroupMemoryLimitForHeap,升级到JDK8u191及以上则默认开启容器感知。
另一个隐蔽坑是-XX:+DisableExplicitGC。禁止显式System.gc()能防止RMI等组件周期性触发Full GC,但有些依赖堆外内存的框架(比如Netty的DirectBuffer清理)会依赖System.gc()兜底,贸然禁用可能引发堆外内存泄漏。G1环境下更稳妥的做法是加-XX:+ExplicitGCInvokesConcurrent,把显式GC转成并发收集,既响应了请求又不全停顿。
最后是G1Region大小的问题。默认情况下G1会根据堆大小自动选Region尺寸,但如果你有大对象(比如一个10MB的字符串数组),它会跨越多个Region变成Humongous Object,这种对象的分配和回收都比较笨重。善用-XX:G1HeapRegionSize把Region调到能装下大头对象的大小,能减少这类特殊处理。
写到这里基本把GC从原理到Spring Boot实战的路都走了一遍。我的个人体会是:真正值钱的不是记住某个参数,而是养成一套"看日志→量化指标→定位根因→最小改动→验证回滚"的排查习惯。调优现场最怕的不是问题复杂,而是你手里没有可复现的证据链。下次再遇到线上卡顿,先深呼吸,打开GC日志,用jstat量一量,然后再决定要不要动参数——大部分时候,你会发现问题根本不在JVM,而在那段三秒钟一次的字符串拼接循环里。