
聊到 Java 面试JVM 垃圾回收几乎是绕不开的关卡。不管是校招还是社招只要岗位要求里写了 JVM 调优、高并发或者中间件相关面试官基本都会拿 GC 来试探深浅。很多候选人背了八股文能说出“复制算法”“标记整理”“分代收集”这些名词但一问到“为什么新生代要用复制算法”“G1 的 Region 和 CMS 比到底强在哪”就哑火了。这篇文章我想从面试突围的角度把 JVM 垃圾回收这条线的知识点串起来。不堆砌术语而是讲清楚每个设计背后的“为什么”同时给出可落地的排查手段和实战经验。不管你是正在准备面试还是已经工作了想系统补一下 GC 知识这篇文章都值得花二十分钟看完。1. 内存区域划分与 GC 的作用范围1.1 先搞清楚垃圾回收到底管哪块内存很多人在面试一开始就出错是因为连 GC 管的是哪块区域都没说清。JVM 的内存区域可以粗分为线程共享和线程私有两部分。线程私有的有虚拟机栈、本地方法栈、程序计数器这些基本不参与垃圾回收生命周期跟随线程栈帧弹出就释放了。真正让 GC 操心的是堆和方法区在 HotSpot 里也叫元空间JDK 8 之后永久代被移除元空间直接使用本地内存。这里有个高频考点堆内存的划分。新生代Young Generation里又分成 Eden 区和两个 Survivor 区S0、S1老年代Old Generation存放长生命周期的对象。很多人背下了比例是 8:1:1但未必说得清为什么要留两个 Survivor。两个 Survivor 的设计是为了配合复制算法。对象在 Eden 区被创建后经历一次 Minor GC存活对象会被复制到 S0下次 GC 时存活对象被复制到 S1同时年龄加一。两个 Survivor 来回倒腾保证了任何时刻都有一个 Survivor 是空的这样复制算法的“从一边复制到另一边”才有空间可用。如果只有一个 Survivor对象无处可复制就只能在原位置标记整理效率会大打折扣。1.2 判断对象存活可达性分析不是玄学面试官问“怎么判断一个对象该不该回收”标准答案是引用计数法和可达性分析。引用计数法有个经典的循环引用问题两个对象互相引用但外面没有入口引用计数永远不为零就没法回收。所以主流 JVM 用的都是可达性分析。可达性分析的起点叫 GC Roots包括虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI 引用等。从这些根出发沿着引用链往下找能到达的对象就是存活的没被到达的就可以回收。这个地方有一个容易出彩的回答准确式 GC 与 OopMap。HotSpot 实现可达性分析时并不是遍历整个栈去猜哪些是引用类型而是在特定位置记录 OopMap普通对象指针映射GC 发生时直接查这张表快速定位 GC Roots。这引出了安全点Safepoint的概念线程只有运行到安全点才会停下来做 GC 根扫描这也是为什么 GC 日志里会有“VM operation”这类停顿时间。另外Java 里的引用类型也是面试常考点。强引用Strong Reference、软引用Soft Reference、弱引用Weak Reference、虚引用Phantom Reference回收力度依次增强。软引用在内存不足时才回收典型应用是缓存弱引用在下一次 GC 时就会被回收ThreadLocal 的 ThreadLocalMap 里专门用了弱引用来防内存泄漏。2. 垃圾回收算法不仅要会背还要会讲2.1 三种基础算法的取舍逻辑标记-清除Mark-Sweep是最朴素的思路先标记所有存活对象再统一清除未标记对象。缺点是产生大量不连续的内存碎片大对象分配时明明总空间够但连续空间不够就会提前触发 Full GC。标记-复制Mark-Copy把内存分成两块只使用一块GC 时把存活对象复制到另一块再清空原来的块。优点是实现简单、无碎片缺点是内存使用率只有一半。新生代用 Eden 两个 Survivor 的 8:1:1 划分就是把这个空间浪费压缩到只有 10% 的 Survivor 空着。标记-整理Mark-Compact结合了前两者的思路标记存活对象后把所有存活对象往一端移动直接清理掉边界以外的内存。没有碎片但移动对象需要更新引用地址整个过程比复制算法更复杂。面试里我建议用一个生活化的类比把三者串起来教室里有 50 个座位20 个人坐着。标记-清除就是把空座位直接废掉导致座位之间有空隙后面来的人坐不了连续的位置标记-复制是只保留一列座位上课时把坐着的人全赶到旁边另一列然后把原来那列全部清空标记-整理是把 20 个人全部集中到前三排后面的位置全部空出来新来的可以连续入座。2.2 分代收集理论为什么不能一套算法走天下不同的对象生命周期差异巨大。大量对象“朝生夕灭”比如方法里的局部变量、循环里临时构建的对象也有少数对象长期存活比如连接池、缓存、静态容器。如果所有对象都放在一起用同一种算法要么浪费空间要么频繁搬动存活对象。分代收集的思路就是按对象的年龄分区治理新生代放短命对象用复制算法快速清理因为存活率低复制成本小老年代放长命对象用标记-整理算法避免频繁移动大对象也避免内存碎片。这里有个值得提的细节对象晋升到老年代的条件不只是年龄阈值默认 15。还有动态年龄判定即 Survivor 中相同年龄对象的总大小超过 Survivor 空间一半时年龄大于等于它的对象直接晋升老年代。另外大对象比如很大的数组或者字符串直接进老年代避免在新生代反复复制。3. 垃圾收集器全景图与选型思路3.1 从 Serial 到 CMS每一代收集器解决什么问题垃圾收集器是算法的工程实现。面试官问收集器核心是考察你对“停顿与吞吐取舍”的理解。Serial 是单线程收集器GC 时必须暂停所有工作线程Stop The WorldSTW。虽然听起来很原始但它在单核环境下的效率其实不差Client 模式的 JVM 默认可能就是它。Parallel Scavenge 的目标是吞吐量适合后台计算任务可以接受偶尔的较长停顿。CMSConcurrent Mark Sweep是第一款真正意义上的并发收集器它把标记过程拆成了初始标记、并发标记、重新标记、并发清除四个阶段其中只有初始标记和重新标记需要短暂停顿。但 CMS 的问题很明显使用标记-清除算法会有内存碎片在并发清理阶段会消耗 CPU 资源最致命的是它无法处理浮动垃圾并发标记期间新产生的垃圾如果老年代在 GC 期间被写满就会触发 Concurrent Mode Failure退化成 Serial Old 做 Full GC停顿时间反而更长。3.2 G1 的 Region 设计从物理分代到逻辑分代G1Garbage First从 JDK 9 开始成为默认收集器。它的核心设计是把堆划分为多个大小相等的 Region每个 Region 可以是 Eden、Survivor 或者 Old逻辑上的分代变得动态灵活。G1 的命名“Garbage First”指的是它优先回收垃圾最多的 Region也就是收益最大的区域。它引入了一个非常重要的概念可预测的停顿时间模型。通过 -XX:MaxGCPauseMillis 设定目标停顿时间G1 会在后台维护一个 Region 回收成本模型每次根据可回收空间大小和回收成本选择一批 Region 进行回收尽量把 STW 控制在预期内。要注意的是G1 的混合回收Mixed GC并不是只回收新生代它会选择部分老年代 Region 一起回收。在 G1 中对象的跨 Region 引用用“记忆集 写屏障”来维护这也是面试里可能问到的高级话题——写屏障的开销是 G1 效率的一个重要平衡点。3.3 ZGC 与低延迟时代的新解题思路如果说 G1 追求的是可预测停顿那 ZGC 的目标就是直接把停顿时间压到毫秒级。它的核心是染色指针Colored Pointers和读屏障Load Barrier。ZGC 将一部分内存地址用作状态标记通过读屏障在对象引用访问时即时处理重映射把大部分 GC 工作从 STW 中剥离开来。ZGC 的停顿时间不随堆大小增长而增长这在超大堆场景下很有价值。不过我个人的经验是ZGC 适合大堆低延迟场景比如在线业务、实时推荐这类对响应时间敏感的应用。如果你的服务堆内存就 1-2GB吞吐优先的业务反而用 Parallel 或者 G1 更合适。面试时如果能主动说出这个分层选型的思路会比机械堆参数好很多。4. 实战为中心GC 日志分析与问题定位4.1 先造一个“内存泄漏”现场光会背理论不如亲手看到一个 Full GC 发生。我平时演示的时候会用这样一段模拟代码不断往一个静态 List 里放对象人为制造老年代压力public class GcDemo { private static final Listbyte[] HOLDER new ArrayList(); public static void main(String[] args) throws Exception { for (int i 0; i 1000; i) { HOLDER.add(new byte[1 * 1024 * 1024]); // 每次分配 1MB Thread.sleep(50); } } }运行的时候加上 GC 日志参数java -Xms256m -Xmx256m -XX:PrintGCDetails -XX:PrintGCDateStamps GcDemo观察控制台输出你能看到 Eden 区逐渐被打满触发 Minor GC然后部分对象因为被 List 引用无法回收逐渐晋升到老年代最后触发 Full GC持续扩容但内存始终无法释放。这个实验的意义在于直观展示内存泄漏不一定是“内存一直涨”也可能是对象大量晋升导致 Full GC 频繁响应时间飙升。4.2 读 GC 日志中的关键指标GC 日志里的信息密度很高但初次看容易懵。以一段并行收集器的日志为例[GC (Allocation Failure) [PSYoungGen: 51200K-5120K(59392K)] 61200K-10240K(195584K), 0.0123456 secs]这里的含义是年轻代 GC 前占用 51200KGC 后占用 5120K整个堆 GC 前 61200KGC 后 10240K。如果括号里的“前后”数值非常接近说明这次 GC 基本没释放出空间对象几乎都是存活的这通常是内存压力的早期信号。还有 Full GC 的日志经常能看到类似[Full GC (Metadata GC Threshold) [Metaspace: 34522K-34522K]Metaspace 引起的 Full GC 也是个常见坑反射或者 CGLIB 动态代理使用过多元空间不断膨胀如果不设置 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSizeJVM 会根据应用运行情况动态调整阈值导致频繁 Full GC。一个合理的做法是把 MetaspaceSize 设置为一个接近实际使用量的值减少阈值伸缩带来的抖动。4.3 真实场景中常见的 GC 异常速查我在实际排查过程中总结了一个问题对照表面试里回答“你遇到过什么 GC 问题”时可以直接套用现象可能原因排查方向Minor GC 非常频繁新生代空间太小Eden 区对象很快填满调整堆大小或 -XX:NewRatioFull GC 频繁老年代空间不足或晋升阈值设置不合理检查是否有大对象、内存泄漏调整 -XX:MaxGCPauseMillisGC 后内存不降反升大量对象被集合静态引用无法回收dump 堆快照用工具分析引用链Metaspace Full GC动态类生成过多限制 -XX:MaxMetaspaceSize排查类加载器泄漏Concurrent Mode FailureCMS 回收速度跟不上分配速度增加堆空间或切换 G1单次 GC 停顿时间过长堆太大且收集器选择不当或根扫描范围过大检查 GC Roots 数量、使用分代收集器调优这里额外说一个我在实践中踩过的坑不要盲目调大堆内存。有一段时间我们某系统频繁 Full GC我觉得是堆不够直接把 Xmx 从 4G 调到了 8G。结果 Full GC 次数减了但单次停顿时间翻了一倍最后下游超时更多了。后来发现根因是某个定时任务把大量数据加载进了静态 Map排查到之后换了缓存实现并做了容量上限控制才算真正解决。4.4 常用的 JVM 参数梳理面试经常让你“说一下 JVM 参数”我建议按组记忆而不是零散地报一堆名字。堆与代际参数-Xms4g -Xmx4g -Xmn1g -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15 -XX:PretenureSizeThreshold1mGC 日志参数JDK 8 和 JDK 9 写法不同# JDK 8 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log # JDK 9 -Xlog:gc*:file/data/logs/gc.log收集器切换参数-XX:UseSerialGC -XX:UseParallelGC -XX:UseConcMarkSweepGC -XX:UseG1GC -XX:UseZGC我个人建议线上环境务必加上 -XX:HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath这样 OOM 时能自动留下堆快照否则故障过去之后连证据都没有排查极其被动。5. 面试现场的加分技巧与表达框架5.1 用“停顿与吞吐”作为主线回答面试官问 GC 问题时心里其实在验证你的知识体系是否成网络而不是零散的点。我的建议是所有回答都围绕“停顿时间STW与吞吐量Throughput”这条主线展开。比如问到“G1 和 CMS 有什么差别”不要只列举“CMS 是分代的G1 是 Region 的”。你可以说CMS 主要目标是低延时它减少了老年代的并发标记停顿但它使用标记-清除导致碎片问题而且在并发阶段出现 Concurrent Mode Failure 时会有一次很长的 Full GCG1 用 Region 划分实现了逻辑分代通过写屏障维护记忆集来记录跨 Region 引用配合可预测停顿模型兼顾了延迟和大堆的扩展性。这样既展示了原理又展示了对比思维。5.2 举一个真实问题的排查案例“描述一次你排查 GC 问题的经历”是高频场景题。我建议准备一个完整的小故事现象是什么初步用了什么工具中间排除了哪些假设最后怎么定位和解决。一个比较通用的模板是某天收到告警说接口 P99 上涨查看监控发现 Full GC 间隔从原本的 30 分钟一次缩短到 3 分钟一次。先看 GC 日志确认老年代持续增长然后 dump 堆快照用内存分析工具查看大对象发现某个缓存在并发写入时没有设置上限导致 key 无限增长。修复方式是改用本地缓存并设置最大容量同时把过期策略调明显。这个案例里面监控发现、日志分析、堆转储、根因定位、解决方案是完整闭环的比泛泛而谈“我遇到过内存泄漏”有说服力得多。5.3 如果被问到不会的问题怎么办GC 领域很深面试官追问到 ZGC 的染色指针底层实现、JIT 对内存分配的影响这类问题时很多人会慌。我建议的应对方式是先接住问题用已有知识做推理同时坦诚边界。你可以说“这部分细节我没有在源码层面深入研究过我目前的理解是……从工程使用的角度我会怎么怎么做。”把问题拉到你熟悉的领域同时展现出诚实和学习意愿面试官通常不会继续死磕。6. 从面试到实战GC 知识如何反哺日常开发把视野拉远一点GC 不只是面试题它对日常编码的指导意义非常直接。第一它教会我们尽量少创建短命大对象。比如字符串拼接在循环里用 号会反复创建中间字符串对象虽然 GC 能处理但高频场景下依然会造成密集的 Minor GC。用 StringBuilder 不是教条而是在 GC 层面减少分配压力。第二它让我们理解缓存与池化的价值。线程池、连接池、对象池本质都是为了避免频繁地创建和销毁对象降低 GC 负担。一个系统如果每秒创建大量生命周期极短的对象Eden 区会被迅速打满即使 Minor GC 很快但频率上来了总体消耗依然可观。第三它提醒我们在设计全局集合时要格外小心。静态集合很容易成为 GC Roots 的延伸只要集合本身活着里面的所有对象都无法回收。Spring 里一些容器缓存、业务里的全局配置 Map都是容易出现问题的地方。往这些集合里放东西之前先想一想对象的生命周期是否可控。第四合理预估系统容量。对堆内存规划来说不是“内存越大越好”而是结合业务对象大小、并发量、GC 频率综合估算。我常用的粗估方式是统计应用存活对象的平均值乘以预期的并发数和对象生命周期再加上缓冲余量。这个数值未必精确但能帮你避免拍脑袋定参数。我在实际项目中还发现GC 日志的巡检应该纳入日常运维。哪怕系统没有告警每周看一眼 GC 频率和停顿趋势往往能提前发现隐患。比如日志显示 Young GC 频率从每 30 秒一次变成了每 10 秒一次虽然接口响应还正常但这已经是内存压力上升的早期信号了。提前处理往往能在变成故障之前就解决掉。7. 写在最后一条我常用的自测题如果你想把这一整套知识串起来自测下面这道题是个不错的抓手“一台 8C16G 的机器上部署了我们的 Java 服务堆内存 8G最近出现 Full GC 频繁但是堆内存总占用看起来只有 30%。你觉得可能是什么原因你会用什么手段定位”这个题目的开放度很高。堆占用不高但 Full GC 频繁可能的原因有很多元空间膨胀、直接内存泄漏、GC Roots 扫描范围过大、晋升阈值设置不当导致对象过早进入老年代、甚至系统里某个线程疯狂触发 System.gc。排查手段上可以从 jstat 看各代容量和 GC 次数用 jmap dump 堆看对象分布用 jcmd 查看当前 JVM 参数再配合业务日志找周期性的分配高峰。回答得好不好完全取决于你平时的积累深度。GC 这个主题说容易也容易毕竟知识点就是那些说难也难因为它把 JVM 内存管理、并发控制、性能调优、故障排查全部串在一起。面试前我建议大家不只背结论而是问自己三个问题这个设计解决的是什么问题它牺牲了什么换来了什么如果让我选我会在什么场景下用它把这三个问题想透了面试官问什么都不怕。