JVM 频繁 Full GC 先看日志括号里的 cause,jstat 的 FGC 在 JDK 8 上会骗人

➡️ 程序员曜灵 · 后端面试追问链 - 欢迎认识我

作者程序员曜灵,绿泡泡「我要拿offer」和小红书同名。

主业在一家大型央企做后端开发,Java 方向,参与过公司内部招聘面试。

这里在拆高频面试题的追问链,一题三层,每层给及格线答案和大多数人挂在哪。

工作日每天一篇,评论区点最高频的问题决定下一篇写什么。

前阵子有同学说他们服务频繁 Full GC,证据是 jstat 里 FGC 那一列一直在涨。我让他把 GC 日志贴出来,数完是 0 次。

这个场景我在本机复现过。Corretto 1.8.0_502 加 -XX:+UseConcMarkSweepGC 跑 45 秒,jstat -gcutil 的 FGC 从 2 走到 12,同期日志里 CMS-concurrent-mark、CMS-concurrent-sweep 一轮接一轮,Full GC 这个字符串一次都没出现。换到 Corretto 21.0.12 的 G1,FGC=4 恰好对应日志里 4 条 Pause Full,并发周期被记进了另一列。

「频繁 Full GC 怎么排查」这道题,背清单的人在第二问就答不住,他手上没有一件能对着日志判定的东西。

cause 是一份闭合枚举,清单里没有「老年代满」

HotSpot 把 GC 原因写成了一个枚举,在 src/hotspot/share/gc/shared/gcCause.cpp,master 分支版权行是 2002, 2025。日志里 GC 行括号中那个词就是它。跟 Full GC 有关的项是这些。

复制代码
System.gc()            Diagnostic Command
JvmtiEnv ForceGarbageCollection
Heap Inspection Initiated GC       Heap Dump Initiated GC
Allocation Failure     Metadata GC Threshold      CodeCache GC Threshold
G1 Evacuation Pause    G1 Compaction Pause        G1 Humongous Allocation
G1 Periodic Collection

同一份文件头的 gcCause.hpp 还给了三个判定函数,把上面的项分成三组。is_user_requested_gc 只认 System.gc() 和 Diagnostic Command,is_serviceability_requested_gc 认 JvmtiEnv ForceGarbageCollection、Heap Inspection、Heap Dump 三项,is_codecache_requested_gc 认 CodeCache 两项。

这个分组就是判锅的地方。括号里落在第一组,该问的是谁在代码里调了 System.gc(),或者谁挂了个定时任务在跑 jcmd GC.run,GC 参数本身没病。落在第二组,该翻的是巡检脚本和诊断命令。落在 CodeCache 那组去看 CodeCache 容量。剩下那些三组都不沾的,Allocation Failure、Metadata GC Threshold、G1 的几项,才轮到看堆、元空间和 region 配得合不合适。

那份流传最广的原因清单,写的是老年代空间不足、永久代或方法区满、大对象直入老年代、Minor GC 晋升平均值超过老年代剩余、System.gc、空间担保失败。这六个词在枚举里一个都找不到。它抄自 2016 年 4 月那篇 14,471 阅读的老文,至今仍是这个词下前十名的主要成分,里面还在讲永久代,那个东西在 JDK 8 就被 Metaspace 换掉了。

大对象那一项还有更具体的错。「触发 Full GC」这件事在 G1 里对应的 cause 是 G1 Humongous Allocation,而 humongous 的判定是对象大小超过半个 region。g1CollectedHeap.cpp 第 1351 行写的是 _humongous_object_threshold_in_words = humongous_threshold_for(G1HeapRegion::GrainWords)。我拿 -XX:G1HeapRegionSize=1m 试了三档,new byte[512*1024] 立刻被计入 Humongous regions: 56->1,new byte[511*1024] 是 0->0。所以大对象是不是问题,取决于你配的 region 多大,它跟堆多大没有直接关系。

同一个事件在 JDK 8 和 JDK 21 长得不一样

这点头部文章全都跳过了,跳过的后果是,读者按文章教的字符串去 grep 新日志,什么也搜不到。

晋升失败复制不动,两台机器上的写法是这样。

复制代码
# JDK 8 + G1
[GC pause (G1 Humongous Allocation) (young) (to-space exhausted), 0.0015978 secs]
[Evacuation Failure: 0.2 ms]
[Full GC (Allocation Failure)  65092K->64784K(128M)]

