
前言学完垃圾回收算法后翻开 JVM 文档往往会被一长串收集器名字绕晕Serial、Parallel、CMS、G1、ZGC……为什么 JVM 搞出这么多收集器本文顺着垃圾收集器的演进主线讲清楚它们各自的取舍与核心特点。文章目录前言一、为什么不能用一个收集器包办一切二、经典时代的分代收集器为什么总要两两配对2.1 极简单线程的 Serial 与 Serial Old2.2 追求吞吐量的 Parallel 组合2.3 专注低延迟的 ParNew 与 CMS三、不用两两配对的全堆收集器3.1 掌控停顿预期的 G1 收集器3.2 迈向毫秒级停顿的 ZGC四、排查与切换收集器的实用参数4.1 查看 JVM 默认的收集器4.2 常见收集器参数切换对照写在最后一、为什么不能用一个收集器包办一切很多人刚开始接触 JVM 垃圾收集器时脑海里都会冒出一个疑问既然 Java 官方养了那么多顶尖工程师为什么不直接写一个各方面表现都完美的收集器把所有的场景通通拿下现实情况是垃圾收集机制在底层天然存在一个“不可能三角”。垃圾收集器的权衡三角此消彼长空间换时间算力与空间折中吞吐量 ThroughputCPU 运行用户代码的时间占比停顿时间 Pause TimeSTW 卡顿的毫秒数内存开销 Footprint元数据与运行期额外消耗在算力与内存有限的物理世界里你不可能同时追求极致的吞吐量希望 CPU 把 99% 以上的时间都拿去跑业务逻辑垃圾回收最好一口气干完哪怕中间把业务线程卡停半秒也无所谓。极短的停顿时间要求每次 Stop The WorldSTW不能超过 10 毫秒甚至要压在 1 毫秒以内用户点页面时绝对感觉不到卡顿。极低的内存占用不占用额外的标记内存元数据和记忆集Remembered Set不占堆空间。这三者无法兼得。如果你想停顿时间短垃圾收集器就必须在业务线程还在跑的同时去并发标记和清理。这一并发收集器线程就得抢占 CPU 算力吞吐量立刻被拉低而且还需要额外的内存空间来记录并发期间发生变动的对象引用。反过来如果你追求极致吞吐量最直接的办法就是让收集器在需要清理时全速单跑把业务线程全部挂起不搞复杂的并发交互。没有放之四海而皆准的收集器。不同应用场景的需求千差万别科学计算和批处理任务看重吞吐量电商支付和 Web 接口在乎低延迟而在资源极度受限的嵌入式设备或客户端结构简单、占用内存少才是第一位。这就是 JVM 演进至今衍生出这么多收集器的根源。二、经典时代的分代收集器为什么总要两两配对在 G1 登上历史舞台之前JVM 的堆内存一直保持着物理上的严格分代新生代和老年代在物理地址上是两块完全独立的连续内存空间。当时的设计理念非常明确新生代存活率低用复制算法老年代存活率高用标记-清除或标记-整理算法。因为不同代际的算法和数据结构差异巨大早期的收集器都是按代际独立研发的。一个收集器只负责新生代另一个收集器只负责老年代。要想让整个 JVM 正常工作你必须把它们“两两组合”起来配合使用。渲染错误:Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ... subgraph 新生代收集器大多采用复制算法 Y ----------------------^在经典分代体系下诞生了三组最有代表性的组合。2.1 极简单线程的 Serial 与 Serial Old这是 JVM 最古老的收集器组合。它的特点非常纯粹单线程工作。当进行垃圾回收时不仅只有一个收集线程在干活而且它会毫不留情地暂停所有用户业务线程Stop The World直到清理完毕。Serial / Serial Old 收集线程应用程序业务线程Serial / Serial Old 收集线程应用程序业务线程触发 Minor GC / Full GC触发全局 STW只有一个单线程全速标记与复制/整理业务代码高速运行1业务线程全部挂起进入等待2垃圾收集完成恢复所有业务线程3业务代码继续运行4用现代的视角看单线程加全局卡顿似乎很落后。但在单核 CPU 或者内存只有几百兆的客户端环境里Serial 拥有不可替代的优势没有多线程交互与线程切换的开销代码实现简单占用额外的内存极少。新生代 Serial 使用复制算法老年代 Serial Old 使用标记-整理算法。在如今的云原生时代很多运行在微型容器例如只有 1 核 512MB里的轻量级后台服务指定使用 Serial 往往能跑得比多线程收集器更稳定。2.2 追求吞吐量的 Parallel 组合当多核 CPU 普及后单线程的 Serial 无法榨干硬件性能。JVM 团队推出了 Parallel Scavenge新生代与 Parallel Old老年代组合简称为 Parallel 组合。它们最大的标签就是吞吐量优先。Parallel 多核并行 GC 线程组业务线程池Parallel 多核并行 GC 线程组业务线程池内存不足触发 GC全部进入 STW调动所有 CPU 核心多线程并行清扫业务线程并发运行1所有业务线程挂起2清扫完毕业务线程恢复3业务代码继续全速运转4与 Serial 一样Parallel 在清理垃圾时依然会发生全局 STW。但区别在于它会调动所有可用的 CPU 核心多线程并行进行标记与对象搬运。Parallel 组合的核心逻辑是既然停顿不可避免那就用最快的速度把垃圾清完让 CPU 把绝大部分时间继续交给业务运算。新生代 Parallel Scavenge 使用复制算法老年代 Parallel Old 使用标记-整理算法。由于它能把 CPU 算力用到极致并且不容易产生内存碎片所以在很长一段时间里它都是 Java 生态的基石。在广泛使用的 JDK 8 中默认开启的正是这套组合。2.3 专注低延迟的 ParNew 与 CMS随着互联网 Web 应用的大规模兴起服务架构发生了根本性变化。用户在浏览器或手机端点击下单后端如果因为 JVM 搞一次 Full GC 卡住三五秒用户端直接就会超时甚至报错流失。此时吞吐量不再是唯一指标控制停顿时间成了燃眉之急。于是CMSConcurrent Mark Sweep应运而生。它是老年代第一款真正意义上的并发垃圾收集器通常与新生代多线程收集器 ParNew 搭配使用。CMS 的四阶段工作流程1. 初始标记Initial Mark只标记 GC Roots 能直接关联到的对象耗时极短需要短暂 STW2. 并发标记Concurrent Mark与用户业务线程同时运行顺着引用链遍历整个堆不阻塞业务耗时较长3. 重新标记Remark修正并发标记期间因用户写操作而发生变动的对象耗时较短需要 STW4. 并发清除Concurrent Sweep清理掉所有判定为垃圾的对象与用户业务线程并发执行无需 STWCMS 最精妙的设计在于把最耗时的“遍历整堆对象”与“清除死对象”两道工序放到了与用户业务线程并发执行的阶段。整个过程中STW 只发生在初始标记和重新标记两个极短的窗口期。对于外部调用方来说服务卡顿感被大幅削弱。但是CMS 为了换取低停顿付出了两个沉重的代价内存碎片为了能与用户线程并发执行它在老年代采用了标记-清除Mark-Sweep算法而不是标记-整理。这样做的结果是老年代会产生大量不连续的碎片。当突然来了一个大对象申请不到连续空间时就会被迫触发一次严重的 Full GC。退化降级如果在并发标记或清除期间剩余的老年代内存不足以承载新晋升的对象被称为 Concurrent Mode FailureCMS 会瞬间崩溃退化直接启动单线程的 Serial Old 进行全堆整理造成长时间的深度卡死。这两个缺陷也直接注定了 CMS 后续被逐步废弃的命运。三、不用两两配对的全堆收集器随着服务器内存越来越大从当年的 2GB、4GB 飙升到 32GB、128GB 乃至上 TB经典分代收集器遇到了物理瓶颈。面对几十上百 GB 的堆内存如果老年代依然是一整块连续物理空间无论是标记遍历还是对象整理耗时都以数十秒甚至分钟计。JVM 工程师意识到必须彻底重构堆的物理结构。ZGC 动态分页架构小型 Page(2MB)中型 Page(32MB)大型 Page(动态大小)G1 逻辑分代架构Region 1(Eden)Region 2(Old)Region 3(Survivor)Region 4(Old)Region 5(Humongous)Region 6(Eden)传统物理分代架构连续新生代Eden S0 S1连续老年代Tenured从这一代开始垃圾收集器不再需要“两两配对”。一个收集器就能统一掌管整个堆。3.1 掌控停顿预期的 G1 收集器G1Garbage-First在 JDK 7 引入并在 JDK 9 成为默认垃圾收集器。它彻底打破了新生代和老年代的物理连续界限。G1 把整个堆内存切分成几千个大小相同的独立小块称之为 Region通常大小在 1MB 到 32MB 之间。每个 Region 在逻辑上依然扮演 Eden、Survivor 或 Old 的角色但它们不再需要物理连续而且身份是动态变化的。G1 之所以叫 Garbage-First垃圾优先是因为它建立了一套性价比评估模型G1 会跟踪每个 Region 里的垃圾堆积价值回收该区域能释放多少空间需要花费多长时间。用户可以通过参数-XX:MaxGCPauseMillis指定一个期望的最大停顿时间默认 200ms。每次发生 GC 时G1 不会傻傻地把全堆都清理一遍而是优先挑选那些垃圾最多、回收耗时最少的 Region 进行回收Mixed GC。在算法层面从局部Region 之间看它采用的是复制算法把一个 Region 的存活对象复制到另一个空 Region 中从全局看等同于标记-整理。这使得 G1 既能控制单次 STW 的时间上限又不会产生像 CMS 那样难以收拾的内存碎片。3.2 迈向毫秒级停顿的 ZGC在超大内存系统下即使是 G1当堆扩容到几百 GB 时STW 停顿依然可能达到几百毫秒。为了将停顿压到极限新一代低延迟收集器 ZGC 应运而生。ZGC 采用分页模型Page并且几乎在所有的关键阶段都实现了并发并发标记并发预备重分配并发重分配移动存活对象并发重映射。它通过**染色指针Colored Pointers和读屏障Load Barrier**两项核心黑科技使得收集器即使在把一个对象从旧地址挪到新地址的瞬间业务线程依然可以安全访问根本不需要停顿下来等待对象搬迁。无论堆内存是 8GB 还是 16TBZGC 的停顿时间通常都能严格压在 1 毫秒以内。四、排查与切换收集器的实用参数了解了各款收集器的特性后在实际工程中我们该如何确认当前正在运行什么收集器又该如何主动切换4.1 查看 JVM 默认的收集器最简单直接的方法就是在启动 Java 程序时加上-XX:PrintCommandLineFlags参数。我们可以写一段基础的测试代码来观察 JVM 的行为packagecom.crayontech.jvm.gc;/** * 垃圾收集器环境探测与对象分配测试 */publicclassCollectorProbe{publicstaticvoidmain(String[]args){longmaxMemoryRuntime.getRuntime().maxMemory();longtotalMemoryRuntime.getRuntime().totalMemory();System.out.println(Java 运行时版本: System.getProperty(java.version));System.out.println(JVM 最大可用内存: (maxMemory/1024/1024) MB);System.out.println(JVM 当前总分配内存: (totalMemory/1024/1024) MB);// 持续分配小对象模拟运行for(inti0;i5;i){byte[]payloadnewbyte[1024*1024*2];// 2MBSystem.out.println(成功分配 2MB 对象轮次: (i1));}System.out.println(探测执行完毕。);}}在终端中执行编译并携带参数运行# 编译测试类javac com/crayontech/jvm/gc/CollectorProbe.java# 携带打印参数运行java-XX:PrintCommandLineFlagscom.crayontech.jvm.gc.CollectorProbe如果运行在 JDK 8 环境下控制台第一行通常会输出包含以下标志的内容-XX:UseParallelGC这说明当前默认使用的正是 Parallel Scavenge Parallel Old 组合。如果运行在 JDK 9 或 JDK 17 及以上版本输出中则会看到-XX:UseG1GC这表明系统默认启用了 G1 收集器。4.2 常见收集器参数切换对照如果需要根据业务特点显式指定收集器可以通过下表中的 JVM 参数进行配置垃圾收集器启用参数典型适用场景对应算法新生代 / 老年代Serial Serial Old-XX:UseSerialGC客户端应用、单核或小内存微服务512MB复制 / 标记-整理Parallel 组合-XX:UseParallelGC离线数据分析、批处理、科学计算等吞吐优先场景复制 / 标记-整理ParNew CMS-XX:UseConcMarkSweepGC早期 JDK 8 的中小型 Web 应用已逐步废弃复制 / 标记-清除G1-XX:UseG1GC堆内存在 4GB~64GB 以上的中大型 Web 服务Region 复制 / 标记-整理ZGC-XX:UseZGC超大堆内存16GB、对接口延迟极度敏感的核心业务动态分页并发复制JDK 版本与默认收集器变迁从吞吐量转向关注停顿与大堆支持大内存下追求亚毫秒级极致响应JDK 8默认Parallel 组合吞吐量优先算力最大化JDK 9 ~ JDK 17默认G1 收集器停顿可预期无物理代际界限JDK 14彻底移除 CMSZGC 与 Shenandoah 走向成熟从 JDK 的默认策略变迁中可以看出明显的风向转变从早年追求极致利用 CPU 算力Parallel全面转向控制单次卡顿时间与大内存伸缩性G1、ZGC。写在最后学垃圾收集器最忌讳的是死记硬背那一串冗长的参数。只要抓住“吞吐量”与“低停顿”这条拔河绳脉络就非常清晰早期追求多核算力Parallel 成了 JDK 8 的绝对主力后来互联网业务怕卡顿CMS 靠并发标记撕开了一道口子但终究受制于内存碎片G1 与 ZGC 则通过重构内存结构Region 与动态分页彻底消灭了物理分代的包袱。弄清楚每款收集器愿意为什么买单、又付出了什么代价你在面对真实生产环境的调优选型时就不会感到迷茫。欢迎点赞关注追更《从0到1学习JVM》系列后续内容。