首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
JVM垃圾回收全面解析:从算法到收集器与调优实战
📅 2026/10/9 5:41:59
✍️ 爱科研究院
👁 阅读 3,247
1. 先搞清楚我们在聊什么GC 的全局认知作为一名长期跟 JVM 调优打交道的开发者我见过太多人一听到“GC”就开始背八股文什么是分代收集、什么是复制算法、CMS 和 G1 谁更好……但真到线上出现 Full GC 频繁、STW 时间过长的时候却不知道从哪里下手。原因很简单——GC 不是孤立的知识点它是一个由收集器、算法、内存模型、GC 类型组成的完整体系。今天这篇就是想用一篇文章把这些东西串起来让你既能应付面试也能真正解决生产环境的问题。先给不熟悉的读者一个基本概念GCGarbage Collection就是 JVM 自动回收堆内存中不再被引用的对象的过程。Java 开发者之所以不需要手动delete内存就是因为 JVM 背后的 GC 机制在替我们干活。但“自动”不等于“免费”——GC 执行时会暂停应用线程也就是 Stop-The-World简称 STW停顿时间越久系统延迟越高。所以 GC 调优的本质其实就是在吞吐量和停顿时间之间做权衡。这篇文章适合谁正在准备高级 Java 岗位面试的开发者、做后端服务调优的运维工程师、以及所有对 JVM 底层运行机制好奇的人。我会从四种基础算法讲起再逐个拆解主流收集器最后把 Minor GC、Major GC、Full GC 这些容易混淆的概念用表格和实例彻底理清。看完之后你会对“什么时候该用哪种收集器”“为什么老年代 GC 比新生代慢那么多”这类问题有自己明确的判断。2. 地基四种垃圾回收算法的原理与演进2.1 标记-清除Mark-Sweep最朴素的思想标记-清除算法是现代垃圾回收算法的基础。它分两步走第一步从 GC Roots 出发把所有可达对象做上标记第二步线性扫描堆内存把没有标记的对象直接回收。听起来很简单但实际使用中它有两个致命问题。首先是碎片化回收之后存活对象之间会留下大量不连续的内存空洞下次分配一个大对象时明明总空间足够却因为找不到连续区域而被迫触发一次 GC。其次是效率不稳定标记和清除两个阶段都要遍历整个堆堆越大耗时越长而且清除之后堆的可利用空间反而可能变得更不规整。不过别急着否定它——它的思想贯穿了后面所有算法而且 CMS 收集器的底层用的就是它的改良版。理解标记-清除的关键在于理解“碎片化带来的连锁反应”这会帮助你搞懂为什么后来会出现复制算法和标记-整理算法。2.2 标记-复制Mark-Copy用空间换时间标记-复制算法把内存分成两块相同大小的区域每次只用其中一块。分配对象时只用一块GC 时把存活对象整体复制到另一块然后一次性清空旧区域。这样做的优点非常直接没有碎片化分配指针只需要按顺序移动效率高复制完后旧区域整个变成空白新区域中对象排列紧密。代价是空间利用率只有 50%——你明明有 100MB 内存实际能用的只有 50MB。这在 JVM 里被设计成新生代的默认方案因为新生代对象“朝生夕灭”的比例极高绝大多数对象活不过第一轮 GC所以复制成本极低。HotSpot 虚拟机把新生代划分成 Eden 区和两个 Survivor 区默认 8:1:1就是为了把复制算法的空间浪费控制在 10% 左右——每次 GC 时把 Eden 和一块 Survivor 中存活的对象复制到另一块 Survivor这样只有 10% 的 Survivor 空间是闲置的。2.3 标记-整理Mark-Compact解决老年代的痛点老年代的对象存活率很高如果用复制算法每次 GC 都要复制大量对象成本高得离谱。所以老年代用了一种基于标记-清除改进的算法标记-整理。它的标记过程和标记-清除一样但清除前先把所有存活对象向内存一端移动然后直接清理掉边界之外的所有空间。这样既避免了碎片化又不用浪费一半内存。代价是移动对象需要更新引用这一步往往需要多次 STW——CMS 的设计初衷就是让这一步尽量并发执行而 G1 则用 Region 分区加局部复制来绕开全局整理。这里的人生经验是处理老年代问题要么接受长时间停顿要么接受复杂的并发机制没有免费的午餐。2.4 分代收集Generational Collection算法组合的艺术分代收集不是一种具体的算法而是对上述三种算法的组合应用策略。它的依据来自绝大多数 Java 程序的统计规律大部分对象生命周期极短创建后很快变成垃圾少数对象活得很久比如缓存、单例、连接池对象。于是 JVM 把堆分成新生代Young和老年代Old。新生代用复制算法因为垃圾多、存活少复制便宜老年代用标记-整理或标记-清除因为对象存活率高清理才是主要矛盾。GC 的调优思路也由此而来新生代越大Minor GC 频率越低但每次停顿可能变长老年代越大Full GC 频率越低但一旦触发停顿更可怕。理解分代是理解后面所有收集器的前置条件。3. 主角登场主流垃圾收集器逐一拆解3.1 Serial / Serial Old单线程的极致Serial 收集器是最古老的收集器新生代用它做复制算法老年代用 Serial Old 做标记-整理。它的特点是所有 GC 工作都在一条线程内完成没有线程切换开销所以在单核 CPU 或低并发的客户端场景下它的吞吐表现反而非常优秀。我见过不少极简的嵌入式 Java 服务还在用它因为停顿再长也只有一条线程在跑没有额外的同步成本。它的致命短板也是单线程GC 时会中断所有应用线程多核 CPU 利用率低停顿时间长。但在以下场景依然值得考虑JVM 启动初始阶段GC 日志中能看到 Serial 被用作默认收集器宿主机只有单核 CPU对吞吐没有高要求的小型后台任务。Serial Old 作为老年代收集器还有一个重要身份——它是CMS 的备用方案。当 CMS 并发失败时JVM 会强制降级为 Serial Old 做 Full GC很多线上日志里的“Full GC (System.gc())”或“concurrent mode failure”背后就是它在兜底。3.2 Parallel / Parallel Old吞吐优先的多线程选手Parallel Scavenge新生代和 Parallel Old老年代组合起来就是 JDK 8 默认的收集器组合。它和 Serial 的逻辑相似但用多条线程并行执行 GC能够大幅缩短 STW 时间。为什么说它是“吞吐优先”因为它的设计目标就是可控制的吞吐量。JVM 提供了两个极其实用的参数-XX:MaxGCPauseMillis控制最大 GC 停顿时间JVM 会动态调整堆大小、新生代比例来实现这个目标-XX:GCTimeRatio设置垃圾回收时间占总时间的比例用来计算期望的吞吐量。打个比方如果GCTimeRatio19则允许 GC 时间占总时间的 5%1/191也就是要求系统至少 95% 的时间都在运行业务逻辑。Parallel 收集器还有一个自适应的调节开关-XX:UseAdaptiveSizePolicy开启后 JVM 会自动调节 Eden、Survivor 和老年代的比例来匹配目标。这种“自动调参”在大多数场景能省心但如果你想精细控制分区大小记得把它关掉。它的问题在于停顿时间目标不受硬性保证——如果堆很大一次 Full GC 照样可能停顿好几秒。吞吐量上去了延迟不一定达标这就是为何在后端接口要求毫秒级响应时会考虑换 CMS 或 G1。3.3 CMS追求低停顿的先行者CMSConcurrent Mark Sweep是老一代低延迟代表。它的核心设计是让大部分 GC 工作与业务线程并发执行主要针对老年代使用标记-清除算法。整个流程分为四个阶段初始标记CMS Initial Mark标记 GC Roots 直接关联的对象。需要 STW但耗时很短并发标记CMS Concurrent Mark从已标记对象向下遍历标记所有可达对象。与业务线程并发执行不 STW重新标记CMS Remark修正并发标记期间因业务线程修改引用而产生的漏标或错标。需要 STW但可通过-XX:CMSScavengeBeforeRemark让其在 Minor GC 后执行来缩短时间并发清除CMS Concurrent Sweep清除垃圾对象与业务线程并发。这种设计让 CMS 的停顿主要集中在一小段初始标记和重新标记上整体停顿远低于 Parallel。但优点和缺点同样尖锐会产生碎片标记-清除算法不做整理长期运行后老年代碎片化严重出现“明明空间够但对象分配失败”的情况并发模式失败Concurrent Mode Failure如果并发标记期间老年代空间不足以容纳新对象CMS 会临时启用 Serial Old 做全程 STW 的 Full GC效果反而是灾难性停顿吞吐量下降并发阶段占用 CPU 资源且清理由后台线程做吞吐不如 Parallel。有一个常用参数组合值得记住-XX:CMSInitiatingOccupancyFraction70表示老年代用到 70% 时就开始并发标记给业务线程留出缓冲配合-XX:UseCMSInitiatingOccupancyOnly让它只按这个阈值触发而不依赖 JVM 自动判断。JDK 9 之后 CMS 被标记废弃但我仍建议理解它因为 G1 和 ZGC 的设计都是从解决 CMS 的痛点出发的。3.4 G1全堆分区与可预测停顿G1Garbage First在 JDK 9 成了默认收集器。它把整个堆分成多个大小相等的 Region默认约 2048 个逻辑上把 Region 区分为 Eden、Survivor、Old、Humongous大对象区。G1 每次回收不需要全堆扫描而是优先回收垃圾最多的 Region这就是名字“Garbage First”的来源。G1 的 GC 分为两种操作Young GC并行复制新生代 Region 中的存活对象到 Survivor Region速度快Mixed GC在 Young GC 基础上同时回收部分垃圾比例高的 Old Region。它的核心特点是可通过参数设定停顿时间-XX:MaxGCPauseMillis200表示期望停顿控制在 200 毫秒内。G1 通过跟踪每个 Region 的垃圾回收成本动态调整回收数量和顺序来尽量满足这个目标。但很多人忽视了 G1 的一个关键问题它并不是在所有场景都优于 Parallel。数据量小的堆比如 1~2GB用 G1 反而可能因为有额外的 RSet 维护成本而表现更差而超大堆、大量大对象则容易触发 Full GC退化为 Serial 的 Full GC。G1 的调优重点在于合理设置RegionSize、MaxGCPauseMillis和InitiatingHeapOccupancyPercent默认为 45即堆使用到 45% 时开始并发标记这需要结合真实业务做反复测试而不是盲目套参数。3.5 ZGC / Shenandoah超低停顿的未来方向ZGC 是 JDK 11 引入的实验性收集器JDK 15 转正。它最大的突破是所有阶段几乎都是并发的停顿时间基本控制在 10ms 以内且不随堆大小增长。它引入了染色指针Colored Pointers、读屏障Load Barrier等技术通过在对象引用上携带标记信息实现在并发状态下移动对象让应用线程在访问对象时自动修正引用。Shenandoah 则是另一款类似思路的收集器它默认使用 Brooks 指针而非染色指针但理念相同让 GC 的移动过程并发执行只有初始标记和结束标记需要极短暂的 STW。对于大内存、低延迟场景ZG是目前最值得关注的选择。它支持从 64GB 到数 TB 的堆同时也兼容分代模型JDK 21 引入分代 ZGC。我建议普通项目先不要在 8GB 以下堆强上 ZGC收益不明显反而浪费了成熟稳定的 G1 经验。3.6 收集器选型对照表收集器组合适用场景核心优点核心缺点Serial Serial Old单核 CPU、小型桌面应用实现简单无并发开销停顿时间长Parallel Parallel OldJDK 8 默认高吞吐后台任务吞吐量高多核并行停顿时间高CMS ParNew老版本低延迟应用停顿短并发清除碎片化并发失败风险G1JDK 9 默认大堆服务器可预测停顿分区回收小堆上优势不明显ZGC超大堆、极低延迟停顿 10ms几乎不随堆大小回收机制复杂需 JDK 154. 彻底理清 Minor GC、Major GC、Full GC 这三兄弟4.1 Minor GC新生代的日常清扫Minor GC 也叫 Young GC作用范围是新生代Eden Survivor。当 Eden 区空间不足JVM 就会触发 Minor GC把 Eden 和一块 Survivor 中存活的对象复制到另一块 Survivor同时将 Survivor 区中超过年龄阈值默认 15可通过-XX:MaxTenuringThreshold调整的对象晋升到老年代。Minor GC 的特点是频率高、速度快。因为新生代存活对象少复制成本低停顿时长通常在几十到几百毫秒。但频率过高也不行意味着对象分配压力大系统吞吐下降。调优目标往往是通过增大 Eden 区来降低 Minor GC 频率但要警惕 amplification——Eden 太大Minor GC 间隔拉长大量对象在同一时间晋升到老年代反而提前触发 Full GC。一个常见的业务场景是某服务高峰期每秒创建大量临时对象Minor GC 从每秒 5 次降到 3 次但老年代占用快速上升这就是 Eden 过大导致的“搬水牛”效应。Minor GC 是否 STW是的。虽然时间短但它是完全 STW 的只是绝大多数情况下影响有限。这就是为什么优化 Minor GC 频率能直接降低接口延迟抖动。4.2 Major GC 和 Full GC 的模糊地带这里必须先纠正一个广泛存在的误区在很多资料里Major GC 和 Full GC 被混为一谈但严格来说Major GC 通常指老年代 GCOld Generation GC回收对象范围是老年代Full GC 则指整个堆新生代 老年代 元空间的回收并且一般伴随 STW。但在实际 JVM 日志中尤其是 Parallel 收集器Full GC 经常包含了 Major GC 和 Minor GC 的组合动作所以大家习惯性把所有老年代 GC 都叫 Full GC。理解这点对面试有很大帮助——当面试官问“Major GC 和 Full GC 的区别”时你回答“Major GC 旧定义是老年代回收Full GC 是全堆回收但在多数收集器中 Major GC 可以视为 Full GC 的一部分”就已经碾压了一半候选人。4.3 Full GC 的代价为什么这么大Full GC 意味着要扫描整个堆甚至包括元空间。触发场景主要有以下几类老年代空间不足晋升对象太多、大对象直接进入老年代占满空间元空间不足加载的类太多触发 Full GC 去回收废弃的类System.gc()显式调用很多框架和运维脚本会调用它除非加了-XX:DisableExplicitGC否则一脸无奈并发模式失败CMS 老年代在并发标记期间耗尽空间G1 的 Humongous 对象分配失败超大对象找不到合适 Region。Full GC 的停顿时间通常是 Minor GC 的数倍甚至数10倍因为在做标记-整理或者复制时要处理海量对象。这也是所有调优策略要优先避免 Full GC 的原因。生产环境常见的调优目标就是把 Full GC 降低到几小时甚至一天一次同时让单次停顿可控。4.4 一次完整 GC 事件实战推演假设我们用 G1 收集器堆大小为 4GBEden 到达阈值触发了一次 Young GC。日志中会显示年轻代的大小变化、回收前后占用回收耗时通常毫秒级对象晋升情况。随后如果堆占用达到 IHOP 阈值比如 45%就会进入并发标记周期完成后可能触发 Mixed GC。如果 Mixed GC 后老年代依然不够或者分配 Humongous 对象失败就会进入 Full GC暂停时间可能数秒。通过 GC 日志分析这类问题能被快速定位。下面我会重点聊聊我在实际调优中的排查路径。5. 从日志到行动GC 调优实战技巧5.1 如何查看和解析 GC 日志先普及 JVM 参数-XX:PrintGCDetailsJDK 8或-Xlog:gc*JDK 9输出 GC 详情-XX:PrintGCDateStamps打印时间戳-XX:PrintTenuringDistribution输出对象年龄分布。以 JDK 8 常用组合为例java -Xloggc:/path/to/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintTenuringDistribution -jar app.jarJDK 11 推荐java -Xlog:gc*,gccausedebug:filegc.log:tags,uptime,level -jar app.jar拿到日志后重点看几类信息每次 GC 的类型、GC 前后堆占用、停顿时间、晋升对象大小。如果 Minor GC 频繁且每次晋升量很大说明 Survivor 区太小或年龄阈值过低如果 Full GC 间隔持续缩短就要考虑是老年代容量或分配速率出了问题。5.2 调优三板斧堆大小、分区比例、收集器切换第一板斧是调整堆大小。-Xms和-Xmx不要配置成不同值以免运行时动态扩容造成额外开销。堆太小必然频繁 GC但堆太大也会增加单次 GC 时间和复制成本所以要结合服务的实际对象分配速率来定。一个可参考的经验是先看进程 RSS 内存占用把堆设置为 RSS 的 60%70% 左右再用压测验证。第二板斧是调整分区比例。Parallel 收集器可以设置-XX:NewRatio2老年代:新生代2:1、-XX:SurvivorRatio8Eden:一个Survivor8:1。G1 可设置-XX:NewSize、-XX:MaxNewSize来限制新生代大小以及-XX:SurvivorRatio。关键在于观察对象晋升速率如果晋升量大适当加大 Survivor 区或降低晋升阈值。第三板斧是更换收集器或升级 JDK。如果服务的堆在 4GB 以上且延迟要求高G1 是好选择如果堆超过 32GB 且需要 10ms 内停顿ZGC 值得考虑JDK 8 默认 Parallel 在很多业务中是“够用但不够优”的选项。我并不建议上来就抄网上参数而是先在测试环境用压测工具跑出 GC 日志基线然后每次只改一个参数反复比较。调优没有银弹只有用数据说话。5.3 几个我踩过的坑第一个坑把-XX:MaxGCPauseMillis设得太低。比如设成 50ms但物理机根本达不到G1 会让回收行为变得激进频繁 GC 反而降低吞吐。通常情况下 200~500ms 是更现实的停顿目标。第二个坑盲目使用-XX:UseG1GC但没调整 Region 大小。Region 太小会要求大量 Region增加 RSet 维护开销Region 太大又容易让大对象直接占用整个 Region。建议 2GB 堆用默认8GB 以上可以考虑设置-XX:G1HeapRegionSize16m。第三个坑不关注System.gc()。很多 RPC 框架的某个组件或监控脚本会调用它导致线上频繁 Full GC。谨慎起见可以加-XX:DisableExplicitGC注意它也会禁用部分工具的内存请求或者把-XX:ExplicitGCInvokesConcurrent打开让显式调用走并发 GC 而不是全停顿。6. 常见问题速查与面试高频问答6.1 哪些情况会触发 Full GC触发原因说明规避策略老年代空间不足晋升对象过多或分配大对象调整老年代容量、Survivor 比例元空间不足类加载过多或动态生成类调大-XX:MetaspaceSize或及时清理System.gc() 被调用显式请求禁用或改成并发模式CMS Concurrent Mode Failure并发标记期间空间耗尽提前触发阈值预留空间G1 Humongous 分配失败Region 放大对象不适配增大堆或调整 Region 大小6.2 面试官常问的几个问题“什么是 Stop-The-World”STW 指 GC 期间所有业务线程被暂停目的是保证对象引用的一致性防止 GC 线程和应用线程并发修改堆导致标记错乱。“为什么新生代不适合标记-整理”新生代存活率低复制成本比整理低得多整理还要移动大量对象并更新引用不划算。“G1 和 CMS 的最大区别是什么”CMS 对老年代整堆做并发标记清除G1 把堆分成 Region支持增量回收和可预测停顿。“ZGC 为什么停顿这么短”它把对象搬移、标记的大部分阶段并发化用染色指针和读屏障保证应用线程看到一致性视图。回答这些问题时一定不要只背概念尽量结合 GC 日志和实际调优场景来解释会更有说服力。7. 写在最后我对 GC 调优的一点切身体会我在实际项目里最深的体会是GC 调优不是为了追求“零 GC”或“零停顿”而是为了让 GC 的停顿变得可预测、可接受。曾有一次我们优化一个交易链路花了几周时间调 JVM 参数最后发现最大的停顿来源居然是被某个监控组件触发的System.gc()。把所有显式 GC 屏蔽、再把堆的初始和最大大小统一之后接口 P99 直接下降了 40%。那次经历让我明白在动工具之前先确认垃圾到底是怎么产生的。如果你正在处理线上的 GC 问题我的建议很简单打开 GC 日志跑一轮压测把每次 Minor GC 的晋升量、每次 Full GC 的前置事件、堆的占用曲线记录清楚再决定要去调整哪个参数。一次只改一个配置验证后保留有效项不要同时改一堆参数否则出了问题根本分不清是哪一步引入的。GC 的世界没有银弹但只要你把算法、收集器和 GC 类型的底层逻辑吃透面对再复杂的停顿问题你都会有清晰的排查线索。希望这篇从一个实践者的视角写出来的长文能成为你理解 GC 的一座桥梁。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/9 5:41:59
K3 Cloud WebAPI对接攻略:从Token认证到业务接口调用
2026/10/9 5:36:59
电器元件-微动开关
2026/10/9 5:36:59
智能家居安防设备B端客户获取方法分析:按行业筛选企业名单的实操路径
2026/10/9 6:42:04
C++跨平台移植性设计实战:避开编译器、数据类型与构建系统的坑
2026/10/9 6:42:04
C++高性能计算优化实战:从编译器参数到内存布局与多线程
2026/10/9 6:42:04
Claude本地调用全链路实战:代理、WSL2与VS Code深度集成
2026/10/9 6:42:04
Redis从安装到生产部署:配置、主从复制与持久化实操指南
2026/10/9 6:42:04
二阶锥规划求解动态最优潮流:主动配电网调度实战指南
2026/10/9 6:37:04
全屋定制避坑指南:从板材、封边到报价验收的实用流程
2026/10/9 0:01:35
RISC-V裸机启动全流程:从复位向量到main函数的七步实现
2026/10/9 0:01:35
Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南
2026/10/9 0:01:35
EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错
2026/10/8 5:02:14
Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化
2026/10/9 1:10:43
多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系
2026/10/9 3:31:49
hindsight:面向LLM应用的事后可观测性工程实践
2026/10/8 4:30:43
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/9 3:32:01
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/8 4:32:33
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)