JVM的GC触发条件与堆分区对象变化

总结摘要
JVM的GC触发条件与堆分区对象变化

在JVM中,GC(垃圾回收)的触发时机和对象变化取决于具体的垃圾回收器实现,但我们可以从分代收集理论的角度来理解通用的规则。

分代收集理论

一、GC的触发时机

JVM的GC主要分为两种:Minor GC(小型GC)Full GC(完全GC)(或称Major GC)。

1. Minor GC 的触发条件

  • 触发场景:当新生代(Young Generation)的Eden区(伊甸园区)被占满,无法为新对象分配内存时。
  • 特点:通常发生得非常频繁,回收速度也很快。

2. Full GC 的触发条件

Full GC涉及对整个堆(包括新生代、老年代和方法区/元空间)的回收,触发条件相对复杂:

  • 老年代空间不足
    • 对象从新生代升入老年代时,发现老年代剩余空间不足以容纳这些对象。
    • 大对象(如很长的数组)直接在老年代分配时,老年代空间不足。
  • 空间分配担保失败
    • Minor GC后,存活对象大小超过了幸存区(Survivor)的容量,需要进入老年代,但老年代无法容纳(或担保失败)。
  • 元空间(Metaspace)不足
    • 加载的类太多,或者使用了大量的动态代理/CGLIB,导致元空间内存耗尽。
  • 显式调用
    • 在代码中调用 System.gc()(除非 JVM 显式设置了 -XX:+DisableExplicitGC 来禁止)。
  • JVM工具或JMX触发
    • 通过JMX等管理接口触发的GC。

二、GC发生时,堆内存分区中的实例对象变化

堆内存主要分为:新生代(Eden、Survivor From、Survivor To)和老年代(Old Generation)。

1. Minor GC 发生时的变化(以复制算法为主)

假设初始状态:

  • Eden区:存满了对象(包括存活的和垃圾)。
  • Survivor From:存有上一轮存活下来的对象。
  • Survivor To:为空。

回收过程与对象变化:

  1. 标记:扫描Eden区和Survivor From区,标记出哪些对象是存活的。
  2. 复制与年龄增加
    • Eden区中的存活对象,以及Survivor From区中的存活对象,会被复制到空的 Survivor To 区。
    • 每复制一次,这些对象的年龄计数器就增加1。
    • 规则:如果对象的年龄达到了阈值(例如15,取决于JVM参数),则直接晋升(Promotion)到老年代,而不是复制到Survivor To。
  3. 清理:直接清空Eden区和Survivor From区(此时里面全是垃圾对象)。
  4. 角色互换:Survivor From 和 Survivor To 交换指针(下次GC时,To区变为From区,空的区域成为新的To区)。

结果

  • 垃圾对象:被彻底回收,内存被释放。
  • 存活对象:移动到了 Survivor To 或 老年代。

2. Full GC 发生时的变化(以标记-清除-压缩/复制算法为例)

Full GC通常会回收整个堆(新生代+老年代),甚至包括元空间。

回收过程与对象变化:

  1. 初始标记:暂停所有用户线程(Stop The World),标记出GC Roots直接关联的对象。
  2. 并发标记:从GC Roots开始,遍历整个对象图,标记所有存活对象(这一般是并发执行的)。
  3. 重新标记:修正并发标记期间因用户线程继续运行而产生变动的标记记录。
  4. 清除与压缩
    • 新生代:依然采用复制算法,清空Eden和Survivor。
    • 老年代:由于老年代空间较大,通常采用标记-清除-压缩算法。
      • 标记:标记存活对象。
      • 清除:回收垃圾对象占用的内存。
      • 压缩:将所有存活对象向内存空间的一端移动,消除内存碎片。

结果

  • 垃圾对象:被完全清理。
  • 存活对象:被移动到堆内存的起始位置(压缩后),使得剩余空闲内存是连续的一大块。

三、对象变化的核心规则总结

场景对象变化
刚创建的对象分配在 Eden 区
经历一次 Minor GC 后存活从 Eden 移动到 Survivor To(年龄+1)
多次 Minor GC 后存活在 Survivor 区之间来回移动(年龄持续增加)
年龄达到阈值 / 动态年龄判定从 Survivor 晋升到 老年代
老年代空间不足触发 Full GC老年代存活对象被压缩整理,垃圾被清理
大对象直接分配直接进入 老年代

简单来说: GC的过程就像是一个“整理房间”的过程。新生代的GC像是一个**“分类垃圾桶”,先把有用的东西挑出来放到旁边的空桶(Survivor To),然后把原来的桶倒掉(清空Eden)。老年代的GC更像一个“仓库盘点”**,把没用的废品扔掉,然后把剩下的货物堆整齐(压缩),腾出空间。

主流垃圾收集器

