JVM 知识--垃圾收集器

本文最后更新于:1 个月前

基于分代收集理论

新生代收集器 (Minor GC)

Serial

  1. 标记-复制算法

  2. 单线程: 不仅仅是只有一条收集线程,更是进行垃圾收集时必须暂停其他工作线程的工作

  3. 有 “STOP THE WORLD”,降低用户体验

  4. 优点是: 简单高效,仍然是 HotSpot 虚拟机客户端模式下的默认新生代收集器

    因为它是所有收集器里额外内存消耗最小的,适用于内存资源受限的场景

    由于没用线程交互的开销,可以获得最高的单线程收集效率

  5. 推荐搭配 Serial Old 使用

ParNew

  1. 标记-复制算法

  2. ParNew 实质上是 Serial 收集器的多线程版本

  3. ParNew 是 HotSpot 虚拟机服务端模式下的默认新生代收集器

  4. ParNew 在单线程的环境下绝对不会有比 Serial 收集器更好的效果

  5. ParNew 最先退出历史舞台,只能和 CMS 一起使用

Parallel Scavenge

  1. 标记-复制算法

  2. 多线程,能够并行收集

  3. 特点是保证一个可控的吞吐量[1],而 CMS 保证的是一个缩短用户线程的停顿时间

  4. 也被成为"吞吐量优先收集器"

老年代收集器 (Major GC)

Serial Old

  1. 标记-整理算法

  2. 单线程

  3. 默认是 HotSpot 虚拟机客户端模式下默认的老年代收集器

  4. 如果在服务端下,有两种用途:

    1. JDK 5 以前,与 Parallel Scavenge 搭配使用

    2. 另一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集时发生 Concurrent Mode Failure 时使用

Parallel Old

  1. Parallel Scavenge 的老年代版本

  2. 标记-整理算法

  3. 多线程

CMS (Concurrent Mark Sweep)

  1. Old GC, CMS 独有

  2. 标记-清除算法

  3. 以获取最短回收停顿时间为目标

  4. 运作过程更复杂

    1. 初始标记

      仅仅标记 GC Roots 能够关联到的对象,速度快

    2. 并发标记

      从 GC Roots 的直接关联对象开始遍历整个对象图,耗时长但是不用停顿用户线程

    3. 重新标记

      修正并发标记期间,因用户线程继续运作而导致标记变动的一部分对象的标记记录,停顿时间比初始标记时间稍长

    4. 并发清除

      清理删除掉已经标记死亡的对象

    初始标记和重新标记两个过程仍然需要 “Stop The World”

  5. 缺点:

    1. 面向并发的程序对处理器资源比较敏感

      虽然并发阶段不会导致用户线程停顿,但是由于占用了一部分资源,会导致应用程序变慢,降低总吞吐量

    2. 无法处理"浮动垃圾"

      浮动垃圾是由于在并发清除阶段,用户线程继续产生的垃圾对象,而此次垃圾收集不能回收他们,只能等到下一次再清除

      而且需要留足够的内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样,等老年代几乎完全满了再进行收集

      如果预留内存无法满足程序分配新对象的需要,有可能出现 “Concurrent Mode Failure” 失败,此时虚拟机就会启动预备方案,临时启用 Serial Old 收集器执行 GC,但这样停顿时间就很长了

    3. 标记-清除产生的大量空间碎片

      无法找到足够大的连续空间分配大对象,而不得不产生另一次完全 “Stop the World” 的 Full GC

其他

