第 3 章:垃圾收集器和内存分配策略

本文最后更新于:1 年前

需要回收的内存:

  • 【确定性】程序计数器虚拟机栈本地方法栈:线程私有的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
  • 【不确定性】Java 堆方法区:有着很显著的不确定性一个接口的多个实现类需要的内存可能不一样、一个方法所执行的不同条件分支所需要的内存可能不一样,只有处于运行期间才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

判断对象存活状态的方法:

  • 引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。主流的 Java 虚拟机里面都没有选用
    • 【存在的问题】很难解决对象之间相互循环引用的问题。
  • 可达性分析算法:通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到 GC Roots 间没有任何引用链相连,则证明对象是不可能再被使用的。

固定可作为 GC Roots 的对象包括:

  • 虚拟机栈栈帧中的本地变量表中引用的对象。
  • 本地方法栈中 JNI 引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 所有被同步锁 synchronized 关键字持有的对象。
  • Java 虚拟机内部的引用。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

Java 里面的引用:

  • 传统的引用:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。
    【存在的问题】过于狭隘,一个对象在这种定义下只有被引用未被引用两种状态,无法描述缓存等场景。
    【解决问题】扩充后的引用:
    • 强引用:传统的引用。
      只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
    • 软引用:描述一些还有用,但非必须的对象。
      在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
    • 弱引用:也是描述非必须对象。
      弱引用关联的对象只能生存到下一次垃圾收集发生为止。
    • 虚引用:无法通过虚引用来取得一个对象实例。能在这个对象被收集器回收时收到一个系统通知。

宣告一个对象死亡最多会经历两次标记过程:

  1. 如果对象没有引用链,将会被第一次标记。
  2. 如果对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,将会被第二次标记。
    • 如果需要执行 finalize() 方法,对象将会被放置在一个名为 F-Queue 的队列中,并由 Finalizer 线程执行对象的 finalize() 方法,但是并不保证一定会等待它运行结束,以避免执行缓慢或死循环。
    • 如果对象要在 finalize() 中拯救自己,需要加入引用链
    • finalize() 方法只会被系统自动调用一次。
    • finalize() 方法运行代价高,不确定性大,无法保证各个对象的调用顺序,应该使用 try-finally 或其他方式。

方法区的垃圾收集内容:

  • 废弃的常量,比如回收常量池中字面量 java,需要满足:
    • 没有任何字符串对象引用常量池中的 java
    • 虚拟机中也没有其他地方引用这个字面量。
  • 不再使用的类型,需要满足:
    • 该类所有的实例都已经被回收。
    • 加载该类的类加载器已经被回收。
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用。

垃圾收集算法:

  • 【直接垃圾收集】引用计数式垃圾收集。主流 Java 虚拟机中均未涉及
  • 【间接垃圾收集】追踪式垃圾收集。

分代收集理论:

  • 建立在三个分代假说之上前两个假说奠定了多款垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。
    • 弱分代假说:绝大多数对象都是朝生夕灭的。
    • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
    • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
      • 解决了分代收集的一个痛点:对象不是孤立的,对象之间会存在跨代引用。
        不应再为了少量的跨代引用去扫描整个老年代,只需在新生代上建立一个全局的数据结构记忆集,把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。
  • 划分出不同的区域,才有了回收类型的划分:
    • 部分收集 Partial GC
      • 新生代收集 Minor GC/Young GC
      • 老年代收集 Major GC/Old GC
      • 混合收集 Mixed GC:收集整个新生代和部分老年代
    • 整堆收集 Full GC
  • 划分出不同的区域,才能够针对不同的区域,安排与里面存储对象存亡特征相匹配的垃圾收集算法:
    • 标记-清除算法:标记一部分对象,在标记完成后,统一回收掉所有被标记的对象或所有未被标记的对象。
      【存在的问题】
      • 执行效率不稳定。
      • 产生大量不连续的内存碎片。
    • 标记-复制算法:将可用内存按容量划分为大小对等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
      • 优:解决了标记-清除算法的问题。
      • 【引入问题】内存空间浪费太多IBM:新生代中的对象有 98% 熬不过第一轮收集。
        【解决问题】Appel 式回收:把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 SurvivorHotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1
        • 【引入问题】Survivor 空间可能不足以容纳一次 Minor GC 之后存活的对象。
          【解决问题】逃生门:依赖其他内存区域进行分担担保。
    • 标记-整理算法:标记过程和标记-清除算法一样,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
      • 优:解决了标记-清除算法的问题。
      • 【引入问题】在有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作。而且这种对象移动操作需要 Stop The World

