垃圾收集器

159 阅读15分钟

垃圾收集器

什么是Java的垃圾回收呢? Java 垃圾收集是 Java 程序执行自动内存管理的过程。 Java 程序编译为可以在 JVM 上运行的字节码。 而后当 Java 程序在 JVM 上运行时,对象是在堆上创建的,堆是专用于程序的一部分内存。 最终,将不再需要某些对象。 垃圾收集器找到这些未使用的对象并删除它们以释放内存。

那么垃圾收集器是如何实现的呢?在理论上,可以从以下两个方面来分析:

  • 如何判断某一对象是不是应当回收呢?即判断对象是否“存活”。
  • 应当回收的对象在内存中如何回收呢?即垃圾收集算法。

判断对象是否该回收

引用计数法

什么是引用计数器呢?即在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;同样的,当引用失效时,计数器值就减一;计数器值为零的对象就是未被使用的对象,应当回收。虽然引用计数算法原理,但效率高。缺点在于,引用计数法往往需要其他额外的处理才能确保正确的工作。单纯的引用计数法有许多例外的情况需考虑,例如对象间的循环引用问题。

可达性分析算法

什么是可达性分析算法呢?即通过“GC ROOT”这一根对象作为起始节点集,从起始节点开始,根据引用关系向下搜索,搜索过的路径称为“引用链”,若某个对象到GC ROOT间没用任何引用链相连(对象不可达),则该对象是不可能被使用的。
可做GC ROOT的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。如当前运行方法所使用的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象。如Java类中的引用类型静态变量。
  • 方法区中常量引用的对象。如字符串常量池里的引用。
  • 本地方法栈中引用的对象。
  • jvm 内部引用。如系统类加载器等。
  • 所用同步锁(synchronized)持有的对象。
  • 反映 jvm 内部情况的 JMXBean、JVMTI中注册的回调、本地代码缓存等。

那么当一个对象被判定不被使用后,是不是就直接回收该对象呢?
答案是否定的。当对象不可达后,会被第一次标记,随后筛选一次,判断相关对象是否有必要执行finalize()方法。如某对象没有覆盖finalize()方法,或者finalize()已经被调用过。那么 jvm 都将这两种情况视为“没有必要执行”。

那么当某对象被判定确要执行finalize()方法后,是否就意味这该对象已经到了生命的尽头呢?
答案是否定的。当对象需执行finalize()方法后,该对象会放置到 F-Queue 队列中。当由 jvm 创建的Finalizer 线程来执行 finalize() 方法时,收集器会对 F-Queue 中的对象进行第二次标记,若此时有引用链可达时,该对象将被移出“回收清单”,若仍然不可达,则回收。
可通过如下代码验证:(Note:finalize运行代价昂贵,不确定性大,为不推荐使用语法。

public class FinalizeEscapeGC {
    public static  FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive(){
        System.out.println("yes, i am still alive!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
        //第一次执行finalize方法
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(5000);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead!");
        }
        //finalize已被执行过,不会被再次执行。
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(5000);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead!");
        }
    }
}
执行结果:
finalize method executed!
yes, i am still alive!
no, i am dead!

垃圾收集算法

当前商用的垃圾收集器,大多数都遵循“分代收集”理论。即:

  1. 弱分代假说:绝大多数对象都是朝生夕灭。
  2. 强分代假说:熬过越多次垃圾收集的对象越难消亡。
  3. 跨代引用假说:跨队引用相对于同代引用来说仅占极少数。 由以上假说引出了常用垃圾收集器的一致的设计原则:收集器应当将 Java 堆划分成不同区域,然后根据将回收对象根据年龄分配到不同的存储区间。一般的,会将Java堆划分为新生代和老年代两个区域。然后根据不同区域的特点设计不同的垃圾收集算法。

标记-清除算法

标记-清除算法分为“标记”和“清除”两个阶段。首先,标记出所有需要回收的对象,在标记完成后,统一回收所有被标记的对象。示意图如下:

image.png 缺点:

  • 执行效率不稳定。执行效率随对象数量增长而降低。
  • 内存空间的碎片化。

标记-复制算法

为了解决标记-清除算法面对大量回收对象低效率的问题,提出了标记-复制算法。初始的标记-复制算法为“半区复制”,即将可用内存划分为相等的两块,每次使用其中一块。当一块的内存快使用完时,将这一块内存中存活的对象复制到另一块上,然后将已使用的那块内存一次性清理掉。这种做法存在以下两个问题:

  • 若多数对象存活,则会产生大量内存间复制的开销。
  • 可用内存缩小减到原来的一半。 所以为了尽量解决以上问题,将内存区域划分一个 Eden 区和两个 Survivor 区(默认比例为8:1:1),发生垃圾收集时,只需将 Eden 和一个 Survivor 区中存活的对象复制到另一个 Survivor 区即可。此外,当Survivor区的空间不足以容纳存活对象时,则一般通过分配担保机制直接进入老年代。

image.png

标记-整理算法