上面描述的规则(新生代复制、老年代标记-整理)确实是基于分代收集理论通用设计思想。但在具体的垃圾收集器(Garbage Collector, GC)实现中,为了追求不同的目标(如低延迟、高吞吐量或大内存管理),这个理论的落地方式会有很大的差别,甚至有些收集器已经不完全遵循传统的分代物理划分。

主要的差别体现在内存布局回收算法以及回收阶段上。以下是主流垃圾收集器与标准分代理论的差异对比:

1. CMS(Concurrent Mark-Sweep)收集器

目标: 获取最短回收停顿时间(低延迟)。

  • 与理论的差异:
    • 算法不同: 标准理论中老年代通常使用标记-整理(压缩)算法,但CMS在老年代使用的是标记-清除算法
    • 后果: 由于不压缩,CMS 不会移动对象。这虽然减少了停顿时间,但会产生内存碎片
    • 触发机制变化: 由于内存碎片的存在,即使老年代总空间还很大,但如果没有连续空间容纳从新生代晋升的大对象,就会触发 Concurrent Mode Failure,导致退化为使用 Serial Old 收集器进行 Full GC(这时就会进行压缩整理,停顿时间极长)。

2. G1(Garbage First)收集器

目标: 在延迟可控的前提下实现高吞吐量,面向大堆内存。

  • 与理论的差异:
    • 物理分区消失: G1 不再严格坚持对象在物理上必须待在“新生代”或“老年代”。它将整个堆划分为多个大小相等的 Region(区域)
    • 逻辑分代仍在: 虽然物理上不分代,但每个 Region 会在逻辑上被标记为 E、S、O(Eden、Survivor、Old)。
    • 算法混合: G1 整体上属于 标记-整理 算法(从整体看是整理,局部看是复制)。
    • 回收范围变化: G1 引入了 Mixed GC(混合GC)。它不再只是单独回收新生代或全堆,而是在一次 GC 中既处理新生代,也处理部分老年代 Region(根据收集优先级,优先回收垃圾最多的 Region)。

3. ZGC(Z Garbage Collector)收集器

目标: 极低延迟(<10ms),支持超大堆内存(TB级),且停顿时间不随堆大小增加。

  • 与理论的差异:
    • 分代实验特性: 早期的 ZGC 是不分代的!它同时回收新生代和老年代的所有对象。这与最基础的分代理论完全相反。
    • 染色指针与读屏障: ZGC 通过指针技术实现并发整理。它在对象移动时,通过读屏障和染色指针技术,允许用户线程在读对象时如果发现对象被移动了,可以立即帮助修复引用(类似于“自愈”),而不用等待垃圾收集线程处理。
    • 内存布局: ZGC 也使用 Region(但称为 Page),但具有动态创建和销毁的特性,且 Region 大小可以动态变化(大、中、小三类)。

4. Epsilon 收集器

目标: 无操作收集器(主要用于性能测试、短生命周期的服务)。

  • 与理论的差异:
    • 根本不回收! 它处理内存分配,但当堆内存耗尽时,JVM 直接崩溃。这完全跳过了所有的 GC 理论和算法,用于在极端情况下测试应用本身的内存使用边界。

5. Shenandoah 收集器

目标: 与 ZGC 类似,极低延迟。

  • 与理论的差异:
    • 并发整理: 传统的理论中,对象移动(复制/整理)通常需要暂停用户线程。Shenandoah 与 ZGC 一样,实现了并发整理,让对象移动的阶段也能与用户线程一起运行。
    • 转发指针: 它通过在对象头中引入转发指针来实现,与 ZGC 的染色指针实现方式不同。

总结:理论 vs. 现实的差异点

为了让你更直观地理解,可以看看下面这个对比表格:

比较维度经典分代理论CMSG1ZGC (早期)
老年代算法标记-整理标记-清除标记-整理标记-整理
物理分代逻辑分代,物理不分通常不分代
内存布局连续 Eden/S0/S1/Old连续 Eden/S0/S1/OldRegion 化动态 Region
并发阶段较少并发标记并发标记并发标记+并发整理
主要缺陷无(基础模型)内存碎片、CPU高算法复杂、需预测内存占用略高

简单来说:

  • 如果你使用的是 Serial、ParNew、Parallel Scavenge,它们基本遵循你之前了解的那套经典分代理论。
  • 如果你使用的是 CMS,它打破了老年代必须整理(压缩)的理论。
  • 如果你使用的是 G1,它打破了堆必须物理分代的理论。
  • 如果你使用的是 ZGC,它甚至打破了必须分代(早期版本)以及必须暂停才能移动对象的理论。

这些实现上的差异,本质上都是为了在吞吐量延迟内存占用这三者之间寻找更适合特定场景的平衡点。