根节点枚举
【存在的问题】迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的虽然现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行
【解决问题】OopMap 数据结构:快速准确地完成 GC Roots 枚举记录对象内什么偏移量上是什么类型的数据,在特定的位置记录下栈里和寄存器里哪些位置是引用

  • 【引入问题】因为能导致 OopMap 内容变化的指令非常多,所以如果为每一条指令都生成对应的 OopMap,将会需要大量的额外存储空间。
    【解决问题】安全点:保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点在“具有让程序长时间执行的特征”的位置才记录信息,要求用户程序必须执行到达安全点后才能够停顿下来开始垃圾收集
    • 【引入问题】程序不执行时无法响应虚拟机的中断请求。
    • 【解决问题】安全区域:确保在某一段代码片段之中,引用关系不会发生变化虚拟机进行垃圾收集时会忽略安全区域内的线程,但是安全区域内的线程在收到可以离开的信号之前,会被封在里面

记忆集:一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

  • 优:缩减 GC Roots 扫描范围。
  • 【引入问题】空间占用和维护成本高。
    【解决问题】选择更为粗犷的记录粒度来节省记忆集的存储和维护成本因为只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针
    • 字节精度:每个记录精确到一个机器字长处理器的寻址位数,常见的是 32 位和 64 位
    • 对象精度:每个记录精确到一个对象。
    • 卡精度:每个记录精确到一块内存区域用卡表实现

卡表:最简单的形式只是一个字节数组 HotSpot 虚拟机是这样做的,使用 byte 数组而不是 bit 数组是因为现代计算机硬件都是最小按字节寻址的,没有直接存储一个 bit 的指令

卡页:字节数组的每一个元素在内存区域中的内存块。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏,没有则标识为 0。

写屏障:维护卡表状态。

  • 【引入问题】
    • 额外的开销这个开销与 Minor GC 时扫描整个老年代的代价相比低得多
    • 伪共享问题:高并发场景下,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行现代中央处理器的缓存系统是以缓存行为单位存储的,就会彼此影响而导致性能降低。
      【解决问题】不采用无条件的写屏障,而是先检查卡表标记。

用户线程与收集器并发工作时的对象消失问题

  • 当且仅当以下两个条件同时满足,才会产生对象消失问题
    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
    • 赋值器删除了全部从灰色对象到白色对象的直接或间接引用。
  • 解决对象消失问题的两种方案:
    • 【破坏第一个条件】增量更新:黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象。
    • 【破坏第二个条件】原始快照:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

ART GC overview
ART has a few different GC plans that consist of running different garbage collectors. Starting with Android 8 (Oreo), the default plan is Concurrent Copying (CC). The other GC plan is Concurrent Mark Sweep (CMS).

因为 Android ART 默认使用 CC The other GC that ART still supports is CMS.,所以只看了 CMS。

CMS Concurrent Mark Sweep:一种以获取最短回收停顿时间为目标的收集器。

  • 运作过程:
    1. 初始标记 CMS initial mark:标记 GC Roots 能直接关联到的对象。
    2. 并发标记 CMS concurrent mark:从 GC Roots 的直接关联对象开始遍历。
    3. 重新标记 CMS remark:通过增量更新解决对象消失问题
    4. 并发清除 CMS concurrent sweep:清理删除掉标记阶段判断的已经死亡的对象。
  • 初始标记重新标记仍然需要 Stop The World并发标记并发清除时,用户线程可以与垃圾收集线程并发运行。
  • 【存在的问题】
    • 因为基于标记-清除算法实现,所以收集结束时会产生大量的空间碎片。
    • 对处理器资源非常敏感默认启动的回收线程数是(处理器核心数量+3)/4
      • 【解决问题】增量式并发收集器 i-CMS实际效果很一般,已经被废弃。
    • 无法处理浮动垃圾在标记过程之后,在清理过程之前产生的垃圾,所以必须预留一部分空间供并发收集时的程序运作使用。