G1 (Garbage First)

  1. Mixed GC

  2. 被 Oracle 称为"全功能的垃圾收集器"

  3. 不再分代收集,而是面向整个堆的任何部分组成回收集 Set 进行回收,同时也是面向局部收集

    注意,收集范围还是整个堆,但将堆划分为每一个 Region 看做是一个局部,每个 Region 代表不同的代,具体是哪个代是动态的,而不是像以前那样的要么是新生代,要么是老年代,要么是整个堆

    即以前都是什么区域决定什么对象,现在是对象决定区域

  4. 是否回收衡量标准不是代数,而是哪块内存的多少,是回收的经验时间,即回收的收益大小

    通过维护这样一个优先级列表,每次回收价值更大的对象

  5. 每一个 Region 可以扮演, Eden, Survivor, Major 或者 Humongous 区域

  6. 运作过程

    1. 初始标记

      仅仅标记 GC Roots 能直接关联的对象,暂停用户线程,但耗时较短

    2. 并发标记

      唯一不用暂停用户线程的阶段

      从 GC Roots 开始对堆中的对象进行可达性分析,递归扫描整个对象的对象图

    3. 最终标记

      短暂暂停用户线程,用于处理并发标记时有引用变动的对象

    4. 筛选回收

      更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序

      根据用户期望的停顿时间来制定回收计划,可以把一部分 Region 的存活对象复制到空的 Region 当中,再清理旧 Region

      并且,由于是按照 Region 清理,G1 不会产生内存碎片!

  7. 里程碑:通过追求能够应付应用的内存分配速率

    不追求一次把整个 Java 堆全部清理干净,而只要保证收集的速度能跟得上对象分配的速度就能工作的很完美

  8. 特点总结:

    1. 不分代

    2. 建立可预测的时间停顿模型,可以指定最大停顿时间

    3. 分 Region 的布局,不会产生空间碎片

    4. 按照收益回收的红利

  9. 缺点:

    1. 内存占用大

      和 CMS 一样,使用卡表来处理跨区指针,但是比 CMS 更加复杂,占用堆的20%空间甚至更多

    2. 执行负载

      G1 除了使用写屏障来更新卡表之外,还为了实现原始快照搜索(STAB)算法,用写屏障来跟踪并发时的指针变化情况,导致用户线程在执行阶段有额外的负担

低延迟垃圾收集器

几乎整个工作过程都是并发的,只有在初始标记,最终标记阶段有短暂的停顿,且停顿时间基本固定

Shenandoah

  1. 和 G1 有很多相似的地方

    内存布局 初始标记,并发标记的思路 …

  2. G1 就是由于合并了 Shenandoah 的代码才能获得多线程 Full GC 的支持

    G1 修改的代码也会反映到 Shenandoah 上面

  3. 相比 G1 的改进

    1. G1 的筛选回收阶段是多线程并行但不是并发的,而 Shenandoah 可以

    2. 默认不使用分代收集

    3. G1 中的记忆集变成了连接矩阵的全局数据结构 —— 二维表,减少维护消耗

  4. 运作过程

    1. 初始标记

    2. 并发标记

    3. 最终标记

    4. 并发清理

      清理那些整个 Region 连一个存活对象都没有的

    5. 并发回收 (核心)

      先把回收集里面的存活对象复制到未被使用的 Region

      但是这个复制对象的过程是在并发时完成的, 就可能会出现一边复制,一边读的问题

      而它是通过读屏障和被称为 Brooks Pointers 的转发指针来解决的

    6. 初始引用更新

      把所有指向旧对象的引用修正到复制到的新地址

      只是建议一个线程集合点,会产生短暂的暂停

    7. 并发引用更新

      真正开始引用更新,不再需要沿着对象图进行搜索,只需要按照内存物理地址的顺序,顺序搜索出引用类型

    8. 最终引用更新

      修正 GC Roots 中的引用

    9. 并发清理

      经过并发回收和引用更新之后,整个回收集中所有的 Region 已再无存活对象, 最后再调用一次并发清理过程回收 Region 的空间

    • 关于 Brooks Pointers

      其实原来的解决方案是设置自陷陷阱,主动触发异常,再进入预设好的异常处理器中,再由代码逻辑吧访问转发到复制后的对象上.但是如果没有操作系统的支持,会导致用户态频繁转到内核态,代价很大

      而在原有对象布局结构的最前面统一增加一个新的引用字段,在不处于并发移动的i情况下,该引用指向自己

      这样,当对象有了一份新的副本时,只需要就对象指针的值,指向新对象.只要就对象的内存依然存在,虚拟机内存中所有通过旧引用地址访问代码便仍然可用,都会被转发到新对象上继续工作

      • 但是请注意,这个设计导致了必然会出现多线程竞争问题

        例如当复制对象完成后,用户修改了旧对象,收集器线程才刚刚更新指针

        所以,对指针转发的操作必须采用同步措施,在统一时间内只能让用户线程或者收集器线程其中之一访问

      • 还有一点是执行频率问题

        在面向对象编程的语言中,对象访问是非常重要的,非常频繁的,而 Shenandoah 同时设置读,写屏障,带来了数量庞大的性能开销

