☯️ 垃圾收集

吞佛童子2022年6月9日
  • Java
  • JVM
大约 14 分钟

☯️ 垃圾收集

1. 哪些内存需要回收?

    • 回报率高,应重点关注
  • 方法区
    • 回收性价比低
    • 主要收集内容:
      • 废弃的常量
      • 无用的类
        • -Xnoclassgc 参数控制是否回收 [HotSpot]
        • 该类的所有实例已被回收
        • 加载该类的类加载器已被回收,这种情况需自定义类加载器
        • 该类对应的 java.lang.Class 对象无任何地方引用,且无法通过反射访问该类的方法

2. 什么时候进行 GC 回收?

  • 对象死亡时

1) 如何判断对象已死亡?

① 引用计数法

  1. 概述
    • 每个对象都绑定一个计数器
    • 当该对象被引用时,计数 ++
    • 当引用失效时,计数 --
    • 当计数 == 0 时,说明该对象不被使用,可以被回收
  2. 优点
    • 原理简单,判定效率高
    • 只要被发现引用为 0 就可回收,适用于内存敏感的场景,
    • 而可达性分析则需要每次GC统一进行,遍历完所有后,才能判断是否可被回收
  3. 缺点
    • 占用额外空间
    • 很难解决对象间循环引用的问题
      • 虽然很难解决,但还是有很多语言采用该方法,例如 CPython
      • CPython 如何解决:
        • 借助双向链表连接容器对象
        • 找链表中引用计数 <= 0 的对象,即不可达对象
        • 将其标记 - 清除
        • 缺点: 需要维护容器额外字段开销 & 双向链表开销,影响性能
  4. 循环引用 详解
public class Test {
    public static void  main (String args[]){
        MyObject1 object1 = new MyObject1();// object1 为 MyObject1的第一次引用 ,引用+1
        MyObject2 object2 = new MyObject2();// object2 为 MyObject2的第一次引用,引用+1
 
        object1.ref = object2; // object1.ref 为 MyObject2 的第二次引用,引用+1
        object2.ref = object1;// object2.ref 为 MyObject1 的第二次引用,引用+1
 
        object1 = null; //MyObject1 对象的引用-1
        object2 = null;//MyObject2 对象的引用-1
 
        // 但是 MyObject1 和 MyObject2 这俩个对象仍然还有一次引用
        // 引用不是0,垃圾回收器就无法回收他们
        // 所以这就循环引用所带来的问题
    }
}
class MyObject1{
    public Object ref=null;
}
class MyObject2{
    public Object ref=null;
}

② 可达性分析

  1. 概述
    • GC Roots 作为起始点集
    • 根据引用关系依次向下遍历整个对象图,
    • 当某个对象通过 GC Roots 不可达时,说明该对象可被回收
  2. 可作为 GC Roots 的对象:
    • 方法区中的常量引用的对象
    • 方法区中的类静态变量引用的对象
    • 虚拟机引用的对象
    • 本地方法引用的对象
    • 同步锁[synchronized 关键字]持有的对象
    • 跨代引用的对象
  3. 如何找到所有的 GC Roots - 根节点枚举
    • HotSpot 为准确式 GC,在类加载完成时就已经得到 OopMap,通过 OopMap 快速找到所有 GC Roots
    • 在找 GC Roots 的过程中,发生 Stop The World,期间根节点集合对象的引用关系不能发生改变
  4. GC Roots 遍历整个引用链过程中的并发可达性分析
    • 三色标记法
      • : 对象尚未被垃圾收集器访问过
      • : 对象已经被垃圾收集器访问过,但是该对象上的所有引用关系没有完全遍历完
      • : 对象已经被垃圾收集器访问过,且对象上的所有引用关系也已经遍历完
    • 并发下容易出现的问题
      • 多标: 将本该为 W 的对象标记成 B,产生浮动垃圾
      • 漏标: 将本该 B 的对象标记为 W,导致错误回收
        • 产生条件:以下两个条件必须同时满足
          • 新增 一条 | 多条 B -> W 的新引用 [由于 B 已经全部扫描过,因此不会再次进行扫描]
          • 删除 全部 G -> W 的直接 | 间接引用
        • 解决方案:
          • 增量更新
            • 当新增 B -> W 的新引用时,将该信息记录,并发扫描结束后,重新以这些 B 为根,再扫描一次
            • 等效于, 将 B 变为 G
            • CMS
          • 原始快照[SATB]
            • 当删除 G -> W 的引用时,将该信息记录,并发扫描结束后,再以这些 G 为根,重新扫描一次
            • 等效于,不处理此次删除记录,以刚开始扫描时的快照为基础
            • G1 Shenandoah
  5. 不可达一定会被回收吗?
    • 对象要真正死亡,最多需要经历 2 次标记过程
    • 若发现对象不可达,则会被第一次标记,然后进行筛选
    • 筛选条件: 此对象是否有必要执行 finalize() 方法
      • 若该对象没有重写 finalize() | finalize() 已经被虚拟机调用过,则认为 没有必要
      • 若该对象有必要执行 finalize(),则会被放入一个名为 F-Queue 的队列中,
      • 并由一条由虚拟机自动建立的、优先级较低的 Finalizer 线程执行队列中的 finalize()
        • 虚拟机只保证会触发 finalize()的开始运行,并不保证一定会等待其运行结束
        • 这样做是为了避免拖累其他对象 finalize() 的执行
    • 若对象在 finalize() 中成功与引用连中的任意对象建立引用关系,则可以逃脱二次标记,重新存活
    • 否则,将被二次标记,放入“即将回收”集合
    • 任何对象的 finalize() 只能被系统自动调用一次,若下次再面临回收,则 finalize() 不会被执行

