➡️ 程序员曜灵 · 后端面试追问链 - 欢迎认识我
作者程序员曜灵,绿泡泡「我要拿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-alloption 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:+UseG1GCoption”。
这条判死了一批老建议。「配 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 案例的,小红书同名号程序员曜灵。