JVM(8)——详解分代收集算法
JVM 的分代收集算法不是一种具体的垃圾收集算法实现,而是一种指导思想和设计原则,是现代 JVM 垃圾收集器的基石。其核心思想源于对程序运行过程中对象生命周期分布的观察(即弱分代假说)。
核心思想与理论基础:分代假说
JVM 的分代收集算法建立在两个关键的经验性观察(假说)之上:
-
弱分代假说:
-
核心内容: 绝大多数对象都是“朝生夕死”的。它们在分配出来后很快就变得不可达,成为垃圾。
-
证据: 大量的性能分析表明,在大多数应用程序中,超过 98% 的对象在第一次垃圾收集(Minor GC)时就被回收了。
-
推论: 应该将精力集中在回收那些新创建的、很可能很快消亡的对象上。
-
-
强分代假说:
-
核心内容: 熬过越多次垃圾收集过程的对象(存活时间越长),就越难以消亡(在未来也越不容易变成垃圾)。
-
证据: 一些对象(如缓存、静态变量引用的对象、Spring Bean 容器中的单例等)会贯穿应用程序的整个生命周期。
-
推论: 不应该频繁地去扫描那些“老顽固”对象,避免做无用功。
-
-
跨代引用假说(隐含):
-
核心内容: 跨代引用(老年代对象引用新生代对象)相对于同代引用来说非常少。
-
重要性: 这个假说非常关键,它保证了我们可以相对独立地收集新生代,而不必每次都扫描整个老年代来确认新生代对象的可达性。如果跨代引用非常普遍,分代收集的优势就会大打折扣。
-
解决方案: JVM 使用 记忆集 和 卡表 来高效地处理少量的跨代引用。
-
分代收集的核心目标: 基于以上假说,将堆内存划分为不同的“代”,并针对不同代中对象的特性(存活率、生命周期)采用最适合、最高效的垃圾收集算法,从而最大化垃圾回收的效率和性能,最小化停顿时间(STW)。
JVM 堆内存的分代划分
现代 JVM(HotSpot 为主)通常将堆划分为两个物理上或逻辑上的主要代:
-
新生代:
-
对象特点: 新创建的对象绝大部分都在这里分配。对象生命周期极短,死亡率非常高(98%以上)。
-
设计目标: 快速回收大量垃圾对象,追求高回收效率和短停顿时间。
-
内部结构(进一步优化): 为了更高效地管理,新生代通常又被划分为三个区域:
-
Eden 区: 新对象诞生的地方。当 Eden 区满时,触发 Minor GC。
-
Survivor 区 (From): 存放上一次 Minor GC 后存活下来的年轻对象。通常有两个 Survivor 区(S0 和 S1)。
-
Survivor 区 (To): 在 Minor GC 期间,作为存活对象复制的目的地。S0 和 S1 的角色(From/To)在每次 Minor GC 后会互换。
-
-
默认比例:
-XX:NewRatio
可以设置老年代/新生代的比例(默认值 2,表示老年代是新生代的 2 倍)。新生代内部-XX:SurvivorRatio
设置 Eden 区与一个 Survivor 区的比例(默认 8,表示 Eden:S0:S1 = 8:1:1)。 -
GC 类型: Minor GC 或 Young GC。只收集新生代(Eden + From Survivor)。
-
回收算法: 复制算法(或其高度优化的 Appel 式变种)。原因:
-
新生代对象死亡率极高,存活对象少,复制开销小。
-
复制算法效率高(只处理存活对象)、STW 时间短。
-
复制算法天然能解决内存碎片问题,使得新生代对象分配(指针碰撞)非常快。
-
-
-
老年代:
-
对象特点: 存放生命周期较长的对象。
-
在新生代中经历多次 Minor GC(默认 15 次,通过
-XX:MaxTenuringThreshold
设置)仍然存活的对象,会被晋升到老年代。 -
大对象(
-XX:PretenureSizeThreshold
设置阈值)可能直接在老年代分配,避免在新生代中反复复制。
-
-
设计目标: 存放“老年”对象,这些对象存活率高,回收频率相对较低。更关注空间利用率和避免内存碎片。
-
GC 类型:
-
Major GC: 通常指只清理老年代的 GC(但定义有时不统一)。
-
Full GC: 清理整个堆,包括新生代、老年代,通常还会包括方法区(元空间)。触发条件更严格(老年代空间不足、方法区空间不足、显式调用
System.gc()
等),停顿时间通常很长。
-
-
回收算法: 标记-清除 或 标记-整理 算法或其变种/组合。
-
标记-清除: CMS 收集器的老年代回收主要阶段使用并发标记清除。优点是并发执行,停顿时间短;缺点是产生内存碎片。
-
标记-整理: Serial Old, Parallel Old, G1, ZGC, Shenandoah 的老年代/整堆回收本质上都是标记-整理(或基于移动/复制的整理)。优点是解决碎片问题,空间利用率高,分配快;缺点是 STW 时间可能较长(现代收集器通过并发技术极大优化了这点)。
-
-
-
方法区:
-
特点: 存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。回收目标主要是废弃的常量和不再使用的类型(类卸载)。
-
回收条件苛刻: 需要满足类加载器被回收、类的所有实例被回收、该类的 Class 对象没有被引用等条件。回收效率低,通常不频繁。
-
GC 类型: 一般发生在 Full GC 中。
-
分代收集算法的工作流程(以 Minor GC 和对象晋升为例)
-
对象分配:
-
绝大多数新对象在 Eden 区分配。
-
如果对象非常大(超过
-XX:PretenureSizeThreshold
),可能直接在老年代分配(避免在新生代中大量复制)。
-
-
触发 Minor GC:
-
当 Eden 区空间不足时,JVM 触发一次 Minor GC。
-
-
Minor GC 过程:
-
暂停应用线程 (STW): 开始垃圾回收。
-
可达性分析: 从 GC Roots 出发,扫描 Eden 区和当前的 From Survivor区,标记所有可达对象为存活。
-
处理跨代引用: 通过卡表快速找到老年代中可能存在指向新生代对象的引用(脏卡),将其加入 GC Roots 进行扫描。
-
-
复制存活对象:
-
将 Eden 区和 From Survivor 区中所有存活的对象,复制到当前的 To Survivor区。
-
在复制过程中:
-
对象的年龄增加 1。
-
对象被紧密排列在 To Survivor 区的起始位置。
-
-
如果 To Survivor 区空间不足以容纳所有存活对象,或者某个对象的年龄达到了
-XX:MaxTenuringThreshold
,则该对象会被直接晋升到老年代。 -
如果存活对象非常大,或者老年代空间也不足,可能会触发更复杂的处理(如提前晋升、担保失败导致 Full GC)。
-
-
清理与交换:
-
完全清空 Eden 区和当前的 From Survivor区。
-
交换 From Survivor 和 To Survivor 的角色。原来的 To Survivor 区(现在存放着存活对象)成为新的 From Survivor 区。原来的 From Survivor 区(已被清空)成为新的 To Survivor 区。
-
-
恢复应用线程: Minor GC 结束,应用线程恢复运行。
-
新对象分配: 新对象继续在 Eden 区和新的 From Survivor 区分配。
-
-
触发 Full GC:
-
当老年代空间不足、方法区空间不足、或者某些特定条件(如
System.gc()
)被触发时,JVM 会进行 Full GC。 -
Full GC 会对整个堆(新生代、老年代)以及方法区进行垃圾回收,通常使用标记-清除-整理或标记-整理算法(取决于使用的收集器)。Full GC 的 STW 时间通常显著长于 Minor GC。
-
分代收集算法的优势
-
高效性:
-
针对新生代: 利用其高死亡率特性,使用高效的复制算法,只处理少量存活对象,回收速度快,STW 时间极短。
-
针对老年代: 减少扫描频率(存活率高),使用更注重空间管理的算法(标记-清除/整理),避免在长生命周期对象上浪费过多时间。
-
-
低延迟(Minor GC): 新生代的快速回收保证了应用程序大部分时间能快速响应。
-
高吞吐量: 通过减少不必要的扫描(老年代)和高效的回收(新生代),整体上提高了应用的吞吐量。
-
空间优化: 根据不同代的特点选择算法,如复制算法解决了新生代碎片问题,标记-整理解决了老年代碎片问题。
-
灵活性: 允许为不同的代选择不同的垃圾收集器组合(如 ParNew + CMS, Parallel Scavenge + Parallel Old, G1, ZGC 等),以适配不同的应用场景(吞吐量优先 or 低延迟优先)。
分代收集算法的挑战与解决方案
-
跨代引用问题:
-
挑战: 老年代对象可能引用新生代对象。在只收集新生代(Minor GC)时,如果仅扫描新生代的 GC Roots,可能会遗漏这些被老年代引用的新生代对象(它们应该是存活的),导致错误回收。
-
解决方案:记忆集与卡表
-
记忆集: 一种抽象数据结构,用于记录从非收集区域(老年代)指向收集区域(新生代)的所有引用。
-
卡表: 记忆集的一种具体、高效的实现方式。
-
将老年代内存划分为固定大小的块(如 512 字节),称为 卡页。
-
用一个字节数组(卡表)来标记这些卡页。如果某个卡页内的对象存在指向新生代对象的引用,则标记该卡页为 脏卡。
-
在 Minor GC 时,除了扫描新生代自身的 GC Roots,还需要扫描卡表中标记为脏的卡页(即老年代中可能存在指向新生代引用的区域),将这些引用加入到 Minor GC 的 GC Roots 中。这大大减少了需要扫描的老年代区域。
-
-
-
-
对象晋升策略:
-
挑战: 如何决定何时将对象从新生代移动到老年代?过早晋升浪费老年代空间(存放了本可以快速消亡的对象),过晚晋升可能导致 Survivor 区溢出或增加复制开销。
-
解决方案:
-
年龄阈值:
-XX:MaxTenuringThreshold
设置对象在新生代经历多少次 GC 后晋升(默认 15)。 -
动态年龄判定: 如果 Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象就可以直接进入老年代。无需等到
MaxTenuringThreshold
。 -
空间担保: 在 Minor GC 之前,JVM 会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,则尝试 Minor GC(有风险);如果小于,或者设置
-XX:HandlePromotionFailure
为允许担保失败(老版本),则检查是否允许担保失败。如果不允许或判断失败风险大,则改为进行一次 Full GC。这是一种避免 Minor GC 后老年代空间不足导致 OOM 的预防机制。
-
-
现代收集器与分代
虽然分代思想是主流,但现代超低延迟收集器在实现上有所演变:
-
G1: 物理上不再严格划分连续的新生代和老年代区域,而是将堆划分为多个大小相等的 Region。每个 Region 可以动态地扮演 Eden、Survivor 或 Old 角色。但在逻辑上仍然遵循分代假说,收集时会优先收集那些包含最多垃圾的 Region(通常是 Eden 和 Survivor Region),并管理对象的年龄和晋升。它结合了分代和分区收集的思想。
-
ZGC / Shenandoah: 物理上也不分代(或可选分代),它们通过着色指针、读屏障等先进技术,实现全堆并发标记和并发移动/整理,目标是将 STW 停顿时间控制在 10ms 以内。它们通过其他机制(如优先回收最近分配的对象)来利用对象“朝生夕死”的特性,虽然没有物理分代,但思想上仍然吸收了分代假说的精髓。在较新版本中(如 ZGC for JDK 16+),也开始支持分代模式。
总结
JVM 的分代收集算法是一种基于对象生命周期分布规律(弱分代假说、强分代假说)的垃圾回收策略框架。其核心在于:
-
划分代际: 将堆划分为新生代(对象死亡率高)和老年代(对象存活率高)。
-
对症下药:
-
新生代: 采用高效的复制算法(Minor GC),追求高回收速率和超短 STW 停顿。
-
老年代: 采用标记-清除或标记-整理算法(Major GC / Full GC),更注重空间利用率和避免碎片。
-
-
解决关键问题: 通过记忆集/卡表高效处理跨代引用;通过年龄阈值、动态判定、空间担保等策略管理对象晋升。
-
核心价值: 极大地提高了垃圾回收的效率和针对性,显著减少了大多数情况(Minor GC)下的停顿时间,是 JVM 能够高效管理内存的关键设计。