2) 引用

  • 无论是 引用计数法 还是 可达性分析,均只关心引用类型,而不考虑整型之类的其他类型
  • 什么是引用:
    • reference 类型数据中存储的值表示另一块内存的起始地址,而非一个数值

① 引用的类型

  1. 强引用
    • 类似 Object obj = new Object() 这种引用关系
    • 只要引用关系还在,无论什么时候,垃圾收集器均不会回收
  2. 软引用
    • SoftReference 类实现
    • 只要软引用关系还在,则内存溢出前,垃圾收集器会将这些对象加入回收范围进行二次回收
    • 若回收后,内存还不够,才会抛出 OOM
    • 可用来做内存敏感的高度缓存
  3. 弱引用
    • WeakReference 类实现
    • 被弱引用关联的对象,垃圾收集时,发现即被回收,因此只能存活到下次垃圾收集的开始
    • ThreadLocalKey 就是弱引用
  4. 虚引用
    • PhantomReference 类实现
    • 为了能够在对象被垃圾回收器回收时收到系统通知

② JVM 如何判断栈上的数据是什么类型

  1. 保守式 GC
    • JVM 不记录数据的类型,无法区分内存上某个位置的数据是引用类型还是非引用类型
    • JVM 只能通过一些条件,例如所在地址是否在GC堆的上下界,是否字节对齐等排出肯定不是引用类型的数据
    • 而对于满足条件的情况则不能判断
    • 特点:
      • 会放过一些垃圾,对内存不友好
      • 只能使用标记-清除算法,不移动对象
  2. 半保守式 GC
    • 在对象上记录类型信息
    • 特点:
      • 只有追溯到堆内对象才能得到类型信息,该部分对象才可以移动
      • 可以使用移动部分对象的算法,也可以使用标记-清除
  3. 准确式 GC
    • 主流 Java 虚拟机使用
    • JVM 知晓对象的类型,在栈上的引用也能得知类型信息
    • 常见方式:
      • 在指针上打标记,表名类型
      • 在外部记录类型信息形成一张映射表
    • HotSpot:
      • 映射表 - OopMap
      • 记录在该类型的对象内什么偏移量上是什么类型的数据,
      • 类加载时计算得到对象的 OopMap
      • 解释器执行方法时可以通过解释器里的功能自动生成 OopMap
      • 被 JIT 编译过的方法,也会在特定位置生成 OopMap,记录执行到该方法的某条指令时,栈上 & 寄存器哪些位置是引用
        • 特定位置: - 安全点
          • 循环末尾
          • 方法返回前
          • 可能抛异常的位置

  • 安全点
    • 安全点的选定既不能太少以至于垃圾收集器等待时间过长;也不能太多增加运行时的内存负荷
    • 如何在 GC 时让所有线程都跑到最近的安全点:
      • 抢占式中断
        • 系统把所有用户线程全部中断
        • 对于未处于安全点的线程,恢复其执行,知道跑到最近的安全点
        • 目前几乎没有虚拟机采用这种方式
      • 主动式中断
        • GC 发生时,产生一个标志位
        • 各个线程执行过程中不断主动轮询这个标志位,
        • 轮询标志位的地方应和安全点重合,再加上所有需要在堆上分配内存的地方,例如对象的创建过程,为了避免无足够内存分配新对象
        • 一旦发现标志位为 true,则跑到最近的安全点后主动中断挂起
        • 由于轮询频繁,因此必须确保其足够高效, HotSpot 使用内存保护陷阱,将轮询操作精简至只有一条汇编指令的程度
  • 安全区域
    • 解决用户线程处于 sleep | block 状态无法响应虚拟机中断请求的情况
    • 确保在某一个段代码片段中,引用关系不会发生改变
    • 使用:
      • 当用户线程进入安全区域后,会标识自己表示已经进入安全区域
      • 此时若发生 CG,则虚拟机不会管这部分用户线程
      • 当用户线程退出安全区域时,检查虚拟机是否已经完成根节点枚举过程
      • 若没有,则需要等待,直到收到可以安全离开安全区域的信号
      • 若已完成,则继续运行