老年代一般都有大量对象存活,所以以上两种方式均不适用于老年代区域。所以提出了标记-整理算法,标记过程仍与标记-清除算法一样,后续则改为“整理”过程,即所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。示意图如下:

image.png

但在老年代中,由于存在大量存活的对象,所以移动并更新所有存活对象的引用的操作负担较大(需“STW”,即暂停用户应用程序)。若采用标记-清除算法,则会导致内存空间碎片化。所以有一种“和稀泥式”的解决方案,即平常采用标记-清除算法,直到内存空间碎片化影响到对象内存分配时,再采用标记-整理算法收集一次,以此获得规整的内存空间。

垃圾收集器

收集算法是内存回收的方法论,而垃圾收集器则是内存回收的具体实践者。大致有如下几款经典的垃圾收集器:

image.png

收集器之间存在连线,说明可搭配使用。

适用于新生代的垃圾收集器

Serial收集器

Serial收集器是一个简单高效的单线程收集器。采用标记-复制算法,且只会使用一个处理器或一条收集线程来完成收集工作,且收集时,必须暂停其他所有工作线程(STW),直至垃圾收集完成。示意图如下:

image.png

优点:所有收集器中额外内存消耗最小的。
缺点:STW,停顿时间较长,影响用户体验。
适用场景:内存有限的客户端模式下的JVM。

ParNew收集器

ParNew收集器实质上就是Serial收集器的多线程并行版本,示意图如下:

image.png

优点:默认开启收集线程数与处理器核心数相同,能高效利用系统资源。此外,ParNew 是激活 CMS 收集器后默认的新生代收集器。
缺点:在单核环境下效果不比 Serial 收集器。仍然需要STW。 适用场景:CMS 默认搭配的新生代收集器,JDK9后 ParNew 和 CMS 只能相互搭配使用。

Parallel Scavenge收集器

同样是基于标记-复制算法实现的新生代垃圾收集器,不同于CMS等收集器的目标(尽可能缩短垃圾收集时用户线程的停顿时间),Parallel Scavenge收集器目标在于达到一个可控制的吞吐量,吞吐量为处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

image.png

可通过-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间和-XX:GCTimeRatio直接设置吞吐量大小。
-XX:MaxGCPauseMillis参数的值是一个大于0的毫秒数,但并不是越小就意外着垃圾收集速度更快,缩短垃圾收集停顿时间是以吞吐量和新生代空间为代价换取的。收集300M肯定比收集500M来的快,但同样的收集也更为频繁。
-XX:GCTimeRatio参数的值是一个正整数。表示用户期望虚拟机消耗在GC上的时间不超过程序运行时间的 1/(1+N)1/(1+N)。默认为99,即收集器的时间消耗不超过总运行时间的1%。

适用于老年代的垃圾收集器

Serial Old收集器

Serial Old为Serial收集器的老年代版本,使用标记-整理算法,同样为单线程。示意图如下:

image.png

适用场景:供内存有限的客户端模式下的HotSpot虚拟机使用,若在服务端,则主要作为CMS收集器发生失败时的后备预案。(jdk5及以前可与Parallel Scavenge搭配使用)

Parallel Old收集器

Parallel Old为Parallel Scavenge收集器的老年版本,使用标记-整理算法,同样基于多线程。jdk6后才提供,示意图如下:

image.png

适用场景:在注重吞吐量或者处理器资源较为稀缺的场合。

CMS收集器

不同于前面两个老年代垃圾收集器,CMS 使用标记-清除算法,是一种以获取最短回收停顿时间为目标的收集器。
运作过程分为四步:

  1. 初始标记:仅标记从GC ROOT直接关联的对象。(STW)
  2. 并发标记:从GC ROOT关联的对象开始遍历整个对象图并标记。
  3. 重新标记:修正并发标记期间,因用户线程运作而导致标记产生变动的那一部分对象的标记记录。(STW)
  4. 并发清除:清除标记阶段判断已死亡的对象。 示意图如下:

image.png

优点:并发收集、低停顿
缺点:

  • 对处理器资源敏感。默认情况下,CMS启动的回收线程数是(处理器核心数 + 3)/4,也就是说,在处理器核心不足的情况下可能导致用户程序的执行速度大幅降低。
  • 无法处理‘浮动垃圾’。浮动垃圾即在标记过程后新产生的垃圾对象,此次收集中无法处理。
  • 会产生大量空间碎片。
    适用场景:通常用于互联网网站或者基于浏览器的B/S系统的服务端。

里程碑式的Garbage First收集器

G1是一款主要面向服务端应用的“全能的垃圾收集器”。JDK9后取代了Parallel Scavenge加Parallel Old组合,成为服务器模式下的默认垃圾收集器。

设计目标:能够建立“停顿预测模型”的收集器。停顿预测模型就是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒。