# JDK 21 + G1
Pause Young (Prepare Mixed) (G1 Humongous Allocation) (Evacuation Failure) 253M->9M(256M)
Pause Full (G1 Compaction Pause) 125M->125M(128M)

to-space exhausted 这个字符串在新日志里已经不存在了,换成了 Evacuation Failure。Full GC (Allocation Failure) 换成了 Pause Full (G1 Compaction Pause)。G1 的 full GC 从串行到并行是 JEP 307,Release 10,我实测的 JDK 21 那行前面还有一句 Using 2 workers of 9 for full compaction,就是它的痕迹。

日志参数也一样。java(1) 手册里那张「Legacy GC Logging Flags to Xlog Configuration Mapping」写得很清楚,PrintGC 对应 -Xlog:gc,PrintGCDetails 对应 -Xlog:gc*,PrintTenuringDistribution 对应 -Xlog:gc+age*,PrintGCCause 那一行直接标成不适用,原因是 GC cause 现在总是被记录。我在 JDK 21 上加 -XX:+PrintGCDetails,它不报错,打一句 deprecated 警告然后自动改用 -Xlog:gc*;反过来在 JDK 8 上写 -Xlog:gc 是 Unrecognized option,进程起不来。JDK 9 和 10 上是哪一种表现我没样本,不敢写。

jstat 的 FGC 什么时候能信

jstat(1) 给 -gcutil 的列定义里,FGC 的原文是 Number of full GC events,S0、S1、E、O、M 是各空间使用占当前容量的百分比,同一页还写明这个命令是 experimental and unsupported。

我本机 JDK 8 CMS 那组数据说明这个字段在特定收集器上不能按字面读。FGC 涨 10 次,日志里一次 STW Full GC 都没有。它的底层是哪个计数器在递增,我没在源码里定位到,只能给到实测这一层。JDK 21 + G1 那组,FGC 和 Pause Full 是一一对应的,并发周期被分到 CGC 列,那一列在 Oracle 的 jstat(1) 文档里根本没写。

想直接看到原因,用 -gccause,它多给 LGCC 和 GCC 两列,分别是上一次和当前这次 GC 的原因。这是我建议的第一个动作,比翻十篇原因清单快。

复制代码
S0   S1    E     O     M   CCS  YGC  YGCT  FGC  FGCT  CGC  CGCT  GCT    LGCC                 GCC
-   0.20  0.00  1.12  40.84 5.49  61  0.272  4   0.007 110  0.038 0.317  G1 Periodic Collection  No GC

判「频繁」我不会只看次数。三件东西一起看,括号里的 cause、这次回收前后的堆大小、有没有回收不动。G1 的周期采集 G1 Periodic Collection 也在 cause 里出现,它落的是并发周期,别把它算进 Full GC。

回收不动长这样,是同一次实测里连打七次的行。

复制代码
Pause Full (G1 Compaction Pause) 125M->125M(128M) 1.179ms

活着的东西放不下,调什么 GC 参数都没用,该看的是谁在持有。另一种情形是 Metadata GC Threshold,回收前后堆占用没怎么变,锅在类加载器。我在 JDK 21 上用 -XX:MaxMetaspaceSize=40m,每起一个 URLClassLoader 只装一个类,就把它逼出来了。紧跟着还会再来一条 Metadata GC Clear Soft References。枚举里带 Clear Soft References 的那两项被官方标注会清软引用,别的 cause 不清。

巡检脚本自己就是频繁 Full GC 的成因

jcmd(1) 对这几个命令的定性写得很直白。GC.run 那一行原文是 Calls java.lang.System.gc(),Impact 标到 Medium。GC.heap_dump 的 Impact 标到 High,后面还有一句 Request a full GC unless the -all option is specified。

我把它们和 cause 对上了一遍。jcmd <pid> GC.run 在日志里留的是 Pause Full (Diagnostic Command),jcmd <pid> GC.heap_dump f.hprof 留的是 Pause Full (Heap Dump Initiated GC),jmap -histo:live <pid> 留的是 Pause Full (Heap Inspection Initiated GC)。这三条都在 JDK 21 + G1 上跑通过,原文照抄。

每周对线上进程跑一次 jmap -histo:live 又回头数 FGC 的,涨的数就是自己的动作。我在面试里听候选人描述过他们的监控面板上 Full GC 次数很稳定,稳定到和执行脚本的 cron 时间一致,这个推断是我根据日志给的,不是我看过的现场。

DisableExplicitGC 别单独上