3. 如何进行回收?

1) 常见垃圾收集算法

① 标记 - 清除

  1. 步骤
    • 标记:标记出所有 需要回收 | 需要存活 的对象
    • 清除:清除所有 被标记 | 未标记 的对象
  2. 优点
    • 基础 & 经典
  3. 缺点
    • 产生碎片化空间,导致无法分配大对象而提前触发下一次 GC
    • 维护空闲列表
    • 执行效率不稳定,与需要回收的对象多少成正相关
  4. 适用场景
    • CMS

② 标记 - 复制

  1. 步骤
    • 将堆分为两块,From & To
    • 对象只会在 From 上生成,发生 GC 之后会找到所有存活对象,然后将其复制到 To
    • From 区清理掉
    • FromTo 调换
  2. 优点
    • 无碎片空间产生
    • 可使用直接指针分配内存,无需空闲列表
    • 内存分配在相近地方,缓存命中率高,对 CPU 缓存友好
  3. 缺点:
    • 空间利用率低,有部分区域不能分配
    • 若存活对象很多,复制压力大,效率低
    • 移动对象,不适用于保守式 GC
  4. 适用场景
    • 新生代收集
    • Serial, ParNew, Parallel Scavenge
  5. Appel 式回收:
    • 将新生代分为 Eden & Survivor0 & Survivor1
    • 每次分配内存只是用 Eden & Survivor0
    • 垃圾收集时,将 Eden & Survivor0 存活对象复制到 Survivor1
    • 清理到 Eden & Survivor0
    • Survivor0Survivor1 互换
    • default ratio = 8 : 1 : 1, 空间利用率 = 90%
    • Survivor1 不足以容纳一次 Minor GC 之后的存活对象时,通过分配担保机制,直接进入老年代

③ 标记 - 整理

  1. 步骤
    • 标记:标记出所有 需要回收 | 需要存活 的对象
    • 整理:让所有存活对象想内存空间一侧移动
    • 清理掉边界以外的内存
  2. 优点
    • 内存规整,无碎片化空间
    • 空间利用率比 标记 - 复制 高
  3. 缺点
    • 对堆进行多次搜索,毕竟在一个空间内,既进行标记,有进行移动,耗时长
  4. 适用场景
    • 老年代
    • Serial Old, Parallel Old

2) 分代收集理论

img.png

  1. 两个分代假说
    • 弱分代假说: 绝大多数对象都是朝生夕死的
    • 强分代假说: 熬过多次垃圾收集过程的对象越难以消亡
  2. 第三条经验法则
    • 跨代引用假说: 跨代引用相对于同代引用来说仅占极少数

