Java 服务在线上跑着跑着,突然响应变慢、CPU 飙升、监控上出现规律的锯齿或长停顿,上去一看 GC 日志,Full GC 隔几分钟就来一次,每次停顿几秒——这是 Java 运维最典型的性能问题之一。Full GC 本身不是 bug,它只是清理整个堆;真正的问题是它发生得太频繁、停顿太长,超出了业务能接受的延迟。而它背后往往藏着更深的信号:要么堆给小了,要么有对象一直被引用着回收不掉(内存泄漏),要么参数配得压根不对。
这篇文章要解决的是排障思路:拿到一台 Full GC 频繁的机器,怎么通过 GC 日志看清"到底发生了什么",怎么区分"正常的老年代回收"和"对象泄漏导致的反复回收",然后针对性地调堆大小、换垃圾回收器、或者去找代码里的泄漏点。读完你应该能读懂 GC 日志里最关键的几行,并知道什么样的参数对自己业务是对的。
文中涉及的堆大小、停顿时间是经验量级,具体取决于业务对象分配速率、机器内存和 GC 选型。JDK 8 和 JDK 11 及以上在默认回收器和日志参数上有明显差异,下面会分开说明。
一、Full GC 为什么会频繁发生
先分清两种 GC:
- Minor GC / Young GC:只回收新生代(Eden + Survivor),发生频繁但停顿短,通常几毫秒到几十毫秒。
- Full GC:回收整个堆(新生代 + 老年代),停顿长,从几百毫秒到几秒甚至更长。频繁 Full GC 会让应用"卡顿感"特别明显。
Full GC 触发的原因主要有几类:
- 老年代空间不足:对象生命周期长,不断晋升到老年代,把老年代填满。
- 元空间/永久代满:加载的类太多、动态生成类(反射、CGLIB 代理、大量 JSP)会导致 Metaspace 撑爆并触发 Full GC。
- 显式调用
System.gc():代码或某些框架(如 NIO 的直接内存回收触发)调用了它。 - RMI/Timer 等定时触发:JDK 的某些机制会周期性建议 GC。
-XX:+UseSerialGC等单线程回收器在大堆上效率低,间接加剧停顿。
其中最需要警惕的是第 1 类中由内存泄漏导致的情况:正常业务的对象用完就该被回收,但如果有集合、缓存、监听器、ThreadLocal 之类一直持有引用,对象永远进不了回收队列,老年代被慢慢填满,Full GC 就只能"力气花完也收不回来",表现为"每次 Full GC 后老年代占用几乎不降",这就是泄漏的铁证。
二、先把 GC 日志打开
没有日志,一切靠猜。JDK 8 和 JDK 11+ 的开启方式不同。
JDK 8:
java -Xms2g -Xmx2g \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintHeapAtGC \
-Xloggc:/data/logs/gc-%t.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=20M \
-jar app.jar
JDK 11 及以上(统一日志框架):
java -Xms2g -Xmx2g \
-Xlog:gc*,gc+heap=info,gc+age=trace:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=20M \
-jar app.jar
如果服务已经在跑、不方便重启,可以用 jstat 在线观察,虽然不如日志详细,但能立刻看到趋势。
# 找到 Java 进程 PID
jps -l
# 每 1 秒打印一次,共 20 次:新生代、老年代使用及 Full GC 次数
jstat -gcutil 1000 20
# 或看每个区的实际容量
jstat -gc 1000 20
jstat -gcutil 输出里关注几列:O(老年代使用率)、FGC(Full GC 次数)、FGCT(Full GC 总耗时)。如果 O 一次比一次高、FGC 快速上涨、而 FGCT 不断累加,就是频繁 Full GC 的典型曲线。
三、读懂 GC 日志里的关键信息
一条典型的 JDK 8 Full GC 日志长这样:
2026-10-12T10:20:33.123+0800: 3600.123: [Full GC (Ergonomics) [PSYoungGen:
1310720K->0K(1835008K)] [ParOldGen: 1740800K->1699875K(1835008K)]
3621234K->1860021K(4456448K), [Metaspace: 98532K->98532K(1097728K)],
0.9876543 secs] [Times: user=7.82 sys=0.03, real=0.99 secs]
逐段看:
3600.123:JVM 启动后经过的秒数,用它算 Full GC 的频率。Full GC (Ergonomics):触发类型。Ergonomics表示是自适应判断触发的,System.gc()会显示成别的字样。ParOldGen: 1740800K->1740800K之类:老年代回收前后占用。这一对数字最关键——如果回收后占用几乎没降(如 1699875K 回收后还接近满),说明老年代里全是活对象,要么是正常的"堆不够大",要么是泄漏。Metaspace: 98532K->98532K:元空间回收后没变,若持续上涨要警惕类加载泄漏。0.9876543 secs/real=0.99 secs:本次停顿时长。超过 1 秒对在线服务就是明显抖动。user=与real=的关系:多核并行回收时 user 可能远大于 real,说明 GC 用上了多线程。
读日志时,最该算两个数:Full GC 的频率(多久一次)和每次回收后老年代的残留(降不降得下来)。前者决定"影响有多大",后者决定"是容量问题还是泄漏问题"。
四、判断:容量不足还是内存泄漏
这是整篇文章最重要的一步,方向判断错了,后面全白费。
容量不足(正常的堆太小):特征是 Full GC 后老年代占用明显下降(比如从 1.7GB 降到 0.4GB),只是过一会儿又被填满。这说明对象是能被回收的,只是产生速度太快、堆扛不住。解决方向是加大堆、优化对象分配。
内存泄漏:特征是 Full GC 后老年代占用几乎不变(如 1.7GB 降到 1.69GB),而且随着时间推移基线不断抬高,最终 Full GC 越来越频繁却收效甚微,直到 OOM。解决方向是找到泄漏点,而不是继续加堆——堆加十倍也照样填满。
区分工具:
# 导出堆快照(会带来短暂 STW,建议低峰期操作)
jmap -dump:live,format=b,file=/data/heap.hprof
# 查看瞬时堆使用
jmap -heap
# 查看类加载和存活对象直方图
jmap -histo:live | head -30
jmap -histo:live 会列出存活对象按大小/数量排序的前几位。如果连续采集几次,发现某个业务类的实例数一路只增不减,那基本就是泄漏对象。用 MAT(Eclipse Memory Analyzer)打开 heap.hprof,看 Dominator Tree 和 Leak Suspects 报告,能定位到具体是哪个集合、哪个线程在持有引用。
风险提示:jmap -dump 和 jmap -histo:live 都会触发一次 Full GC(live 选项),在大堆(十几 GB)上可能造成数秒甚至更长的停顿,务必在业务低峰执行,并提前通知。回滚无从谈起——这是只读操作,但"停顿"本身就是影响,要做好限流或摘流准备。
五、常见的内存泄漏姿势
即使不深入工具,先排查这几类高频泄漏点,往往能直接命中:
- 静态集合只增不减:
static Map做缓存却不设过期或容量上限,数据一年年往里堆。 - 监听器/回调未注销:注册了监听器、事件总线订阅、观察者,却从不反注册。
- ThreadLocal 未 remove:线程池里线程被复用,ThreadLocal 里的大对象一直跟着线程不释放。
- 连接/流未关闭:数据库连接、文件流、HTTP Client 未关闭,间接导致相关对象无法回收。
- 缓存无淘汰策略:自己写的缓存没有 TTL 或 LRU,等同于无界集合。
- 不合理的
finalize:对象带finalize且逻辑慢,拖慢回收。
定位到之后,修复方式通常是:给缓存加容量上限(如 Caffeine 的 maximumSize + expireAfterWrite)、在 finally 里 remove ThreadLocal、用 try-with-resources 关流。
六、参数优化:调堆、选回收器
如果确认是容量问题,或者想减少停顿,可以从参数入手。第一原则是 -Xms 和 -Xmx 设成相等,避免堆反复伸缩带来的抖动。
# 一个相对通用的在线服务起点(4C8G 机器,经验值)
java -Xms4g -Xmx4g \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=10,filesize=20M \
-jar app.jar
注意几点:
-Xmx不要等于物理内存。堆之外还有元空间、线程栈、直接内存、JVM 自身开销,通常要留出 1/4 到 1/3 给非堆。8G 机器给堆 4~5G 比较稳妥。- G1 是 JDK 9 之后的默认回收器,适合几 GB 到几十 GB 的堆,能通过
-XX:MaxGCPauseMillis设定停顿目标。它不是万能的,但对大多数在线服务比 CMS 更省心(CMS 在 JDK 9 起被弃用,JDK 14 移除)。 - 大堆(30GB 以上)且对停顿极其敏感,可以考虑 ZGC 或 Shenandoah(JDK 15+ 已转正),它们能把停顿压到十毫秒级,代价是更吃内存和 CPU。
-XX:MetaspaceSize设一个合理初值,能让元空间的扩容不那么频繁,减少早期 Full GC。
改参数后一定要用同样的方式开日志,对比调整前后的 Full GC 频率和停顿,而不是改完就不管了。
七、一套排查动作清单
| 步骤 | 命令/动作 | 判断依据 |
|---|---|---|
| 1. 确认现象 | jstat -gcutil |
FGC/FGCT 是否快速上涨 |
| 2. 开/看日志 | 查 gc.log | Full GC 频率、停顿 |
| 3. 看回收效果 | 读日志老年代 before->after |
回收后降不降 |
| 4. 疑似泄漏 | jmap -histo:live、jmap -dump |
对象数是否只增不减 |
| 5. 定位引用 | MAT 分析 hprof | 谁持有大对象 |
| 6. 若是容量 | 调 -Xmx、-Xms 相等 |
频率是否下降 |
| 7. 若是停顿 | 换 G1/ZGC、调停顿目标 | 停顿是否达标 |
常见问题
Q:Full GC 一分钟好几次,但每次才几百毫秒,要紧吗?
要看业务。对低延迟交易类服务,几百毫秒的抖动能感知到;对离线批处理,影响不大。但如果频率高到"刚回收完又满",说明回收效率极低,仍然要查是不是泄漏。
Q:加了 -Xmx 之后 Full GC 反而更频繁了,为什么?
可能是堆变大后,回收器仍按旧比例触发,或者对象晋升行为变了。也可能是机器物理内存不够,堆开大后触发了 Swap 或 OOM,反而更糟。加堆前确认物理内存和容器内存限制。
Q:容器里跑的 Java,为什么设了 -Xmx4g 还被 OOMKilled?
容器的内存限制是给整个进程的,包含堆 + 元空间 + 直接内存 + 线程栈。堆之外的开销容易被忽略。容器里建议显式设置 -XX:MaxRAMPercentage 或明确的 -Xmx,并给容器 limit 留出余量。
Q:System.gc() 会自动被调用吗?
默认情况下,-XX:+DisableExplicitGC 可以禁用显式 GC 请求。很多直接内存(NIO/Netty)的回收依赖 System.gc() 触发,禁用后要用 -XX:+ExplicitGCInvokesConcurrent 之类方式处理,否则可能引发直接内存溢出。别盲目加 DisableExplicitGC。
Q:GC 日志里 user 时间远大于 real,正常吗?
正常。并行或并发回收器会用多个 GC 线程,user 是这些线程 CPU 时间之和,real 是墙钟时间,多核机器上 user 大于 real 是预期行为。
总结
先分清 Minor GC 和 Full GC:频繁的、长停顿的 Full GC 才是问题,它的核心危害是暂停业务线程。
排查第一步是打开并读懂 GC 日志,重点算两个数:Full GC 的频率,以及每次回收后老年代占用降不降。
降得下来是容量不足,方向是合理加大堆;降不下来且基线持续抬高是内存泄漏,必须用 jmap -histo:live 和 MAT 找到持有引用的地方,加堆无用。
参数上坚持 -Xms 等于 -Xmx,堆不要占满物理内存,JDK 9+ 默认 G1 对多数在线服务够用,超低延迟场景再考虑 ZGC。
任何 dump、histo 操作都可能触发停顿,务必低峰执行;调参前后都要用同一套日志做对比,用数据验证效果,而不是改完凭感觉。
企业QQ咨询




