【JVM原理详解】24-垃圾回收算法-标记清除复制整理分代

【JVM原理详解】24-垃圾回收算法-标记清除复制整理分代
24-垃圾回收算法标记-清除、复制、整理与分代知道了哪些对象活着、哪些该死下一步就是如何高效回收死对象。GC 算法经过几十年的演化沉淀出四种基础范式Mark-Sweep标记-清除、Copying复制、Mark-Compact标记-整理和Generational分代收集。本篇将逐一拆解它们的原理、优劣与适用场景并讨论 HotSpot 中的卡表机制如何解决跨代引用问题。Mark-Sweep标记-清除这是最基础、也最直观的 GC 算法。过程分两步标记阶段从 GC Roots 出发遍历对象图标记所有存活对象。清除阶段线性扫描堆回收未标记的对象。标记前: [A*] [B] [C*] [D] [E*] (* 为存活) 标记后: [A*] [ ] [C*] [ ] [E*] 碎片 碎片优点实现简单不需要移动对象。在存活对象比例较低时效率较高少标记、多回收。缺点效率不稳定标记和清除都随堆增大而线性增长。内存碎片回收后产生大量不连续空间。后续分配大对象时可能找不到足够连续空间触发提前 GC。Mark-Sweep 是早期 JVM 的主流算法也是 CMSConcurrent Mark Sweep收集器的核心。CMS 之所以被淘汰碎片问题是重要原因之一——长期运行后需要 Full GC 来整理空间。演进标记-清除的对象头开销HotSpot 在对象头Mark Word中用一个 bit 标记对象的存活状态。标记阶段会修改对象头这个写入操作在并发收集器中需要写屏障支持是 CMS/G1 性能开销的来源之一。Copying复制算法复制算法的思路是用空间换时间将内存平分为两块每次只用其中一块GC 时把存活对象全部复制到另一块然后清空当前块。From 区: [A*] [B] [C*] [D] [E*] ↓ 复制 To 区: [A*] [C*] [E*] [ ... ] From 区清空角色互换优点无碎片复制后的对象紧密排列。分配快分配时只需移动指针bump pointer。当存活对象很少时复制效率非常高——只搬运少量存活对象即可完成回收。缺点空间利用率低可用内存直接减半这是无法接受的代价。存活对象多时复制开销大极端情况下所有对象都存活复制成本接近全堆扫描。HotSpot 的解决方案是Appel 式回收新生代并不按 1:1 划分而是Eden : Survivor0 : Survivor1 8 : 1 : 1每次只用 Eden 一个 Survivor回收时把存活对象复制到另一个 Survivor。这样浪费的空间只有 10%。|----- Eden (80%) -----|-- S0 (10%) --|-- S1 (10%) --| ↑ ↑ 使用中 空备份区这就是新生代采用复制算法的根本原因——新生代朝生夕灭的特性让复制成本极低。Survivor 区的作用Survivor 不是为了备份而存在它的本质是**“晋升缓冲区”**经历多次 Minor GC 仍存活的对象会被晋升到老年代。-XX:MaxTenuringThreshold默认 15控制晋升年龄阈值。# 调整晋升阈值java-XX:MaxTenuringThreshold10-cpMyApp com.example.MainMark-Compact标记-整理标记-整理算法结合了前两者的思路先标记存活对象同 Mark-Sweep然后将存活对象向一端移动清理边界以外的空间。标记前: [A*] [B] [C*] [D] [E*] 整理后: [A*] [C*] [E*] [------- 空 -------]优点无碎片移动后空间连续。空间利用率高不像复制算法那样浪费一半空间。缺点移动对象开销大移动后需要更新所有指向这些对象的引用写屏障或 STW 不可少。暂停时间更长相比 Mark-Sweep整理阶段更耗时。老年代通常采用 Mark-Compact因为老年代存活率高复制算法成本不可接受。但也要注意移动对象会导致更长的 STW所以有些收集器如 CMS选择不整理用 Mark-Sweep 换取更低的停顿。Generational分代收集分代收集不是一种独立的算法而是一种理论框架——它基于弱代假说Weak Generational Hypothesis绝大多数对象朝生夕灭。熬过越多次 GC 的对象越难以消亡。基于这两点观察HotSpot 把堆分为新生代Young Generation和老年代Old Generation针对不同代采用不同算法新生代存活率低用CopyingMinor GC。老年代存活率高用Mark-Sweep或Mark-CompactMajor GC / Full GC。|------------ 新生代 (1/3) ------------|-------- 老年代 (2/3) --------| | Eden | S0 | S1 | | | | 复制算法 Mark-Compact / Mark-SweepMinor GC 与 Full GCMinor GC / Young GC只回收新生代频率高、停顿短。Major GC回收老年代常与 Minor GC 联动。Full GC回收整个堆及元空间停顿最长应尽量避免。工程上 GC 调优的核心目标往往是减少 Full GC 频率——通过合理设置新生代/老年代比例、晋升阈值、 Survivor 大小让短命对象在新生代就被消化掉不进入老年代污染老年代。跨代引用与卡表分代收集有一个绕不开的问题跨代引用。新生代对象可能被老年代对象引用反之亦然。Minor GC 时如果只扫新生代根就会漏掉老年代指向新生代的引用导致误回收。简单方案的代价一种朴素做法是Minor GC 时把整个老年代当作额外 GC Roots 来扫描。但老年代通常很大全扫一遍成本不可接受。卡表Card TableHotSpot 采用卡表解决这个问题把老年代划分为固定大小的卡片Card通常是 512 字节每张卡对应卡表中的一个字节。当老年代对象写入一个指向新生代的引用时通过写屏障把对应卡标记为脏dirty。老年代: |---卡0---|---卡1---|---卡2---|---卡3---| 卡表: 0 1 0 0 dirtyMinor GC 时只扫描脏卡对应的老年代区域把这些对象作为附加 GC Roots。这样把全扫老年代降为只扫脏卡极大降低了跨代引用的扫描成本。写屏障的开销写屏障write barrier是 JVM 在每次引用写入时插入的额外逻辑。HotSpot 使用精化卡表Card Table Refinement写屏障只做置脏这一最便宜的操作扫描交给 GC 线程异步精化。这种设计在吞吐量与延迟之间取得了平衡。G1 进一步引入Remembered SetRSet每个 region 维护指向自己的卡表集合实现谁指向我的反向索引。ZGC 和 Shenandoah 则通过染色指针与并发整理规避了卡表的部分开销。其他记忆集实现除卡表外记忆集还有几种实现字节数组HotSpot 卡表每 512B 一字节。位图每个对象对应一个 bit更精确但更新成本高。对象数组精确记录引用对象内存占用大。卡表是精度与成本的折中选择也是 HotSpot 长期沿用的方案。各算法优缺点对比算法是否移动碎片空间开销停顿时间适用场景Mark-Sweep否有无额外开销中等老年代、低延迟场景CMSCopying是无浪费一半或 10%短存活少时新生代、Survivor 区Mark-Compact是无无额外开销长老年代、吞吐量优先Generational视代而定视代而定综合可控综合较优主流 JVM 默认策略选择依据可以归纳为两条主线吞吐量优先选择 Mark-Compact停顿长但空间利用率高。延迟优先选择 Mark-Sweep 或复制算法停顿短但有碎片或空间浪费。现代收集器G1、ZGC、Shenandoah通过分区Region并发整理打破了必须二选一的困局但底层仍以这四种算法为根基。代码示例观察不同算法的行为/** * 演示分代 GC 下不同对象的生命周期 * 适用 JDK 8/11/17 * * 运行参数 * java -Xms20m -Xmx20m -Xmn10m * -XX:SurvivorRatio8 -XX:UseSerialGC * -Xlog:gc* -cp MyApp GcAlgorithmDemo */publicclassGcAlgorithmDemo{privatestaticfinalint_1MB1024*1024;publicstaticvoidmain(String[]args)throwsException{// 短命对象新生代中即被回收for(inti0;i100;i){allocateTransient();}// 长命对象会晋升到老年代byte[]longLivednewbyte[4*_1MB];// 再分配一批触发 Minor GCfor(inti0;i5;i){byte[]tmpnewbyte[2*_1MB];Thread.sleep(200);}}staticvoidallocateTransient(){byte[]tmpnewbyte[512*1024];// 512KB// 方法返回tmp 失去引用下次 Minor GC 即被回收}}JDK 11 的 GC 日志java-Xlog:gc*info-cpMyApp GcAlgorithmDemo输出中可以看到GC(0) Pause Young (Allocation Failure)—— 新生代分配失败触发 Minor GC使用复制算法。后续Pause Full表示晋升失败或老年代空间不足触发 Full GC使用 Mark-Compact。通过对比-XX:UseSerialGCMark-Compact 老年代和-XX:UseConcMarkSweepGCMark-Sweep 老年代的日志可以直观感受两种算法在停顿时间和碎片表现上的差异。实践要点1. 不要盲目扩大新生代新生代过大会让 Minor GC 周期变长但单次停顿增加新生代过小则 Minor GC 频繁、晋升压力大。一般建议新生代占堆的 1/3 ~ 1/2。2. Survivor 不能太小Survivor 太小会导致存活对象放不下直接晋升老年代造成过早晋升。监控-XX:PrintAdaptiveSizePolicyJDK 8或-Xlog:gcergoJDK 11可看到 JVM 动态调整 Survivor 大小的过程。3. CMS 的碎片代价CMS 采用 Mark-Sweep长期运行后碎片会触发并发模式失败Concurrent Mode Failure退化为 Serial Old 的 Mark-Compact Full GC停顿可能达到秒级。这是 CMS 被 G1 取代的关键原因。4. G1 的混合回收本质G1 的 Mixed GC 既回收 Young Region 又回收部分 Old Region相当于把分代收集与分区结合。理解了 Copying 卡表再看 G1 会顺理成章。5. 监控对象晋升速率通过jstat -gc pid 1s观察老年代使用量增长速率。如果增长过快说明短命对象逃逸到了老年代应排查 Survivor 配置或大对象阈值-XX:PretenureSizeThreshold。小结Mark-Sweep实现简单但产生碎片适合低延迟场景。Copying无碎片、分配快但浪费空间适合新生代。Mark-Compact无碎片、空间利用率高但停顿长适合老年代。Generational基于弱代假说分而治之是主流 JVM 的基础框架。卡表通过写屏障记录跨代引用把全扫老年代降为只扫脏卡是分代收集的关键支撑。下一篇我们将进入具体的收集器实现先看最早出现、也最简单的两个Serial 与 ParNew。更多内容JVM调优实战