YGC & FGC & 元空间 GC 触发时机

  1. YGC
    • 如果Eden区域没有足够的空间,那么就会触发YGC(Minor GC)
  2. FGC
    • 当晋升到老年代的对象大于老年代的剩余空间时,就会触发FGC(Major GC)
    • 老年代的内存使用率达到了一定阈值(可通过参数调整),直接触发FGC
    • 不允许空间分配担保 | 担保失败
    • Metaspace(元空间)在空间不足时会进行扩容,当扩容到了-XX:MetaspaceSize 参数的指定值时,也会触发FGC
    • System.gc() 或者Runtime.gc() 被显式调用时,触发FGC
  3. 元空间 GC
    • -XX:MetaspaceSize 初始空间的大小
    • 达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整
      • 如果释放了大量的空间,就适当降低该值
      • 如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值
    • -XX:MaxMetaspaceSize 最大空间,默认是没有限制的

3) 跨代引用问题

记忆集

  • 用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
  • 避免把整个老年代纳入 GC Roots 扫描范围
  • 根据记录的精度分类:
    • 字长精度: 每条记录精确到机器字长
    • 对象精度: 每条记录精确到对象
    • 卡精度: 每条记录精确到一块内存区域

卡表

  • 最常见的是用卡精度来实现记忆集,称之为卡表
  • HotSpot 采用一个字节数组作为卡表,数组中每个元素指向内存区域中一块特定位置,这个内存块为卡页
  • 一个卡页为 2^9 = 512 字节,里面存放多个对象
  • 只要当前卡页中存在对象有跨代指针,则卡表对应下标位置 1,表示为脏页
  • 因此 GC 时,只需要遍历卡表的数组元素,找到值为 1 的下标对应的内存块,加入到 GC Roots 中即可
  1. 卡表元素何时变脏?
    • 当新生代对象引用了老年代对象时,该老年代对象所在的内存块对应的卡表下标位置 1
  2. 如何在对象赋值的时刻去更新卡表?
    • 需要找到机器码层面的手段,将维护卡表的动作放到每个赋值操作中
    • HotSpot 通过 写屏障实现,类似 AOP 切面,当引用对象被赋值时,就会产生一个环形通知,在通知中进行更新卡表操作
    • 在 G1 出现之前,其他收集器都只用到 写后屏障,而没有用写前屏障
void oop_field_store(oop* field, oop new_value) { 
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新 
    post_write_barrier(field, new_value);
}
  1. 如何避免伪共享问题?
    • 由于缓存行 == 64 字节卡表 == 512字节,里面的每个元素 == 1 字节
    • 也就是说,卡表数组中有 64 个下标位的元素共占 一个缓存行
    • 多线程下会出现伪共享问题,修改元素值时会出现
    • 解决:
      • 在修改指定下标元素值前,先判断对应标志位是否已经是 1
      • 通过参数 -XX:+UseCondCardMark 决定是否开启卡表更新前的条件判断
      • 开启会增加额外判断开销,但是可以避免伪共享问题

4. 内存分配与回收策略

1) 对象优先在 Eden 区分配

  • 当 Eden 区将满时,触发 Minor GC

2) 大对象直接进入老年代

  • 举例:
    • 很长的字符串
    • 元素数量庞大的数组
  • 参数:
    • -XX:PretenureSizeThreshold
      • 指定大于该值的对象直接在老年代进行分配,避免在新生区来回复制
      • 只对 Serial & ParNew 生效

3) 长期存活的对象进入老年代

  • 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代
  • 活过一次 Minor GC,则年龄 +1
  • 参数:
    • -XX:MaxTenuringThreshold
      • 年龄达到该值,晋升老年代
      • default = 15

4) 动态对象年龄判定

  • 在 Survivor 区 >= 某个年龄的对象总和 > Survivor 空间的一半时,
  • 年龄 >= 该值的对象可以直接进入老年代

5) 空间分配担保

  • 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
  • 若大于,那么Minor GC 可以确保是安全的
  • 否则,虚拟机会查看参数值 -XX:HandlePromotionFailure 是否允许担保失败
    • 若允许担保失败,则检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小
    • 若大于,尝试进行 Minor GC,尽管可能有风险
    • 若小于,或 不允许担保失败,则改为进行 Full GC
上次编辑于: 2022/10/10 下午8:43:48
贡献者: liuxianzhishou