美文网首页Android开发经验谈Android开发Android技术知识
学习分析 JVM 中的对象与垃圾回收机制(下)

学习分析 JVM 中的对象与垃圾回收机制(下)

作者: __Y_Q | 来源:发表于2021-04-22 17:30 被阅读0次

    建议按照顺序阅读

    在上一章中学习了 JVM 中对象的创建及分配过程. 本章主要学习知识点如下

    • 常见垃圾回收算法
    • 三色标记法
    • 读写屏障的概念
    • 垃圾回收器的介绍

    1. 垃圾回收的基础算法

    1.1 Mark Sweep 标记 - 清除算法

    原理: 标记阶段会标记出需要回收的对象, 标记完成后统一回收所有被标记的对象

    GC Roots 开始, 将内存整个遍历一次, 保留所有可以被 GC Roots 直接或间接引用到的对象, 而剩下的对象都当做垃圾对待并回收, 整个过程分为两步.

    • 标记阶段
      找到内存中所有 GC Roots 对象, 只要是和 GC Roots对象直接或者间接相连的标记为存活对象, 否则标记为垃圾对象.

    • 清除阶段
      当遍历完所有的 GC Roots 之后, 则将标记为垃圾的对象直接清除.

      标记清理图解
    • 优点: 实现简单, 不需要将对象进行移动.

    • 缺点:

      • 效率不高: 标记和清除的两个过程效率都不高,
      • 空间问题: 标记清除后会参数大量不连续的内存碎片, 空间碎片太多可能会导致程序运行过程中在分配大对象时, 无法找到足够的连续内存不得不触发另一次的垃圾回收动作. 从而提高了垃圾回收的频率.
         
    1.2 Copying 复制算法

    由于标记清除算法的效率不高和内存碎片化问题, 复制 (Copying) 算法就出现了.

    原理: 将现有的内存空间分为两块, 每次只使用其中一块, 在垃圾回收时将存活的对象复制到未被使用的内存块中. 之后再清除正在使用的内存块中的所有对象.

    例如可以将内存划分为 A, B 两块, 当发生 GC 时, 会将 A 中存活对象复制到 B 中. 然后把 A 内存统一回收掉.

    复制算法图解
    • 优点: 效率比标记清除算法好, 也不会出现内存碎片的情况.
    • 缺点:
      • 内存利用率不够: 将内存平均分为 2 块, 可用的内存就变成了原来的一半.
      • 如果对象的存活率比较高的话, 复制的操作就会比较频繁.
         
    1.3 Mark Compact 标记 - 整理算法

    由于复制算法对于存活率高的对象进行垃圾回收需要频繁的进行复制操作, 而 标记-清除算法又会造成内存碎片化, 所以又有人提出了 标记-整理算法.

    原理: 需要先从根节点开始怼所有可达对象做一次标记, 之后它并不简单的清理未标记的对象, 而是将所有存活对象向另一端移动.顺序排放, 最后清理边界外所有的控件, 因此标记整理也是分为两个步骤

    标记整理算法图解
    • 优点: 避免了碎片的产生, 又不需要浪费内存. 因此,性价比比较高
    • 缺点: 所谓整理, 仍需要进行局部对象移动, 所以一定程度上还是降低了效率.

     

    1.4 小结

    垃圾回收基础算法是后面算法改进的基础. 下面对这几种算法做一个小结.

    算法 优点 缺点
    标记清除 算法简单, 高效, 无需移动对象 内存碎片化, 分配慢 (需要找到合适的空间)
    标记整理 堆的使用率高, 没有内存碎片. 需要对对象进行移动
    复制 分配效率高, 没有内存碎片 浪费内存空间

     

    2. 三色标记法

    2.1 什么是三色标记法

    无论是标记-清除, 还是标记-整理. 标记都是必要的一步, 首先需要标记出哪些是垃圾, 才能进行回收, 而标记也可以有很多种.

    现代使用可达性分析的垃圾回收器几乎都借鉴了三色标记的算法思想, 只是实现的方式不同.
    CMS 与 G1 采用的都是三色标记法. (CMS 与 G1 都是垃圾回收器, 见下文.)

    在上一章学习分析 JVM 中的对象与垃圾回收机制(上)中 对象的存活和引用 知道根据可达性分析从 GC Roots 开始进行遍历访问, 可达的则为存活对象, 而最终不可达的就是需要被回收的对象. 三色标记法大致流程就是把遍历对象图过程中遇到的对象按照 是否访问过 这个条件标记成下面三个颜色.

    • 白色: 尚未访问过
    • 黑色: 本对象已经访问过, 而且本对象引用到的其他对象也全部访问过了.
    • 灰色: 本对象已经访问过, 但是本对象引用到的其他对象尚未完全访问完, 全部访问后会变为黑色.
    三色标记法

    根据上图, 假设现在有白,灰,黑三个集合, 那么遍历访问的过程如下.

    1. 初始时所有的对象都在白色集合中
    2. 将 GC Roots 直接引用到的对象移动到灰色集合中.
    3. 从灰色集合中获取对象
      • 第一步将本对象引用到的其他对象移动到灰色集合中.
      • 第二步将本对象移动到黑色集合中.
    4. 重复步骤 3, 直至灰色集合为空时结束. 最后仍在白色集合中的对象即为不可达, 可以尝试进行回收.

    如果这整个过程都是 STW (Stop the world, 解释见下文)的话, 对象的引用关系是不会改变的, 意味着标记结果是正确的, 但是如果是并发进行标记, 与用户线程同时在运行, 那么对象间的引用可能就会发生变化, 多标和漏标的情况就有可能发生.

    并发标记: 进行标记的线程与用户线程同时运行.

    2.2 多标 (浮动垃圾)

    场景: 并发标记线程遍历到 E 了, 此时 E 是灰色, 用户程序线程执行了 D.E = null, 那么此时 E/F/G 应该是被回收的, 但是 GC 线程已经认为 E 是灰色了, 扔会被当做存活对象继续遍历下去, 那么最终结果就是这部分对象被标记为存活, 本轮 GC 不会回收这部分对象.

    这部分应该回收, 但是没有被回收的对象, 被称之为 "浮动垃圾". 浮动垃圾不会影响垃圾回收的正确性, 但是需要等待下一次GC 的时候, 才会被清除.

    另外, 在并发标记线程开始后的新建对象, 通常做法是直接全部标记为黑色, 本轮不会进行清除. 这部分对象期间有可能会变为垃圾, 这也算是浮动垃圾的一部分.


    D 到 E 的引用断开
    2.3 漏标

    场景: 并发标记线程已经遍历到 E, 此时 E 是灰色, 但是用户线程执行了下面的代码

    Object G = E.G;
    E.G  = null;
    D.G = G;
    

    也就是执行了将 E ->G 断开, D -> G 链接的操作. 此时切回 GC的并发标记线程继续跑, 发现 E 无法到达 G, 所以无法将 G 标记为灰色, 尽管 D 重新引用了 G, 但是因为 D 已经被标记为黑色了, 不会再重新做遍历的处理. 那么最终导致的结果就是 G 会一直停留在白色集合中, 最后被当做垃圾被清除掉. 这直接影响到了程序的正确性.


    E 到 G 断开, D 重新引用 G

    从上面可以看出漏标只有同时满足两个条件时才会发生.

    • 灰色对象断开了白色对象的引用. 即灰色对象原来的成员变量的引用发生了变化.
    • 黑色对象重新引用了该白色对象, 即黑色对象成员变量增加了新的引用.

    从代码的角度看, 是经历了三步

    Object G = E.G; //读
    E.G  = null; //写
    D.G = G;  //写
    

    是不是在上面三步中任意一步中做一些手段, 将 G 记录起来, 然后将它标记为灰色再进行遍历即可. 比如将它放到一个特定的集合, 等待并发标记执行完, 在重新标记阶段重新遍历即可. 于是又引出了读写屏障的概念.

     

    3. 读写屏障的概念

    3.1 写屏障

    所谓写屏障就是在写操作前后加入一些处理, 类似于 AOP. 写屏障分为下面两种类型

    • 写屏障 + SATB
      当对象 E 的成员变量的引用发生变化时, 也就是执行了E.G = null;这行代码时候, 可以利用写屏障, 将 E 原来成员变量的引用对象 G 记录下来, 那么记录下来的就叫原始快照 (Snapshot At The Beginning, 简称 SATB). 后续的标记也跟着 SATB 执行. SATB 破坏了漏标的条件一: 灰色对象断开了白色对象的引用, 从而保证了不会被漏标

    • 写屏障 + 增量更新
      当对象 D 的成员变量的引用发生变化时, 也就是执行了D.G = G 这行代码的时候, 可以利用写屏障将 D 新的成员变量引用对象 G 给记录下来. 针对新增的引用, 将其记录下来等待遍历, 称为增量更新.
      增量更新破坏了漏标的条件二: 黑色对象重新引用了该白色对象. 也保证了不会漏标

    3.2 读屏障

    读屏障是直接针对第一步 Object G = E.G, 当读取成员变量时, 一律记录下来.

    3.3 处理漏标的方案

    对于读写屏障,以Java HotSpot VM为例, 其并发标记时对漏标的处理方案如下.

    • CMS 回收器: 写屏障 + 增量更新.
    • G1    回收器: 写屏障 + SATB.
    • ZGC 回收器: 读屏障.

    关于CMS, G1 与 ZGC回收器见下文分析

     

    4. JVM 中常见的垃圾收集器

    现在开始学习一些垃圾回收器/垃圾收集器. 在学习垃圾收集器前, 先了解一下几个相关的术语.

    • STW - Stop the world
      在执行垃圾回收时, Java 应用程序的其他所有除了垃圾回收线程之外的线程都被挂起, 等待垃圾回收线程执行完毕后才能再次运行.

      STW 停顿时间越短就越适合需要与用户交互的程序, 良好的响应速度能提升用户体验.

    • Parallel
      并行, 指两个或多个事件在同一时刻发生. 在 JVM 垃圾回收器中的并行是指多个垃圾回收线程在操作系统上并行运行.

    • Concurrent
      并发. 指两个或多个事件在同一事件间隔内发生. 在 JVM 垃圾回收器中的并发指的是垃圾回收线程和 Java 程序线程并发运行.

    • Incremental
      垃圾回收器对堆的某部分(增量)进行回收, 而不是扫描整个堆

    • Throughput
      吞吐量. 即是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值. 即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间), 虚拟机总共运行了100分钟, 其中垃圾收集花掉1分钟, 那吞吐量就是99%.

      高吞吐量则可以高效率的利用 CPU 时间, 尽快完成程序的运算任务, 主要适合在后台运算而不需要太多交互的任务.

    下面将会学习 7 种垃圾回收器, 每个垃圾回收器的特点不同. 同时回收堆内存区域也不同, 有的是针对新生代的, 有的是针对老年代的.下面用一张从网上找的图来表示各个垃圾回收器作用在什么区域, 哪些是可以组合使用的.


    实线连接的表示可以进行组合收集, 间隔虚线连接的表示在 Java 9 中不能组合. 左下角虚线表示当 CMS 发生 CMS Concurrent mode failure 时可以使用 Serial Old 作为 CMS 的备用方案.

     

    4.1 Serial / Serial Old

    Serial (串行) 垃圾回收器针对新生代使用单线程进行垃圾回收, 在回收的时候需要暂停其他的工作线程, 使用了复制算法.

    Serial Old 垃圾回收器是 Serial 针对老年代的版本, 同样是单线程的, 使用了 标记-清除 算法

    Serial 与 Serial Old 垃圾回收器的线程交互图如下.


    可以通过 -XX:+UseSerialGC 来告诉虚拟机使用 Serial 收集器

     

    4.2 ParNew 回收器

    ParNew 回收器是 Serial 回收器的多线程版本, 除了使用多线程进行垃圾回收外, 其余行为包括 Serial 回收器可用的所有控制参数, 回收算法(复制算法), STW, 对象分配规则, 回收策略等与 Serial 完全相同. 两者共用了很多代码.

    ParNew 回收器 线程交互如下.

    可以通过 -XX:+UseParNewGC 来告诉虚拟机使用 ParNew 收集器。它默认开启的收集线程数与 CPU 的数量相同,也可以通过-XX:ParallerGCThreads 来设置 GC 线程数量

     

    4.3 Parallel Scavenge / Parallel Old 回收器

    Parallel Scavenge 回收器是一个并行的多线程针对新生代的回收器. 同样采用了复制算法. 它的关注点与其他回收器不同, 别的收集器关注点是尽可能缩短垃圾回收时用户线程停顿的时间, 也就是缩短 STW 的时间, 而 Parallel Scavenge 关注的是 Throughput 吞吐量.

    Parallel Old 回收器是 Parallel Scavenge 针对老年代回收的版本, 采用了 标记-整理 算法. 该回收器与 JDK 1.6 版本开始提供, 在此之前新生代的 Parallel Scavenge 只能和 Serial Old 进行搭配使用. 但是 Serial Old 回收器在服务端应用性能上表现不好, 所以才有了 Parallel Old 的出现. 在注重吞吐量和 CPU 资源敏感的场景, 都可以优先考虑使用 Parallel ScavengeParallel Old 回收器.

    通过 -XX:+UseParallelGC 来启用 Parallel Scavenge 回收器.
    通过 -XX:MaxGCPauseMillis 来设置吞吐量, 也可以直接设置吞吐量 -XX:GCTimeRatio, 值为 0 ~ 100.

    如果打开 -XX:+UseAdaptiveSizePolicy 开关, 就不需要通过 -Xmn手动指定新生代的大小, 以及 Eden 区与 from, to 区的比例 -XX:SurvivorRatio. 晋升老年代对象的年龄 -XX:PretenureSizeThreshold 等细节参数了. 虚拟机会根据当前系统的运行情况收集性能监控信息, 动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量, 这种方式称为 GC 自适应的调节策略(GC Ergonomics). 自适应策略也是 Parallel Scavenge 与 ParNew 回收器的一个重要区别

     

    4.4 CMS - Concurrent Mark Sweep 回收器

    CMS 回收器是一种以获取最短回收停顿时间为目标的收集器, 针对老年代. 基于 标记-清除 算法实现的, 是 JDK1.7 之前最主流的垃圾回收器. 可与 Serial 回收器或 Parallel New 回收器配合使用。

    CMS 运作过程相对于前面几种回收器来说相对更复杂一些, 整体过程分为 4 个阶段.

    • 初始标记
      短暂, 仅仅只是标记一下 GC Roots 能直接关联到的对象, 速度很快. 需要 STW.

    • 并发标记
      耗时最长, 进行 GC Roots 追踪的过程. 但是这个阶段是和用户应用程序线程同时进行的.

    • 重新标记
      短暂, 为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录, 这个阶段的停顿时间一般会比初始标记稍微长一点, 但是远比并发标记的时间短, 此阶段也需要 STW.

    • 并发清除
      清除那些被标记为可以回收的对象, 由于这一阶段用户程序也在运行, 这时候产生的浮动垃圾就不能被处理, 只能等下一次 GC 时再清理.

    虽然 CMS 很优秀, 支持并发, 低延迟. 但是也有几个明显的缺点. 其中就有多标和漏标.

    • 多标 (浮动垃圾)

    • 漏标 : CMS 解决漏标的方案在上面说过了是 写屏障 + 增量更新. 下面是具体的方案, 可做了解.

      我们知道在并发标记阶段, 是和用户程序线程一起运行的, 既然用户线程也在运行那么就有可能会触发新生代的垃圾回收, 即Minor GC / Young GC. 那么如果触发了新生代的垃圾回收后, 就会出现以下三种情况导致漏标.

      • 新生代对象晋升到老年代
      • 直接在老年代分配对象
      • 新生代与老年代的引用发生变化.

      CMS 使用了Cart Table 卡表来解决标记过程中对象的变化,

      什么是卡表
      将堆空间划分为一系列 2 次幂大小的卡页 Card Page, 而卡表用于标记卡页的状态, 每个卡表项都对应一个卡页.
      HotSpot 虚拟机的卡页大小为 512 字节, 卡表被实现为一个简单的字节数组, 即卡表的每个标记项为 1 个字节.
      当对一个对象引用进行写操作时(对象引用改变), 写屏障逻辑就会标记对象所在的卡页为 dirty, 即 "脏卡"

      使用卡表记录对象引用的变化还有一个好处就是, 在进行 Minor GC 的时候, 便可以不用扫描整个老年代, 而是在卡表中寻找脏卡, 并将脏卡中的对象加入到 Minor GC 的 GC Roots 中. 当完成所有脏卡的扫描后, JVM 便将所有脏卡的标志位清零.

      在 Minor GC 之前, 是无法确保脏卡中包含的是指向新生代对象的引用的, 这个和写屏障有关.
      当对一个对象引用进行写操作的时候(对象引用改变), 写屏障在进行标记脏卡的时候, 并不会判断更新后的引用是否指向新生代中的对象, 而是宁杀错, 不放过. 一律当成可能指向新生代对象的引用.
      由于 Minor GC 会伴随着将存活对象复制到 from 区或者 to 区, 而复制需要更新指向该对象的引用. 因此, 在更新引用的同时, 又会设置引用所在卡页的标志位, 这个时候, 就可以确保脏卡中必定包含指向新生代对象的引用

      回到上面说的漏标, 在并发标记阶段, D 和 E 的引用改变后, 就会被标记为脏卡,

      由于卡表只有一份, 既要支持新生代 GC 又要支持 CMS , 每次 Minor GC 过程中都会设计重置和重新扫描卡表, 这样是满足了 Minor GC 不扫描老年代的需求, 但却破坏了 CMS 的需求, 因为 CMS 需要的信息可能被新生代 GC 给重置掉了. 因此为了避免丢失信息, 就在卡表的基础上另外加了一个 bitmap 叫做 mod-union table. 在 CMS 并发标记的运行过程中, 每当发生 Minor GC 要重置卡表中的某个记录时, 就会更新 mod-union table 对应的bit. 这样最后到 CMS 重新标记的时候, 就足以记录在并发标记过程中老年代发生的所有引用变化了.

      在高并发情况下, 频繁的写屏障很容易发生徐共享, 从而带来性能上的开销. 这点就不再展开描述.

    • CPU 敏感
      因为是并发收集的所以会占用一部分的 CPU 资源, 虽然不会导致用户线程暂停, 但是会导致应用程序变慢, 总吞吐量降低.
      默认情况下, 开启的线程为 (CPU 的数量 + 3) / 4, 当 CPU 数量不足 4 个时 (比如 2 个), CMS 对用户程序的影响就可能变得很大, 如果本来 CPU 负载就较大, 还要分出一半的运算能力去执行回收线程, 就可能导致用户程序的执行速度忽然降低了 50%.

    • 产生大量的空间碎片
      由于 CMS 使用的是 标记-清除 算法所以会产生大量的空间碎片.

      当老年代空间碎片过多时, 就算可用空间大, 分配对象时如果找不到足够大的连续空间, 那么将不得不提前触发一次 Full GC, 所以, CMS 提供了 -XX:+UseCMSCompactAtFullCollection (默认为打开状态) 开关参数, 用于在 CMS 顶不住要进行 Full GC 时开启内存碎片合并整理过程, 这个过程是无法并发的, 那么停顿的时间就会变长.

      CMS 还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction, 设置在执行多少次 Full GC 后对内存空间进行整理. 我们知道内存压缩整理的过程是没法并发执行的, 所以难免要停顿, 如果 Full GC 频繁, 只调优这个参数的情况下, 那么就不能每次都整理内存空间, 不然积少成多, 停顿的时间也是很可观的, 此时就要调大这个参数, 让 CMS 在经过多次 Full GC 后再对内存空间进行整理. 而如果 Full GC 不频繁, 间隔时间较长, 就可以设置每次 Full GC 后都对内存空间进行整理, 影响也不大.

     
    上面说了 CMS 整体过程的 4 个阶段. 实际上按照 GC 日志中显示的, 可以细分为 7 个阶段.

    • 初始标记
      初始化标记阶段, 是 CMS GC 的第一个阶段, 也是标记阶段的开始, 遍历GC Roots下的新生代对象能够可达的老年代对象, 也就是跨代引用. 发生 STW, 暂停所有应用线程. 标记范围是老年代和新生代.

    • 并发标记
      该阶段 GC线程和应用线程并发执行, 也就是说, 在初始标记阶段被暂停的应用线程恢复运行. 并发标记阶段主要的工作是, 通过遍历第一阶段标记出来的存活对象, 继续递归遍历老年代, 并标记可直接或间接到达的所有老年代存活对象. 不会发生 STW.


      由于在并发标记阶段, 应用线程和 GC 线程是并发执行, 因此可能产生新的对象或对象关系发生变化. JVM 会将发生改变的区域标记为脏卡(Ditry Card), 同时在 Mod-Union Table 中记录.
    • 并发预清理
      在并发预清理阶段, 将会在上阶段记录在 Mod-Union Table 的这些脏卡会被找出来, 刷新引用关系, 然后清除 dirty 标记.

    • 可中断的预清理
      该阶段发生的前提是, 新生代 Eden 区的内存使用量大于参数 CMSScheduleRemarkEdenSizeThreshold (默认 2M) , 如果新生代的对象太少, 就没有必要执行此阶段, 直接执行重新标记阶段即可. 不触发 STW.
      为什么会需要这个阶段呢?
      因为 CMS GC 的终极目标就是为了降低垃圾回收时的暂停时间, 所以在该阶段要尽最大努力去处理那些在并发阶段被用户应用线程更新的老年代对象, 这样在要发送 STW 的重新标记阶段就可以少处理一些, 暂停时间也会相应降低.
      在该阶段主要循环做两件事情

      1. 处理 From 区和 to 区的对象, 标记可达的老年代对象.
      2. 和 并发预清理 阶段一样, 扫描处理 脏卡和 Mod-Union Table 中的对象

      不过当然不会一直循环下去, 打断这个循环的条件有 3 个

      1. 可以设置最多循环的次数 CMSMaxAbortablePrecleanLoops (默认是 0) , 意思是没有循环次数的限制.
      2. 如果执行这个逻辑的时间达到了阈值 CMSMaxAbortablePrecleanTime (默认是 5s), 会退出循环.
      3. 如果新生代 Eden 区 的内存使用率达到了阈值 CMSScheduleRemarkEdenPenetration 默认 50%, 会退出循环.
    • 重新标记
      因为预清理阶段也是并发执行的, 并不一定是所有存活对象都会被标记, 因为在并发标记的过程中对象及其引用关系还在不断变化中, 因此需要一个 STW 的阶段来完成最后的标记工作. 这就是重新标记阶段, 也是 CMS 中标记阶段中的最后一步. 主要目的就是重新扫描之前并发处理阶段的所有残留更新对象.
      主要工作如下

      1. 遍历新生代对象,重新标记.
      2. 根据 GC Roots, 重新标记
      3. 遍历老年代的 Dirty CardMod Union Table, 重新标记
    • 并发清除
      根据标记结果清除老年代中的垃圾对象, 不会触发 STW. 速度一般.

    • 并发重置
      将清理并恢复在CMS GC过程中的各种状态, 重新初始化CMS相关数据结构, 为下一个垃圾收集周期做好准备. 不会触发 STW.

    以上参考链接:
    jvm 优化篇-(8)-跨代引用问题(RememberSet、CardTable、ModUnionTable、DirtyCard)
    CMS垃圾回收器

     

    4.5 G1 - Garbage First 回收器

    G1 垃圾回收器是在 Java7 u4 之后引入的一个新的垃圾回收器. 在 JDK9 中更被指定为官方GC收集器. 是一款面向服务端应用的垃圾回收器, 用于多核处理器和大内存的机器上. 实现高吞吐量的情况下, 尽可能的降低暂停时间.

    G1 回收器的设计目标是取代 CMS 回收器, 它与 CMS 相比, 在以下方面表现更加出色

    • G1 是一个有整理内存过程的垃圾回收器, 不会产生很多的内存碎片, 因此可以不采用空闲列表的内存分配方式, 而可以直接采用指针碰撞的方式.
    • G1 的 STW 更可控, G1 在停顿时间上添加了预测机制, 用户可以指定期望时间.
    • G1 可以在 Young GC 中使用, 而 CMS 只能在回收老年代时使用.

    在了解 G1 垃圾回收器之前, 先来熟悉一下 G1 中的一些概念.

    4.5.1 Region

    在 G1 之前的其他垃圾回收器进行回收的范围都是整个新生代或者老年代, 而G1不再是这样. 使用G1回收器时, Java 堆的内存布局就与其他回收器有很大差别, 它将整个 Java 堆划分为多个大小相等的独立区域 (Region), 虽然还保留有新生代和老年代的概念, 但新生代和老年代不再是物理隔离的了, 它们都是一部分 Region (不需要连续) 的集合.

    在 HotSpot 的实现中, 整个堆被划分成 2048 个左右大小相等的 Region, 每个 Region 的大小在 1M~32M 之间, 具体多大取决于堆的大小. 而 G1 垃圾回收器的分代也是建立在这些 Region 基础上的.

    每个 Region 都可能是新生代也可能是老年代, 但是在同一时刻只能属于某个代, Eden 区, Survivor区, 老年代这些概念都还存在, 不过是逻辑上的概念. 这样方面复用之前分代框架的逻辑.

    Survivor区 就是新生代中的 from 区 与 to 区.

    分区还有一种十分特殊的类型 Humongous , 所谓 Humongous 就是一个对象的大小超过了某一个阈值 (HotSpot 中是 Region 的 1/2), 那么它就会标记为 Humongous , 如果遇到超出 Region 大小的 Humongous, 则可能需要将两个 Region 合并后, 才可以放得下.

    E 表示 Eden , S 表示 Survivor, H 表示 Humongous, O 表示老年代, 剩余灰色的表示空闲的 Region
    每一个分配的 Region , 内部都可以分为两个部分, 已分配和未分配. 它们之间的接线被称为 top. 简单来说, 将一个对象分配至 Region 内, 值需要简单增加 top的值即可, 如下图

    每一次都只有一个 Region 处于被分配的状态中, 称为 current region. 在多线程的情况下, 这样就会带来并发的问题, G1 回收器采用了 TLAB 与 CAS 的方式, (TLAB 本地线程分配缓冲, 这个在上篇中已经说过). 过程如下
    1. 记录 top 值
    2. 准备分配
    3. 比较记录的 top 值 和现在的 top 值. 如果一样, 则执行分配, 并且更新 top 的值. 否则重复步骤 1

    显然, 使用 TLAB 就会带来碎片, 例如: 一个线程在自己的 Buffer 里分配的时候, 虽然 Buffer 里还有剩余空间. 但是却因为分配对象过大以至于这些空闲空间无法满足, 此时线程就会去申请新的 Buffer , 而原来 Buffer 中的空间就浪费了. Buffer 的大小和线程数量都会影响这些碎片的多少.

    Region 可以还说是 G1 回收器一次回收的最小单元, 即每次回收都是回收 N 个 Region , 每一次回收 G1会优先选择可能回收最多垃圾的 Region 进行回收.

    G1使用了停顿可预测模型, 来满足用户设定的 GC 停顿时间, 根据用户设定的目标时间, G1 会自动化的选择哪些 Region 要清除, 一次清除多少个.

    G1 从多个 Region 中复制存活的对象, 然后集中放入一个 Region 中, 同时整理, 清除内存. (复制算法).

    例如: 对两个 Eden 进行回收, 然后每个 Eden 都有可能有存活的对象, 那么那些存活的对象就会集中移动到一个 Survivor 区, 原有的两各个 Eden 被设置为空闲 Regio

    4.5.2 RSet - Remember Set

    RSet 即 Remember Set, 在 G1 中用来记录从其他 Region 指向一个 Region 的指针情况. 因此一个 Region 就会有一个 RSet. 当虚拟机发现执行一个引用类型的写操作时, 就会产生一个写屏障, 检查该引用的对象是否在同一个 Region 中. 如果不是, 则将该引用信息添加到被引用对象的 RSet 中.
    例如. Region9 中的对象引用了 Region2 中的对象. 那么 Region9 就会被记录在 Region2 对应的 RSet 中.

    G1 回收器采用了双重过滤, 过滤掉同一个 Region 内部的引用, 过滤掉空引用

    那么一个线程修改了 Region 内部的引用, 就必须要去通知 RSet, 更改其中的记录, 为了达到这种目的, 这里又用到了卡表 (Card Table), 每一个 Region 内部又被分为了固定大小的若干个卡页关于卡表,卡页在上面 CMS 中已经说过了, 引用发生改变, 就会被标记为脏卡, 同时 RSet 也会记录这个数据. 一般来说 RSet 其实是一个 Hash Table, Key 是别的 Region 的起始地址, Value 是一个集合. 里面的元素是卡表的索引.

    使用 RSet 的好处就是, 在回收时, 在 GC Roots 的枚举范围中加入 RSet, 就可以快速知道 Region 的引用情况. 避免整个堆的扫描. 例如要回收某个新生代的 Region, 通过 RSet 就可以快速的知道是否有老年代的 Region 引用它里面的对象

    4.5.3 CSet - Collect Set

    CSet 即 Collect Set, 是 G1 垃圾回收器选择的待回收 Region 集合. G1 每次 GC 不是全部 Region 都参与的, 可能只清理少数几个, 这几个就可以称为 CSets.

    在 GC 时, 对于 Old 到 Young 和 Old 到 Old 的跨代对象引用, 只需要扫描对应 CSet 中的 RSet 即可.

    G1垃圾回收器软实时的特性就是通过 CSet 的选择来实现的. 对于新生代回收 CSet 只容纳 Eden Region 与 Survivor Region. 对于混合回收(Mixed GC), CSet 还会容纳部分在全局并发标记阶段标记出来的回收后收益高的老年代 Region.

    4.5.4 G1 的 GC 模式

    G1 提供了两种 GC 模式, Young GC 和 Mixed GC. 两种都是需要 STW 的.

    1. Young GC
      Young GC 主要是对 Eden 区进行 GC , 它会将 Eden 区存活的对象移动到 Survivor 区, 如果 Survivor Region 空间不够, Eden 空间的部分数据会直接晋升到老年代 Region. 旧 Survivor Region 的数据移动到新的 Survivor Region 中, 也有部分会晋升到老年代 Region. 最终 Eden Region 数据为空. 以通过调整 新生代 Region 的数量来达到软实时.
      触发条件: 在 Eden 区空间耗尽时会被触发 同时在初始标记时也会伴随着一次 Young GC.
    1. Mixed GC
      Mixed GC 又被称为混合 GC, 也就是说不仅可以进行正常的新生代GC, 同时也回收部分老年代 Region. 通过调整老年代 Region 的数量来达到软实时. 它的 GC 过程又分为 2 部分

      • Gloabal concurrrent marking 全局的并发标记, 执行过程分为下面四个步骤

        Gloabal concurrent marking 在G1 GC中, 它主要是为 Mixed GC 提供标记服务, 并不是一次 GC 的一个必要环节.

        • 初始标记:  它标记了从 GC Roots 开始直接可达的对象, 与 CMS 类似. 不同的是这个过程是和 Young GC 的暂停过程一起的. (因为可以复用扫描 GC Roots 操作). 需要 STW.

        • 并发标记:  这个阶段从GC Roots 开始对堆中的对象进行标记, 标记线程与应用程序线程并发执行, 并且收集各个 Region 的存活对象信息. 不需要 STW. 因为是并发执行, 所以可能会有引用变更, 就是在 CMS 中说过的 漏标和多标的情况. 在介绍读写屏障的时候也说过 G1 使用写屏障 + SATB 来解决.

          SATB 是最开始用于实时垃圾回收器的一种技术, G1 垃圾回收器使用该技术在标记阶段记录一个存活对象的快照, 然后在并发标记阶段, 应用可能修改了原本的引用, 例如删除了一个原本的引用, 这就会导致并发标记结束之后的存活对象的快照与 SATB 不一致. 通过在上面介绍的写屏障, 每当存在引用更新的情况, G1 会将修改之前的值写入到一个 Log Buffer (这个记录会过滤掉原本是空引用的情况), 在最终标记阶段扫描 SATB, 修正 SATB 误差.

        • 最终标记:   该阶段只需要扫描 SATB 记录的 Log Buffer, 处理在并发标记阶段参数的新的存活对象的引用. 需要STW.

        • 清除垃圾:  如果发现完全没有活对象的 Region, 那么将此 Region 加入到空闲 Region 列表中. 在该阶段会重置 RSet.

      • Evacuation 拷贝存活对象: 这个阶段是需要 STW 的.把一部分 Region 里的活对象并行拷贝到空闲Region, 然后回收原本的 Region. 该阶段可以自由选择任意多个 Region 来构成收集集合 CSet.

        • Evacuation 的触发时机在不同模式下会有一些不同. 相同点是, 只要堆的使用率达到了某个阈值, 就必然会触发 Evacuation. 这是为了确保在 Evacuation 时有足够的空闲 Region 来容纳存活的对象.

      G1 会自动在 Young GC 与 Mixed GC 之间切换, 并且定期触发 Gloabal concurrrent marking . HotSpot 的 G1 实现允许指定一个参数 InitiatingHeapOccupancyPercent, 在达到该参数的情况下, 就会执行 Gloabal concurrrent marking. 当统计得到老年代的可回收对象超过了 5% 时(JDK1.8 中默认为 5%) ,就会触发一次 Mixed GC.

      无论出于何种模式, Young Region 都在被回收的范围, 而老年代 Region 只能期望于 Mixed GC. 但是与 CMS 垃圾回收器中的问题一样, Mixed GC 可能会来不及回收老年代 Region, 也就是说在需要分配老年代对象的时候, 并没有足够的空间, 这个时候就只能触发一次 Full GC.

    4.5.5 停顿预测模型

    G1 垃圾回收器突出表现出来的一点是通过一个停顿预测模型根据用户配置的停顿时间来选择 CSet 的大小, 从而达到用户期待的应用程序停顿时间. 但是停顿时间的设置也并不是越短越好.

    设置的时间越短也就意味着 CSet 越小, 导致垃圾逐步积累变多, 最终不得不退化成别的垃圾回收器.
    停顿时间设置的过长, 那么会导致每次都会产生长时间的停顿, 影响了程序对外的响应时间.

    G1 垃圾回收器内容参考: G1垃圾回收器详解

     

    4.6 ZGC - The Z Garbage Collector 回收器

    ZGC (The Z Garbage Collector) 是 JDK 11 中推出的一款低延迟垃圾回收器, 承若在数 TB 的堆上具有非常低的停顿时间. 它的设计目标包括 停顿时间不超过 10ms, 停顿时间不会随着堆的大小,或者活跃对象的大小而增加. 支持 8M - 4TB 级别的堆.

    4.6.1 ZGC 基本原理

    ZGC 采用标记-复制算法, 不过 ZGC 对该算法做了重大改进, ZGC 在标记, 复制和重定位阶段几乎都是并发的, 这就是 ZGC 实现停顿时间小于 10ms 目标的最关键原因.
    关键技术

    • 着色指针
      着色指针是一种将信息存储在指针中的技术
    • 读屏障
      在上面有说明.

    关于 ZGC 垃圾回收器是非常复杂的, 这里没有继续进行学习了, 更多关于 ZGC 回收器的相关知识, 可以参考下面两篇文章,

    相关文章

      网友评论

        本文标题:学习分析 JVM 中的对象与垃圾回收机制(下)

        本文链接:https://www.haomeiwen.com/subject/loqtkltx.html