1. 那次“GC几分钟一次”的诡异现象
1.1 告警先来,第一反应是堆太小
事情发生得很突然。某天下午,监控群里突然跳出告警,一个核心服务的Full GC频率从原来的每半小时几次,直接飙升到每两分钟一次,单次暂停时间接近两秒。对于低延迟业务来说,两秒的停顿意味着大量请求超时,线上很快就出现了明显的毛刺。我当时的第一反应很直接:堆内存肯定不够了,随手就想把-Xmx调大。
这个判断也不是凭空来的。服务运行了几个月,内存水位一直缓慢上涨,没有做过调整,加上最近刚接了几个数据量更大的接口,堆被撑爆是看起来最合理的解释。于是我们快速修改配置,把堆上限从4GB调到6GB,灰度发布之后观察了十几分钟,Full GC频率确实降了一些,但只维持了不到半天,告警又来了,而且单次GC耗时比之前更长了。重启之后又能撑一阵,但隔一段时间又会周期性恶化。
1.2 调大堆后问题为什么反而变严重了
后来复盘时发现,这个反复重启反复恶化、调大堆之后反而更慢的现象,本身就是线索。如果只是堆容量不足,调大之后应该能明显缓解,而且GC耗时不会显著变长。但我们调大堆之后,Full GC的单次暂停时间从两秒涨到了三秒多,这背后只有一个解释:堆里堆积了大量本该被回收却回收不掉的存活对象,每次Full GC要扫描和移动的对象更多了,自然更慢。
这就是内存管理与GC优化里最常见的误区——以为GC调优就是调整堆参数,但其实堆参数只是外围兜底,真正要解决的是为什么那么多对象一直存活。带着这个问题,我花了两天时间把整个排查链路重新走了一遍,从JVM内存模型、GC机制、堆Dump分析到代码层根治,整个过程踩了不少坑,也验证了不少教科书上写过但平时没当回事的细节。这篇文章就把整个思路完整拆一遍,希望能给遇到类似问题,特别是老年代持续增长、Full GC越来越频繁的开发者一些参考。
2. 先把JVM的内存地图补齐
2.1 堆内分代与对象生命周期
内存管理与GC优化这件事,第一步不是看参数,而是先搞清楚JVM到底把内存分成哪些区域,每个区域里住着什么对象。类比一下,堆内存就像一栋办公楼,新生代是“短期租客区”,老年代是“长期住户区”,而GC就是物业在定期清理那些租约到期、无人续租的房间。
具体来说,新生代内部又分成Eden区和两块Survivor区(From和To),绝大多数新对象先在Eden区出生。经过一轮Minor GC后,仍然被引用的对象会搬到Survivor区,并且年龄加一。每当Survivor区放不下或者对象年龄达到阈值(默认15,但可以通过-XX:MaxTenuringThreshold调整),这些对象就会晋升到老年代。老年代里的对象存活时间普遍更长,所以触发GC的频率比新生代低得多,但一旦触发Full GC,涉及的存活对象更多,暂停时间也更长。
这里有个容易忽略的细节:不是所有对象都一定走完“Eden -> Survivor -> Old”这条完整路径。大对象可以直接进入老年代,阈值由-XX:PretenureSizeThreshold控制,目的是避免大对象在新生代反复复制浪费性能。还有栈上分配和TLAB分配,JVM会在满足条件时把一些短命对象直接分配在栈上或线程本地缓冲区里,连堆都不会进。理解这些分配路径,是后面判断“对象为什么会跑到老年代”的前提。
2.2 元空间、堆外内存这些容易漏的地方
堆只是JVM内存版图的一半。另一半包括元空间(Metaspace)、JIT编译产物、线程栈、直接内存(Direct Memory)等。很多线上的奇葩问题,根因根本不在堆里,而在这些“堆外”区域。
元空间存的是类元数据,比如类的结构、方法信息、常量池等,默认情况下它受系统物理内存限制,不再有固定上限,但可以通过-XX:MaxMetaspaceSize限制。如果应用动态生成类比较猛(比如频繁用CGLIB、ASM生成代理类,或者大量使用Groovy之类的动态语言),元空间会出现OOM,表现是堆曲线完全正常,但系统内存不断上涨。
直接内存也特别容易踩坑。像ByteBuffer.allocateDirect()分配的内存就不在堆上,不受-Xmx管控,但受-XX:MaxDirectMemorySize限制,默认值和堆上限一样大。直接内存的回收依赖Cleaner机制,最终要在GC时处理,所以某些场景下,Full GC频率升高反而可能是因为堆外对象过多。
| 内存区域 | 主要存储内容 | 常见溢出类型 | 主要排查手段 |
|---|---|---|---|
| 堆(Heap) | 普通Java对象 | OOM: Java heap space | jmap堆Dump + MAT |
| 元空间(Metaspace) | 类元数据 | OOM: Metaspace | 监控类加载数量 |
| 直接内存(Direct Memory) | DirectByteBuffer | 系统内存持续上涨 | 监控堆外内存 + Native Memory Tracking |
| 线程栈(Thread Stack) | 栈帧、局部变量 | 栈溢出StackOverflow | jstack分析线程状态 |
| JIT产物(Code Cache) | 编译后的机器码 | CodeCache满导致性能下降 | 监控JIT编译指标 |
我当时犯的第二个错误,就是只盯着堆的监控曲线,完全没把元空间和堆外内存纳入排查视野。后来用Native Memory Tracking快照看了下,才发现堆外内存也有不小的增长量,虽然这次的问题主力在堆里,但排查过程中确实走了不少弯路。
3. GC在干什么:从GC机制倒推优化对象分配
3.1 分代假设和对象晋升路径
GC优化的本质,不是把参数调得更好看,而是让JVM的”假设“尽可能成立。JVM的分代收集依赖一个统计学规律:绝大多数对象朝生夕死,活过几轮GC的对象才更可能长期存活。整套内存布局和GC算法都是围绕这个假设设计的。
GC的起点是GC Roots,所有存活对象都是从根集合开始可达的对象。根集合包括:当前栈帧里的局部变量和参数、静态字段、JNI引用、被synchronized持有的对象等。垃圾回收器从这些根出发做可达性分析,摸不到的对象就是垃圾。这个阶段最耗时,尤其是老年代对象很多的时候。
新生代回收通常用标记-复制算法,速度很快,因为有大量对象待回收,复制存活对象的成本低。老年代回收如果还用复制就不合适了,因为存活率高,所以CMS用标记-清除,G1和ZGC用的是标记-整理或更复杂的Region化处理。这套机制设计得再精巧,也扛不住业务代码在应用层源源不断地往老年代输送“长期对象”。
3.2 收集器之间的取舍
不同收集器的选择,本质上是在“停顿时间短”和“吞吐量高”之间做权衡。Serializer和Parallel适合单机后台任务类的场景,吞吐量高但对暂停敏感就差一些;CMS是老一代低延迟的代表,可惜已经废弃;G1是JDK 9之后的默认选择,通过把堆拆成多个Region,尽量控制停顿时间;ZGC和Shenandoah主打超大堆和极低停顿,但CPU开销更高,一般堆很大、节奏很稳的服务才考虑。
做GC选型不要盲目追新。小堆、低并发场景用G1可能反而产生更多CPU开销,而超低延迟场景用ZGC虽然单次GC暂停短,但总体吞吐可能出现小幅下降。我在实际项目里见过有人把4GB堆直接切到ZGC,结果GC暂停时间没怎么变,CPU先涨了三成,属于典型的拍脑袋决策。
3.3 GC日志是用来看症状的“心电图”
排查GC问题,第一步永远是看日志,而不是猜参数。JDK 8用-verbose:gc配合-XX:+PrintGCDetails,JDK 9之后统一走统一日志体系,用-Xlog:gc*。正确的日志参数是GC优化所有后续分析的基石,建议线上服务一定要提前打开,并输出到独立文件,按天分割,避免日志文件过大在故障时反而难以处理。
拿到日志后,关注的核心指标有这几个:
- Young GC频率、单次耗时和对象晋升平均大小
- Full GC触发原因(是
Allocation Failure还是Metadata GC Threshold) - 每个阶段前后的堆占用变化
举个真实例子,日志里显示每30秒一次Young GC,每次Young GC后有大约200MB对象晋升老年代,但老年代占用曲线整体还是缓慢下降的。这个规律意味着应用层在持续产生大量短期对象,而且其中一部分在新生代里活得太久,直接被晋升到了老年代。看到这个规律时,思路就已经从“堆参数”转向了“对象分配”。
4. 一次完整的线上内存排查链路
4.1 从堆曲线和监控指标圈定方向
内存排查不能一上来就Dump堆。第一步是看监控曲线,把时间线对齐,判断问题是渐进型还是突发型。
渐进型特征:老年代占用曲线单调上升,GC频率逐步变快。这通常是泄漏或对象长期堆积的典型特征。突发型特征:老年代占用在某个时间点突然抬高,之后维持在高位。这通常是大任务、大查询或者并发尖峰导致的对象激增。这次碰到的问题,老年代占用曲线呈现周期性阶梯上升:每隔几个小时上升一块,然后短暂持平,接着继续升,Full GC频率也随着阶梯不断恶化。
这一步几乎不用工具,只看监控平台整理好的指标就能完成。建议重点盯这几个指标:老年代占用率、Eden区分配速率、Young GC频率、Full GC频率和每次Full GC后的堆占用。其中“Full GC后堆占用”最容易被忽略,却是判断泄漏的关键——如果每次Full GC后老年代占用都回不到低位,说明垃圾没有真正释放。
4.2 jmap和堆Dump的正确打开方式
接下来用工具做定点勘查。jmap的-histo命令可以看到当前堆里的对象类型统计,注意它展示的是所有实例数量和占空间总和,不是快照。执行jmap -dump:live,format=b,file=heap.bin会先触发一次Full GC再导出存活对象,导出的Dump更适合找泄漏;而-dump:format=b不带live直接导,能看到更多被GC延误回收的区域,但文件更大。
线上执行Dump要小心,-dump:live会加重GC负担,频率别太密,最好排在业务低峰期。稳妥的做法是配置-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath,让OOM发生时自动留档,同时再用jmap在手动时机抓一次。我一般会在Full GC频率最高、老年代最满的时候抓Dump,因为这时候最能还原问题现场;等到服务重启之后再抓就没意义了。
4.3 MAT分析里的几个重点视图
拿到Dump之后,我用的是Eclipse MAT。先看Overview页签给出的Leak Suspects,它会自动把堆里可疑的“支配者链条”圈出来,相当于一个侦探助理先把嫌疑人名单递上来。接着重点看Dominator Tree,也就是支配树,能够看到每个对象以它为根整体占了多少堆空间。如果发现某个类实例占用了两三个GB,它的持有链就是排查重点。
另一条怎么看比较容易绕路的地方在这里:MAT的Dominator Tree默认按保留大小排序,但保留大小小、实例数多的对象也可能制造大量GC压力。这类对象要回到jmap的histo输出里对比,看哪个类的实例数暴增。举个例子,如果某个业务DTO实例数超过了历史常态的十倍,即使单对象很小,累计起来也是压垮GC的最后一根稻草。
5. 根因定位:泄漏和分配压力的区别
5.1 典型泄漏的特征与常见源头
泄漏类问题在监控上的特征是“单调上升”。典型场景有几个:一是静态集合被不断塞入数据却从不清理;二是ThreadLocal使用不当,线程池里的线程存活时间很长,ThreadLocalMap里的Entry key是弱引用,但value是强引用,只要线程不销毁,value就一直被撑着;三是资源对象忘记关闭,或者自定义的观察者容器不断增加观察者而忘记移除。
判断泄漏还有个很实用的旁证:重启之后故障能坚持时间显著变长,因为堆被清空了,泄漏需要重新积累。如果重启后故障立刻复发,大概率不是泄漏,而是代码每次都固定触发大需求。这一点也对应了我们最开始看到的现象——重启后好转,隔半天复发,说明确实有对象在缓慢堆积。
5.2 高分配压力的典型特征与常见源头
分配压力大和泄漏的情况正好相反,堆曲线往往呈锯齿状,Young GC极频繁,每次GC后都能回收大量短命对象。问题源头常见于这几类:循环体内反复创建大对象、JSON序列化和反序列化在热点路径上反复造对象、批量处理时一次性加载了超大结果集、懒加载失效导致每次请求都重复初始化重量级组件。
区分这两种问题的核心指标,就是看Full GC后老年代到达了什么水位。如果Full GC之后老年代水位仍然很高,说明有对象一直被引用,是泄漏;如果Full GC后水位能回到低位,但很快就再次堆高,则更像是分配过快或晋升异常。这一步千万别省,因为用错方向不仅找不到问题,还会误导你去优化根本不存在的泄漏点。
5.3 这次问题的确认过程
排查到这一步,根因基本浮出水面。堆Dump里看到老年代里有一个超大HashMap,被某个定时任务线程长引用。这个Map承载的是某个批量查询接口的结果缓存,业务逻辑是每小时跑一次数据刷新,但刷新的条件判断写错了,每次刷新时都先把数据放入Map,再检查是否过期,结果永远进不去清理分支。数据量每小时增加一点,自然形成了阶梯式上升。
同时,Eden区的分配速度并不高,大量对象在新生代就已被回收,问题焦点确实在“老年代驻留对象持续累积”,而不是整体分配压力。这说明单纯调GC参数解决不了问题,只有修复了那个过期逻辑,让Map里的旧数据能被清出,老年代占用才能回落。
6. 优化落地:先治理对象,再调整参数
6.1 代码层面的对象治理清单
内存管理与GC优化,真正能产生长期效果的一定在代码层。我一般会按以下清单过一遍业务代码:
- 批量查询改为分批或流式处理,避免一次拉取海量结果撑破内存
- 热点循环里拉取创建新对象,改为复用池或者提前构造好容器
- 大字符串、大数组处理时,增加分片逻辑,避免大对象直接进入老年代
- 静态容器增加容量上限和过期清理机制,防止无界增长
- ThreadLocal用完后必须走remove清理,尤其在线程池场景
- 避免在循环体内反复调用序列化和反序列化,能直取的字段不要再造临时对象
在这次的具体修复中,我只需要把定时任务的清理逻辑提前到数据刷新之前,然后给容器加上元素数量上限,超过阈值时先淘汰最旧的数据。改动很小,但对GC曲线的影响立竿见影。
6.2 参数调优的正确姿势
代码层修复之后,参数调优才能发挥作用。调参的正确顺序是:先固定堆大小(-Xms和-Xmx设为一致,避免运行时动态扩容产生的额外开销);再确定新生代大小;最后再调收集器相关参数。
G1的常见参数里,-XX:MaxGCPauseMillis是目标停顿时间,默认200ms,不要把它改得过小,否则G1会牺牲吞吐换取停顿,可能导致Full GC反而变频繁。-XX:G1HeapRegionSize一般不推荐手动改,让JVM根据堆大小自动选即可。对于堆特别大的服务,考虑用-XX:+UseZGC,但务必先做压测对比再上生产。
参数调整的过程有个原则:每次只改一个变量,保留改动前后对比的GC日志和监控截图,不要一次把十个参数全部改掉,出问题了都不知道是哪个参数引起的。
6.3 验证手段与回滚预案
代码和参数都调整之后,验证步骤同样重要。先在压测环境构建完整的模拟流量,对比调整前后的指标:Young GC频率、Full GC频率、Full GC单次耗时、老年代水位变化、业务TP99延迟。通常在压测环境观察一小时,确认各项指标稳定后再灰度到生产。
生产环境建议灰度10%流量跑一天,重点看老年代曲线是否从阶梯上升变成平缓。这个观测时段要跨过一次定时任务周期,确保旧数据的清理逻辑真实生效。我这次还额外开了GC日志的归档,防止日志文件撑爆磁盘,另外保留了参数热更新的回滚入口,万一上线后出现意外能快速回到修复前的版本。
最终上线后,Full GC频率从每两分钟一次降到每天个位数,TP99延迟恢复到正常水位,老年代占用稳定在了健康区间。
最后再说一点我自己的体会
排查这次问题给我最大的感受是:内存管理与GC优化不是调参游戏,而是一个从监控指标到GC日志、再到堆Dump和代码逻辑的完整链路。很多人一听到GC频繁就想去调参数,结果往往是掩耳盗铃,参数只能掩盖症状,不能消灭对象。
我现在遇到GC问题会先问自己三个问题:这些对象是谁创建的?为什么它们一直活着?能不能让它们早点死?把这三件事想清楚,再回头去看参数,会发现大部分时候根本不需要对收集器动大手术。GC的存在是为了清理我们不注意产生的垃圾,而不是让业务在垃圾堆里代代相传。每次调优前保留完整的基线数据,每次改动只动一个变量,这比任何高级参数都有用。