-XX:+DisableExplicitGC 默认是 false,gc_globals.hpp 里的描述就一句 "Ignore calls to System.gc()"。手册补了一句要紧的,禁了之后 JVM 该 GC 的时候照样 GC。所以「禁掉显式 GC 就清净了」这半句是对的。

后半句在堆外。java/nio/Bits.java 的 reserveMemory() 从第 116 行到 215 行,预留直接内存失败时,第 153 行会调 System.gc(),并且拿一个 GC epoch 当令牌避免重复触发,接下来进指数退避,依次 sleep 1、2、4 一直到 256 毫秒,注释里写 total 511 ms,还是腾不出来才抛 OOME。清理动作在 jdk.internal.ref.CleanerImpl 的 tryCleaning() 里。

MaxDirectMemorySize 不设的时候,默认上限取的是 Runtime.getRuntime().maxMemory(),逻辑在 VM.saveAndRemoveProperties()。我拿 -Xmx64m 试 ByteBuffer.allocateDirect(128MB),打出来的文本是下面这行,limit 正好等于堆上限。

复制代码
java.lang.OutOfMemoryError: Cannot reserve 134217728 bytes of direct
buffer memory (allocated: 0, limit: 67108864)

Netty、Kafka client、ES client、文件通道这些堆外用得多的东西碰上这个组合,禁了显式 GC 就只能等自然回收。要压 System.gc() 的停顿,官方给的替代是 -XX:+ExplicitGCInvokesConcurrent,JDK 21 手册原话是它 "disabled by default and can be enabled only with the -XX:+UseG1GC option"。

这条判死了一批老建议。「配 CMS 用」这个说法在 JDK 14 之后没有对象了,CMS 是 JEP 363 移除的,Resolved Release 写的就是 14。我在 JDK 8 CMS 上实测过这个开关,它确实只走并发周期,不出 Full GC 行。

容器里这些讨论的前提换了

前十几篇里没有一篇谈容器,但它们推荐的堆参数在 K8s 下的行为由另两个东西决定。UseContainerSupport 在 JDK 26 那份 java(1) 里写得完整,原话包含 Linux only,默认值 true,想看细节就开 -Xlog:os+container=trace;JDK 21 那份只在 ActiveProcessorCount 里反向引用了它,没展开。MaxRAMPercentage 官方默认 25 个百分点,我本机 8u502 和 21.0.12 的 PrintFlagsFinal 都打出 25.000000。

这两个选项在 macOS 上都验不了,Corretto 8u502 和 21.0.12 都报 Unrecognized VM option。所以这段只能进 Linux 容器跑。至于「8u191 起默认开启容器感知」这个流传的时间点,我没拿到一手出处,不写进结论。

参数、默认值、起始版本

参数或配置项 默认值 起始或失效版本 出处
DisableExplicitGC false JDK 8 与 21 实测均为 false gc_globals.hpp,java(1)
ExplicitGCInvokesConcurrent false JDK 21 文档限定只能配 G1GC java(1) JDK 21,gc_globals.hpp 第 156 行
MaxDirectMemorySize 未设时取 -Xmx 逻辑在 VM.saveAndRemoveProperties() 源码 + -Xmx64m 实测 OOME 文本
MaxGCPauseMillis 200 毫秒 G1 HotSpot GC Tuning Guide 的 G1 章节
InitiatingHeapOccupancyPercent 45 G1 gc_globals.hpp + 两版 PrintFlagsFinal
G1ReservePercent 10 G1 同上
MaxTenuringThreshold 15 两版实测一致 g1_globals.hpp + 实测
G1PeriodicGCInterval 0(关) 开启后 cause 为 G1 Periodic Collection,属并发周期 g1_globals.hpp + 实测
G1HeapRegionSize 按堆推算约 2048 个 region,取值 1MB 到 32MB humongous 阈值是半个 region java(1),g1CollectedHeap.cpp 第 1351 行
UseContainerSupport true,Linux only JDK 21 手册未展开,JDK 26 手册完整 java(1)
MaxRAMPercentage 25 8u502 与 21.0.12 实测一致 java(1) + 实测
CMS JDK 14 起已不存在 JEP 363,Release 14 openjdk.org/jeps/363
G1 的 full GC 并行化 并行,worker 数按堆算 JEP 307,Release 10 openjdk.org/jeps/307
-Xlog 统一日志 JDK 9 起取代 PrintGC 系列 JEP 158,Release 9 openjdk.org/jeps/158

堆转储那份报告给的是嫌疑