内存布局:G1将连续的Java堆划分为多个大小相等的独立区域(Region),每个Region可根据需要扮演Eden、Survivor或老年代等空间,也就是说G1物理上不分代,但逻辑上仍然保有分代的概念。此外,Region中还有一类专门存储大对象的Humongous区域。当对象大小超过一个Region容量的一半大小时即为大对象(Region大小通过-XX:G1HeapRegionSize设定,范围为1~32MB,且为2的N次幂)。超过整个Region的对象会存放在N个连续的Humongous Region中。示意图如下: image.png 设计思路:G1可以面向堆内任何部分来组成回收集。不是仅针对哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是Mixed GC模式。\

G1实现的关键性细节问题

  • 如何解决跨Region引用对象:通过记忆集(本质为哈希表,key为Region的起始地址,Value为集合,存储的是卡表的索引号,而双向卡表则记录了“我指向谁”以及“谁指向我”。由于数据结构更为复杂,内存占用相对更高,约为堆容量的10%-20%)。
  • 如何保证并发标记阶段收集线程与用户线程互不干扰?通过原始快照(SATB)算法实现。此外每个Region还设计了两个TAMS(Top at Mark Start)指针,用于并发回收过程中新对象的分配,即回收时,所有新对象必须分配到这两个指针之上。
  • 如何建立停顿可靠模型?以衰减均值为理论基础实现。用户可通过-XX:MaxGCPauseMillis指定期望值,默认为200ms。 运行步骤及示意图:
  1. 初始标记:仅标记GC Roots能直接关联到的对象。修正TAMS指针,以便并发时可正确分配新对象。
  2. 并发标记:从GC Roots开始进行可达性分析,并重新处理SATB,记录下并发时有引用变动的对象。
  3. 最终标记:STW,用于处理并发标记阶段少量的SATB记录。
  4. 刷选回收:更新Region的统计数据,按回收价值和成本排序,根据用户期望的停顿时间选择多个Region构成回收集。然后将回收集中存活的对象复制到空的Region中,再清理掉回收集中相关的Region。(STW,但是是并发执行)

image.png 与CMS对比

  • 在实现算法上,CMS基于标记-清除算法,而G1,从局部看(两个Region),基于标记-复制算法,但从整体看,基于标记-整理算法。
  • 在内存占用上,G1的卡表结构比CMS更为复杂,占用内存更多。
  • 在执行负载上,G1的负载也相对更高。 所以,在小内存应用上,CMS仍要优于G1,而内存更大时,则G1更占优势。衡量内存大小平衡点通常在6G-8G间。

低延迟收集器

Shenandoah收集器

Shenandoah收集器相比G1的改进之处:

  • 支持并发的整理算法,G1的回收阶段可并发,但不可与用户线程并发。
  • 默认不适用分代收集。
  • 改用“连接矩阵”代替记忆集来管理跨Region引用。 大致工作过程及示意图:
  1. 初始标记:标记和GC Roots直接关联的对象(STW)。与G1一样。
  2. 并发标记:从GC Roots开始进行可达性分析。与G1一样。
  3. 最终标记:STW,用于处理并发标记阶段少量的SATB记录。与G1一样。
  4. 并发清理:用于清理整个Region中没有一个存活对象的Region。
  5. 并发回收:首先将回收集中存活的对象复制到空的Region中。
  6. 初始引用更新:把堆中所有指向旧对象的引用修正到复制后的新地址。(并未执行,仅给每个线程分配任务)
  7. 并发引用更新:执行引用更新。
  8. 最终引用更新:修正GC Roots的引用(STW)。
  9. 并发清理:回收回收集中所有Region。 总的来说,可为三大步:并发标记、并发回收、并发引用更新。

image.png

ZGC收集器

定义:是一款基于Region内存布局的不设分代,使用了读屏障、染色体指针和内存多重映射等技术实现的可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾回收器。

ZGC内存布局的Region具有动态性-动态创建、销毁以及动态的区域容量大小,分为:

  • 小型Region:容量固定2MB,用于放置小于256KB的小对象。
  • 中型Region:容量固定32MB,用于放置大于256KB但小于4MB的对象。
  • 大型Region:容量不固定,但必须为2MB的整数倍,用于放置大于4MB的大对象。

ZGC中的相关实现原理,包括染色体指针和内存多重映射等可参考此文

ZGC大致的工作过程:

  1. 并发标记:遍历对象图进行可达性分析,并更新染色体指针中的Marked0、Marked1标志位。
  2. 并发预备重分配:根据特定查询条件统计出本次收集过程中要清理的Region。
  3. 并发重分配:把重分配集合中存活的对象复制到新的Region上,并为重分配集中每个Region维护一个转发表,记录从旧对象到新对象的转向关系。
  4. 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用。(该过程发生在下一次垃圾收集循环中的并发标记阶段,省去了一个遍历对象图的开销。) ZGC与Shenandoah一样几乎全程并发,STW也仅于GC Roots大小相关而与堆内存大小无关,因此实现了任何堆上停顿都小于10ms的目标,但缺点就是会产生大量浮动垃圾。

总结

image.png

参考文献

Shenandoah垃圾收集器
ZGC垃圾收集器
《深入理解JAVA虚拟机》

该博客仅为初学者自我学习的记录,粗浅之言,如有不对之处,恳请指正。