ZGC

  1. 染色指针技术,将指针的高四位提取出来存储四个标志信息

  2. 运作过程

    ZGC 的四个大阶段都是可以并发执行的,仅仅两个阶段中间会有短暂的停顿用户线程

    1. 并发标记

      和 G1, Shenandoah 类似,只不过标记是在指针上而不是在对象上进行的

    2. 并发预备重分配

      根据特定的查询条件统计要清理的 Region, 将这些 Region 重新组成分配集,准备复制到其他的 Region 中

    3. 并发重分配 (核心)

      把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表,记录从旧对象到新对象的转发关系

      如果用户线程此时访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录,将访问转发到新复制的对象,这种行为成为指针的 “自愈”

      相比 Shenandoah 的转发指针,每次都会有转发的固定开销,ZGC 运行时的负载比 Shenandoah 要低一些

    4. 并发重映射

      修正整个堆中只想重分配集中旧对象的所有引用,类似 Shenandoah 的并发引用更新

      但是 ZGC 这个任务并不是那么迫切的,因为即使是旧引用,也是可以自愈的,也不过是一次转发和修正

      因此,ZGC 把这个阶段的工作合并到了下一次垃圾收集的并发标记阶段,节省了一次遍历所有对象图的开销

  • 优点

    由于 ZGC 完全没有记忆集,甚至没有分代,所以给用户线程的运行负担小很多

  • 缺点

    1. ZGC 对整个堆回收的速度较慢,但是由于对象的分配速率很高,并且很多朝生夕灭,但是在此时又被当作是存活对象,没有标记回收,导致产生了很多浮动垃圾

      因为较长的回收时间,和高速产生的对象,并且这个过程如果持续维持的话,堆中能够用于腾挪的空间会越来越小

      目前解决办法是尽可能的增加堆的大小,但是要本质上解决,还是必须引入分代收集理论,将对象都在一个专门的区域创建,然后专门对这个区域进行更快,更频繁的收集

Epsilon

不能够进行垃圾收集

垃圾收集器的范围

  • Partial GC

    • Minor GC / Young GC

      仅仅是收集新生代

    • Old GC (CMS 独有模式)

      收集老年代

    • Major GC

      同 Old GC,收集老年代

    • Mixed GC (G1 独有模式)

      目标是收集整个新生代和老年代

  • Full GC / Major GC

    收集整个 Java 堆和方法区

注意:

Major GC 有不同语义,要弄清楚到底是指与 Full GC 同样的整个堆和方法区的收集,还是值与 Old GC 类似的仅仅老年代堆的收集

并发和并行

  1. 并行 (Parallel): 多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认用户线程此时处于等待状态

  2. 并发 (Concurrent): 描述的是垃圾收集器线程和用户线程之间的关系,说明同一时间垃圾收集器线程和用户线程都在运行,但由于垃圾收集器线程占用了一部分系统资源,应用程序虽然还行响应,但是吞吐量会受到一定影响


  1. 吞吐量=(运行用户代码时间)/(运行用户代码+_垃圾收集的时间) ↩︎