MAT 的 Leak Suspects 走的是支配树,官方那句判定标准里写着 default is 10%,同一页还有一句限定,它只用堆转储数据,而堆转储是某一瞬的快照,不含任何时间信息。所以它的位置在日志之后,不在日志之前。

这道题我会怎么答

及格线那一层,背出六个原因,说老年代满了就 Full GC。这层守不住第二问,因为你说的六个名字在 HotSpot 的枚举里找不到。

面试官真正想听的那层,是先定位 cause 再定锅。括号里的词落在用户显式或诊断那一组,去查代码和脚本;落在堆和元空间那一组,才轮到看回收前后的堆大小、确认是不是放不下;顺手说一句 JDK 8 和 11 之后的日志写法不一样,-XX:+PrintGCDetails 早就不作数了。

大多数人挂的地方在中间那层。他们会说把 -Xmx 调大、新生代调大、换 G1。这三句在没看 cause 之前没有一句能验证。我自己排查这类问题时的顺序是固定的,先 -Xlog:gc* 开日志,再 jstat -gccause 看 last GC cause,然后按三组分。这条顺序是我在这几台机器上跑出来的习惯。

一处说明,这篇里的数字和日志行都来自本机 macOS arm64 上的 Corretto 21.0.12+8 和 1.8.0_502,容器那段例外,它只能在 Linux 里验。两处没核到的我在正文里标了出来,JDK 9 和 10 上老日志参数是什么表现,还有 8u191 那个容器感知的时间点。

出处

GC cause 的枚举项与三个分组函数取 OpenJDK master 分支的 gcCause.cpp 和 gcCause.hpp,humongous 阈值在 g1CollectedHeap.cpp 第 1351 行,直接内存那段在 java/nio/Bits.java 第 116 到 215 行,默认上限取 maxMemory 在 jdk/internal/misc/VM.java。DisableExplicitGC、ExplicitGCInvokesConcurrent、legacy 日志参数映射表、UseContainerSupport、MaxRAMPercentage、G1HeapRegionSize 取 Oracle 的 java(1) 手册,JDK 21 和 JDK 26 两份都翻过,UseContainerSupport 的完整表述只在 26 那份里。jstat 的列定义取 jstat(1),GC.run 与 GC.heap_dump 的 Impact 取 jcmd(1),System.gc() 的 best effort 表述取 JDK 21 的 API 文档。CMS 移除、G1 的 full GC 并行化、统一日志依次是 JEP 363、307、158,Release 字段取 openjdk.org 的 JEP 页面。MAT 那个 10% 与「不含时间信息」取 Eclipse MAT 帮助文档的 Leak Suspects 页。头部文章的阅读数与发布日取 CSDN 站内搜索接口。日志行、jstat 输出、OOME 文本都是本机实测。

最后

如果觉得这篇有用,可以点赞+收藏+关注 支持一下,程序员曜灵的主页还有很多按追问链拆的面试题,欢迎前去点评。

哪里讲得不准,评论区指出,我改在这篇的更新里。欢迎学习交流、面试问题求助、共同进步。

绿泡泡【我要拿offer】,回复 JVM 拿那份按模块整理的高频面试题 PDF。想看每日面经和 offer 案例的,小红书同名号程序员曜灵。

相关推荐
IT研究室1 小时前
最新计算机毕业设计选题推荐-基于spring boot的葡萄酒文化宣传网站-网站-文档指导-Java-springboot
java·spring boot·课程设计
慧都小项2 小时前
MyEclipse 中创建和测试 JAX-RS REST 接口:从项目配置到 REST Explorer
java·restful·myeclipse·jax-rs
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 入门案例:编写 Dubbo 服务提供者
java·dubbo·交友
czt_java2 小时前
JVM 核心基础|运行时数据区、类加载机制、GC 分代回收
jvm
秋饼2 小时前
Spring AI 2.0 结构化输出企业级实战:从 BeanOutputConverter 源码到生产级解析容错
java·ai·技术分享·后端开发
摇滚侠2 小时前
《计算机是如何工作的:人人都能懂的计算机软硬件工作原理》第 9 章 机器码与汇编语言 阅读笔记 9
java·开发语言·笔记
Cc.Y2 小时前
Java零基础入门:深入Java核心:文件IO与序列化全解
java·开发语言·python
拿本唠嗑AI研究2 小时前
本体工程实战指南:从建模到可维护的完整落地路径
java·人工智能·python·jena·ontology
lambo mercy3 小时前
Day3:结构化输出
java·数据库·oracle