
前几天有个同事拿一份压测报告来找我说他们的网关服务一压测就抖TP99平时只有2毫秒的接口到了峰值直接飙到800毫秒再往上压就开始批量超时。这位同事在群里发了一连串监控截图CPU、内存、网络全都有但我没看完就猜了个大概——十有八九又是GC停了世界。后来我把GC日志和堆dump拉出来一看老年代一个下午Full GC了三十多次最狠的一次暂停接近3秒。做Java服务这些年这种栽在GC手里的案例我见得太多了凡是字符串处理密集、临时对象产生量特别大的项目几乎都会把GC性能陷阱踩个遍。这次正好把排查和调优的完整过程整理出来从原理到参数、从日志到代码给同样被GC拖累的朋友一份能直接抄的作业。1. GC性能陷阱的本质先搞清楚垃圾回收在忙什么1.1 垃圾回收为什么会影响性能很多开发者对GC的理解停留在“自动释放内存”这个层面觉得有GC就不用管内存了直到线上出现一次几秒钟的卡顿才意识到问题没那么简单。GC的核心代价是STWStop The World垃圾回收器在回收内存时需要暂停所有业务线程暂停期间应用对外是完全不可服务的。Minor GC的停顿通常很短几十毫秒以内但如果频率极高整个系统的吞吐会被切得非常碎Full GC则是全局回收范围覆盖整个堆还经常伴随压缩阶段停顿从几百毫秒到几秒都很常见对延迟敏感的服务来说基本就是大事故。我经常用保洁阿姨做类比办公室每过几分钟就进来擦一次桌子每次擦桌子所有人都得站起来虽然一次只有几十秒但一上午的工作节奏全被打乱如果保洁阿姨来一次大扫除一搞就是几分钟那这段时间里所有工作都得停下。GC就像这个保洁团队它干活的时候会扛着业务线程走到一边整个过程无法避免。1.2 性能陷阱的四个典型来源GC性能陷阱的来源远不止“内存不够”这么简单。我整理了一个对照表基本涵盖了我在实际项目中见过的高频坑陷阱类型典型表现根本原因分配速率过高Minor GC频繁、吞吐量下降单位时间创建的临时对象过多大对象直接晋升老年代快速打满、Full GC频繁大对象越过新生代过早占用老年代堆动态扩缩容偶发长停顿、GC日志出现扩容动作Xms与Xmx不一致堆在增长/收缩GC线程抢占CPUCPU高但业务吞吐不高并发标记/清理阶段与业务线程争抢资源第一类分配速率过高是最常见也最容易被低估的。很多服务表面看起来很稳但GC日志里YGC新生代GC的频率高得吓人说明业务代码里每秒钟都在产生海量短命对象这些对象大部分在新生代就被回收了可问题是回收动作本身也在消耗CPU和暂停时间。第二类大对象晋升更隐蔽数组或超大字符串可能直接跳过新生代进入老年代老年代空间被快速消耗Full GC来得比预期早得多。第三类则完全是配置问题很多部署脚本里Xms设得小、Xmx设得大JVM一开始用小堆压测流量一上来就不断扩容扩一次容就要STW一次雪上加霜。2. 场景复盘一压测就抖的特殊字符网关2.1 项目背景与问题表现这次出问题的服务团队内部代号就叫“特殊字符”——原因很简单它处理的上游报文里含有大量特殊字符核心链路包括特殊字符过滤、脱敏、格式规范化再转发给下游系统。这类业务的典型特征非常鲜明几乎每个报文字段都要走正则匹配和字符串拼接临时对象的产生量大得惊人。压测场景是模拟一批包含大量特殊符号的报文持续涌入并发数从50逐渐拉高到200。压测刚开始的几分钟表现一切正常但并发超过某个阈值后延迟曲线开始断崖式爬升。我让同事把压测过程中的关键数据拉了出来并发数TP99延迟有效吞吐Full GC次数10分钟503ms4200/s010012ms3800/s2150286ms2100/s11200830ms900/s27为什么前几分钟稳、后面突然崩这几乎是GC问题的标准剧本启动阶段堆内存充足短命对象还能在新生代里被快速清理但随着并发升高对象产生速率大幅上升越来越多“活得略久”的对象开始晋升到老年代老年代被打满后一次Full GC的停顿就像急刹车后续请求全部排队延迟瞬间失控。2.2 GC日志里的第一个信号排查这类问题第一步永远是看GC日志。下面是压测现场抓到的关键日志片段时间戳正好是压测峰值出现的节点2026-01-24T17:29:33.1230800: 382.456: [GC (Allocation Failure) [PSYoungGen: 2044128K-245640K(2555904K)] 3415248K-1530256K(4096000K), 0.0462050 secs] ... 2026-01-24T17:29:33.6710800: 383.004: [Full GC (Ergonomics) [PSYoungGen: 245640K-0K(2555904K)] [ParOldGen: 3648048K-68702K(3686400K)] 3893688K-68702K(6242304K), [Metaspace: 93250K-93250K(...)], 2.8321102 secs]这段日志信息量很大。前一行是Minor GC触发原因是Allocation Failure也就是新生代在给新对象分配内存时空间不足这个停顿只有46毫秒其实还好。重点是第二行Full GC停顿了2.83秒这2.83秒里所有业务线程全部暂停表现在业务上就是大量超时和心跳断开。注意括号里的PSYoungGen和ParOldGen说明当前用的是Parallel Scavenge配合Parallel Old收集器这套组合在吞吐量上确实不错但STW停顿相当明显根本不适合延迟敏感型业务。括号里那些数字从左到右分别是GC前的占用、GC后的占用、该区域的总容量。倒数第二个括号6242304K就是堆的总大小算下来约6GB。此时老年代GC前占用了3648048K接近3.5GB说明老年代已经被打满Full GC回收掉大半之后才回归正常但这只是暂时的只要流量继续这个循环就会反复上演。3. 定位路径三步锁定GC根因3.1 先看堆的使用曲线很多团队看完GC日志就直接冲去改参数我不建议这么干。调参之前得先搞清楚老年代里到底住着谁为什么晋升这么多排查的第一站是用jstat看堆的实时变化采样间隔1秒持续观察几十秒jstat -gcutil 23831 1000输出结果类似这样S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 13.76 87.34 86.12 95.20 96.01 218 3.452 37 62.549 65.981这里重点看三个指标YGC次数和YGCT决定了新生代的清理频率和耗时FGC次数和FGCT则直接反映老年代回收的压力。这个项目在压测不到半小时内YGC跑到218次、Full GC跑了37次平均下来每分钟超过1次Full GC已经非常严重。再看E区使用率87%O区使用率86%几乎全程处于高位运转说明不是偶尔抖动而是系统性堆积。第二步用jmap确认JVM当前堆配置和实际用量是否匹配jmap -heap 23831这个命令会输出堆参数、各区域容量和当前使用率。如果发现Xms远小于Xmx且当前堆用量在持续爬升就说明动态扩容路径也被激活了这部分我们后面会在参数调优里专门说。3.2 再看谁占用了老年代定位老年代空间被什么对象占据最直接的方式是先打一枪直方图jmap -histo:live 23831 | head -30实测下来输出名列前茅的对象类型非常有代表性num #instances #bytes class name 1: 15302934 1224234720 [C 2: 9120334 218887200 [B 3: 8510234 204245616 java.lang.String 4: 1029932 179442170 java.util.regex.Pattern 5: 998302 75639210 java.util.regex.Matcher这里的[C是char数组也就是String对象的底层存储。一个Java字符串对象实际字符数据就放在char[]里这一项居然占到了1.2GB以上排在第二位的[B是byte数组通常是报文缓冲或编码转换的产物。结合后面的Pattern和Matcher数量原因已经浮出水面这个项目大量使用正则表达式而且还伴随着海量字符串拼接导致字符数组几乎堆满了整个老年代。到这里我建议再做一次堆快照把现场完整保留下来jmap -dump:formatb,filespecial_char_dump.hprof 23831然后用MAT打开这个hprof按Dominator Tree看最占内存的对象的持有链。这一步很关键如果老年代里躺着大量无法回收的中间字符串一定要找到谁在引用它们。当时查出来的问题很有意思一批处理完的原始报文被放进了一个“上下文对象”里虽然业务不再需要它们却因为上下文对象被缓存到请求处理完才释放导致大字符串的生命周期被无限拉长。这就是典型的“该短命的没短命”。3.3 最后看分配路径具备基础知识的话还应该理解JVM的对象分配路径。HotSpot虚拟机里大多数对象先尝试在TLABThread Local Allocation Buffer上分配TLAB是线程私有的小块区域可以减少并发竞争对象如果被证明没有逃逸甚至有机会在线程栈上分配直接避开堆内存的GC压力。HotSpot默认开启逃逸分析-XX:DoEscapeAnalysis和标量替换-XX:EliminateAllocations但这两项优化不是万能的对象作为方法返回值返回、被放进集合容器、被静态字段引用都会打破“不逃逸”的判定优化自动失效。这个网关项目里的处理流程是典型的逃逸重灾区每个报文进来都要经过多层方法处理中间产生的字符串要么被拼接后返回要么被塞进上下文对象几乎没有对象能逃过堆内存的宿命。所以代码层面的调整方向就很明确了——不是抱怨优化器不干活而是从源头减少那些“注定要逃逸”的临时对象的产生数量。4. 参数调优像配空调一样配JVM堆4.1 Xms与Xmx要相等拒绝动态扩缩容先处理配置层面的问题。很多发布脚本里写的都是-Xms512m -Xmx4g初衷是“省内存”但服务端JVM根本不适合这么玩。堆从512MB扩容到4GB的过程不是瞬时的JVM在扩容和缩容阶段都会触发线程暂停缩容还往往发生在Full GC之后两个动作叠加起来停顿时间翻倍。固定堆尺寸的理由很简单减少不确定的停顿。我的建议是-Xms4g -Xmx4g让JVM启动时就一次性申请完整堆大小。具体多少要根据业务体量来定一个可供参考的经验线是取老年代常态占用的2到3倍再在压测中逐步修正。千万不要把Xmx设成物理内存上限要给Metaspace、线程栈、堆外缓冲区留出余地。4.2 新生代比例与晋升阈值的权衡新生代的大小决定了对象在“短命区”能待多久。默认配置下新生代约占堆的1/3但对于这个项目大部分对象生命周期极短把它们留在新生代里被Minor GC回收远比让它们晋升到老年代再被Full GC回收要便宜得多。我建议把新生代适当调大堆4GB时可以先用-Xmn1536m或者用-XX:NewRatio3把新生代占比抬上去。同时要留意Survivor区比例和晋升阈值的关系。-XX:MaxTenuringThreshold控制对象经历多少次Minor GC后晋升到老年代默认值是15。如果Survivor区太小对象还没有活到阈值就被迫晋升如果太大又可能让本该早点晋升的长命对象来回复制。这里不建议直接猜最好在参数里加上-XX:PrintTenuringDistribution先看对象年龄分布再决定要不要调阈值。一个很容易犯的错调参数时一下改好几个变量。线程池大小、堆比例、GC算法一起改出问题根本没法定位。每次只动一个点用同一套压测脚本跑同样的时长对比GC日志的变化。4.3 G1还是ZGC延迟敏感场景的选择这个项目原来用的Parallel Scavenge收集器虽然吞吐量出色但STW停顿完全不适用于延迟敏感业务。JDK 11及以上版本G1已经是默认收集器设计思路是Region化堆和并发回收把一次全堆扫描式的停顿拆成多轮配合-XX:MaxGCPauseMillis可以将单次停顿控制在目标范围内。我推荐把服务切到G1暂停目标先设为200ms跑两轮压测再决定是否收紧。收集器适用场景停顿特征建议Parallel高吞吐、堆不大、允许较长停顿大且明显不适合延迟敏感服务G1中等至大堆、延迟有要求、JDK 11可控目标可设定这个项目的首选ZGC超大堆、极低延迟、JDK 21表现成熟亚毫秒级有升级条件可纳入考虑这里要提醒MaxGCPauseMillis是软目标不是硬保证而且目标设得越小G1触发混合回收越频繁反而会牺牲吞吐量。设成10ms这类激进值通常适得其反。如果服务已经升级到JDK 21ZGC的亚毫秒停顿确实诱人但ZGC对堆外内存、文件描述符都有额外要求线上切换前务必先在压测环境跑满48小时。最终的JVM参数组合我整理成一份可直接参考的模板-Xms4g -Xmx4g -Xmn1536m -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:MaxTenuringThreshold8 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -Xlog:gc*:file/data/logs/gc.log:time,uptime,level:filecount10,filesize50mJDK 8环境没法用-Xlog需要换成传统开关-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCApplicationStoppedTime4.4 容器环境下的参数补充如果服务部署在容器里只调-Xmx还是不够。JVM默认读取的是宿主机内存而不是容器配额这就导致一个常见事故容器限制2GBJVM却以为有几十GB可用堆一膨胀直接触达容器内存上限然后整个进程被系统杀掉。JDK 8u191版本之后JVM默认开启容器感知UseContainerSupport可以用百分比参数控制堆上限-XX:InitialRAMPercentage50.0 -XX:MaxRAMPercentage75.0容器配额为2GB时MaxRAMPercentage设为75%能让JVM把最大堆控制在1.5GB左右剩余内存留给Metaspace、线程栈和堆外缓冲区。特别注意InitialRAMPercentage要和MaxRAMPercentage搭配使用否则启动时堆很小、后续不断扩张又回到了动态扩缩容的老路上。5. 代码层面的内存减负把垃圾扼杀在源头5.1 一个容易走偏的认知调参数只能缓解症状不能根治病因。GC回收的是垃圾它不负责“减少垃圾”。如果业务线程每秒产生几GB的临时对象就算换最新款的垃圾回收器也扛不住。这就好比房间里天天倒进来几大袋垃圾与其花重金换保洁团队不如先把垃圾源头管住。排查顺序永远是先代码后参数先确认没有内存泄漏、没有无意义的对象分配再去调JVM参数才有意义。5.2 四个立竿见影的编码优化这个项目的代码层面至少有四个明显可以优化的点。第一个是循环内的字符串拼接。很多人知道StringBuilder但没意识到真正坑人的是在循环体里用拼接。单行表达式a b c在编译期就可能被优化为常量但循环体内每一轮迭代都会创建StringBuilder和新的String积少成多就是海量临时对象。// 这种写法在循环里会产生大量临时对象 String result ; for (LogMessage msg : messages) { result msg.getContent(); } // 改为复用同一个StringBuilder StringBuilder sb new StringBuilder(1024); for (LogMessage msg : messages) { sb.append(msg.getContent()); }第二个是正则表达式的预编译。String.matches方法和Pattern.matches静态调用每次执行时都会重新编译表达式相当于把正则引擎重新启动一遍。把Pattern对象提取为静态常量并复用既能提速又能减少一批对象分配。像text.replaceAll(\\s, )这种写法内部同样会触发Pattern.compile热点路径上必须手工改成复用。第三个是避免频繁的自动拆装箱。如果Map的value用Integer、Long这类包装类型频繁put/get时会有大量自动装箱动作。Integer存在一个-128到127的缓存一旦数值超出这个范围就要创建新的包装对象数据量上来之后非常明显。能用基本类型的地方就用基本类型能避免Integer转为int就尽量避免。第四个是减少临时大数组的分配。比如用split切分一个巨大的报文瞬间产生大数组和大量子串对象。对于这个网关项目热点路径上的报文切分可以直接改成按索引扫描性能提升是数量级的只是代码可读性会下降所以只建议在确实被压测验证过的热点方法里做。对象池也不是银弹。复用StringBuilder、ByteBuffer这类重量级对象是合理的但普通小对象池化反而会增加GC压力和并发竞争因为池本身也是对象也要面临内存回收的问题。判断标准很简单只有创建成本明显高于复用成本的对象才值得池化。这套优化在这个项目里执行完仅仅是修复了正则预编译和循环拼接两个点压测里YGC频率就下降了接近40%。这比任何花哨的JVM参数都来得有效。6. 常见问题与排查技巧实录6.1 典型问题速查表最后把这些年遇到的GC问题整理成一张速查表方便大家直接对照排查问题现象定位方向常见根因处理手段并发一高延迟就开始抖看FGC频率和STW时间老年代被打满、晋升过快调整新生代/老年代比例查大对象来源堆明明没用满却频繁Full GC看GC日志的Cause字段代码里显式调用System.gc()排查显式GC调用确认NIO缓冲释放逻辑设置了Xmx4g还是OOM用NMT分析Native内存元空间、DirectByteBuffer、线程栈用-XX:NativeMemoryTracking确认内存去向容器频繁杀进程对比容器内存限制JVM读取到宿主机内存配置MaxRAMPercentage等容器感知参数老年代显示promotion failed查晋升对象大小大对象直接进入老年代疏散失败检查超大数组和字符串分配路径其中有几个值得一提的细节。System.gc()在很多人的代码里是不留神出现的比如一些NIO框架的DirectByteBuffer释放逻辑会触发显式GC这时可以确认是否开启了-XX:ExplicitGCInvokesConcurrent让显式GC走并发路径而不是一次全停顿。还有promotion failed发生在老年代空间看似足够、但无法容纳晋升对象时通常是Survivor区过小或晋升对象过于碎片化导致。6.2 三个值得长期保留的排查习惯第一生产环境必须开GC日志。这个项目最开始就是没开日志线上出问题时只能靠重启后来回猜。JDK 8开PrintGCDetails和PrintGCDateStampsJDK 11用-Xlog:gc*日志文件记得配置轮转避免写满磁盘。GC日志是所有内存问题的最原始证据没有它排查效率至少低一半。第二监控指标优先盯GC停顿时间而不是只看CPU和内存。CPU高不代表GC有问题但GC停顿高基本可以确定有异常。压测时把FGC次数、FGCT总耗时、最大暂停时间三个指标纳入监控大盘异常时能第一时间发现。第三参数变更要有记录和对照组。线程池大小、堆大小、GC算法一起改是最常见的翻车原因。每次只动一个点用同样的压测场景跑同样的时长对比GC日志和监控曲线。线上发现问题时能直接回溯到“上次变更了什么”特别重要。这次排查到最后我并没有把堆调到很大而是先在代码层把字符串和正则的坑做了瘦身堆只是切换到G1并固定了尺寸问题就解掉了。后来我还建议他们在每次发布前跑同一个压测基线把GC停顿和FGC次数写进CI卡点以前是出了问题再救火现在是性能问题在合并前就被拦下来。整个过程复盘下来最深的一个体会是GC性能陷阱的根子大半不在GC本身而在代码制造垃圾的方式。先把垃圾产生速度控住再谈回收器选型和参数调优顺序反了就是在错误的路上越跑越远。