Java服务器频繁发生Full GC怎么办?GC日志分析与堆内存优化

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 触发的原因主要有几类:

  1. 老年代空间不足:对象生命周期长,不断晋升到老年代,把老年代填满。
  2. 元空间/永久代满:加载的类太多、动态生成类(反射、CGLIB 代理、大量 JSP)会导致 Metaspace 撑爆并触发 Full GC。
  3. 显式调用 System.gc():代码或某些框架(如 NIO 的直接内存回收触发)调用了它。
  4. RMI/Timer 等定时触发:JDK 的某些机制会周期性建议 GC。
  5. -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 1000 20 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 操作都可能触发停顿,务必低峰执行;调参前后都要用同一套日志做对比,用数据验证效果,而不是改完凭感觉。