浅谈垃圾收集器(GC)

浅谈垃圾收集器(GC)

1 思考

​ 《深入理解JVM虚拟机》中这样说道:“Java和C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙”。显然Java中的一个最大特性就是垃圾收集,垃圾收集这一技术诞生的作者思考过垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?

  • 什么时候回收?

  • 如何回收?

    我们从这三个问题去真正认识一下垃圾回收器,以及为什么这堵墙墙外的人想进去,墙里面的人想出去?

2 JVM运行时数据区域

在这里插入图片描述

​ 如上图所示(源自网络),本地方法栈、虚拟栈、程序计数器三个区域随线程而生,随线程而灭,这几个区域内不需要过多考虑如何回收的问题。而Java堆和方法区(1.8后变成了元空间和直接内存)这两个区域有着明显的不确定性:一个接口的多个实现类需要内存可能不一样,一个方法的不同分支所需内存也可能不一样,垃圾收集器所关注的正是这部分内存该如何管理。

3 对象已死?(哪些对象需要回收)

​ 在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象哪些还“存活着”,哪些已经“死去”,然后再对死去的对象进行回收。

3.1 引用计数算法

​ 在对象中添加一个引用计数器,每当在一个地方引用时,计数器就加1,失效时就减1;任何时候计数器为0的对象就是不可能再被使用的对象。

缺点:尽管它的原理简单,但是缺点也很明显,比如单纯的引用计数器算法无法确定相互循环引用的问题。因此,在Java领域里主流的Java虚拟机没有选用引用计数器算法进行内存管理。举个例子:

public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;

    /**
     * 这个成员方法的意义就是占用内存,以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        // 假设在这行发生GC,objA和objB是否会被回收?
        System.gc();
    }
}

结果:

在这里插入图片描述

从结果可以看出虚拟机并没有因为它们相互引用就放弃回收。

3.2 可达性分析算法

​ 当前主流的应用程序语言的内存管理都是采用可达性分析算法来判断对象是否存活。这个算法的基本思想就是通过一系列的“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,如果某个对象到GC Roots之间没有任何引用链相连,则证明此对象不能再被使用。

在这里插入图片描述

如上图,object5、object6、object7虽然相互之间有关联,但是到GC Roots是不可达的,因此判断他们为可回收的对象。

3.2.1 可做GC Roots的对象

​ 在Java体系中,固定可做GC Roots的对象包括以下几种:

  • 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用的参数、局部变量、临时变量等。
  • 在方法区中静态属性的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池里的引用。
  • 在本地方法栈中JNI(即Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的class对象,还有一些常驻的异常对象,系统类加载器。
  • 所有被同步锁持有的对象。
  • 反应Java虚拟机内部情况的jmxBean,本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前会后的内存区域不同,还可以有其他对象“临时性”的加入,共同构成完整GC Roots集合。

3.3 再谈引用

​ 无论是通过什么方法来判断对象是否存活,都和“引用”离不开关系。在jdk1.2之前,java中的引用还是很传统的定义:如果reference类型的数据中存储的数值是另一块内存的起始地址,就称该reference数据代表某块内存、某个对象的引用。

​ 在jdk1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用。这4中引用强度依次减弱。

  • 强引用:最传统引用的定义:Object obj = new Object()这种,无论什么情况,只要强引用关系还在,垃圾收集器就永远不会回收这些被引用的对象
  • 软引用:描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:也是描述非必须对象,但强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾回收器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用:它是最弱的一个引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。它的存在唯一目的就是能够在这个对象被回收时给一个系统通知。

3.4 真正判刑!

​ 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”,这种时候还处于“缓刑”阶段,要真正宣告一个对象的死亡,至少要经过两次标记过程:

  1. 不可达为第一次标记

  2. 判断是否有必要执行finalize()方法:假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机都将视为没有必要执行。

    如果这个对象被判定为确有必要执行finalize()方法,那么该对象会被放到一个F-Queue的队列中,稍后为有一个finalizer线程对其进行finalize()方法。

以上就是真正判刑了,不过对象还可以在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 Throwable{

        SAVE_HOOK  = new FinalizeEscapeGC();

        // 对象的第一次成功自救
        SAVE_HOOK = null;
        System.gc();
        // 暂停0.5s,等待Finalizer方法执行,
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("No, I am dead :)");
        }

        // 对象的第二次成功自救:失败!因为finalize方法只会被系统自动调用一次
        SAVE_HOOK = null;
        System.gc();
        // 暂停0.5s,等待Finalizer方法执行,
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("No, I am dead :(");
        }

    }
}

结果:

在这里插入图片描述

可以注意到,第二次自救失败了,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次。(PS:周老师让我们忘掉这个方法)

4 垃圾回收算法(什么时候回收)

​ 同样,垃圾回收算法也可以划分为两大类:引用计数式垃圾回收和追踪式垃圾回收。第二个是主流Java虚拟机中使用的,因此主要介绍下第二种。

4.1 分代收集理论

​ 当前商业虚拟机的垃圾收集器,大多数遵循“分代收集”的理论进行设计,它建立在两个分代假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。

这两个分代假说共同奠定了多款常用垃圾收集器的一致设计原则:收集器应该将Java堆划分为不同的区域,然后将回收对象按照其年龄分配到不同的区域之中存储。比如在新生代中,每次收集都会有大量对象的死亡,所以可选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾回收。

​ 不同分代的名词定义:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。目前只有G1有这种行为。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

4.2 标记-清除算法

​ 最早出现的垃圾收集算法就是“标记-清除算法”,首先标记出需要回收的对象,然后在标记完成后统一回收所有被标记的对象。也可以反过来标记存活的对象,统一回收所有未被标记的对象。

缺点:

  • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分都是需要被回收的,这时就需要大量标记清除,导致这两个过程执行效率随着对象数量增长而降低;
  • 内存空间碎片化问题,标记、清除后产生大量不连续的内存碎片。

图示:

在这里插入图片描述

4.3 标记-复制算法

​ 为了解决标记-清除算法的效率低问题,1969年Fenichel提出一个“半区复制”垃圾回收算法,它将可用内存按照容量划分为大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。不过这种算法的缺点也显而易见,空间浪费太多了一点。执行过程如下:

在这里插入图片描述

​ 现在商用的Java虚拟机大多都优先采用这种收集算法去回收新生代。

4.4 标记-整理算法

​ 标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

​ 针对老年代对象的存亡特征,1974年Edward提出了另外一种有针对性的“标记-整理算法”,其标记过程仍和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。它与标记-清除算法的本质差异就是标记-清除算法是一种非移动式的回收算法,而标记-整理算法是移动式的。示意图如下:

在这里插入图片描述

5 垃圾收集器(如何回收)

堆空间的基本结构如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hXpP7eNh-1625618569941)(img/01d330d8-2710-4fad-a91c-7bbbfaaefc0e.png)]

如上图,Eden区,From Survivor区,To Survivor区都属于新生代,Old Memory区属于老年代。

​ 大部分情况下,对象首先会在Eden区域分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。如果对象还存活就会进入s0,并且对象的年龄加1,当年龄增加到一定程度(默认15岁),就会被晋升到老年代中。然后将Eden区和s1区全部删除,等下一次快满了的时候再将s0区和Eden区所有的对象进行打标,将存活的放入s1区,然后删除s0区和Eden区,如此反复工作。

  • 大对象直接进入老年代:避免大对象分配内存由于分配担保机制带来的复制而降低效率。
  • 长期存活的对象将进入老年代

垃圾收集器主要有如下几种:

在这里插入图片描述
(本文图大部分来自于JavaGuide,很好的一个面经博客)

5.1 Serial收集器

​ Serial收集器是最基础、历史最悠久的收集器,曾经是Hotspot虚拟机新生代收集器的唯一选择。这是一个单线程工作的收集器,它再进行垃圾回收时必须暂停其他所有工作线程,直到它收集结束。即“Stop the World”。

5.2 ParNew收集器

​ ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为和Serial收集器一样。

5.3 Parallel Scavenge收集器

​ 也是采用标记-复制算法的多线程收集器。它与ParNew收集器不同的是它关注点是吞吐量(高效的利用CPU)。CMS等垃圾收集器关注点更多的是用户线程的停顿时间(提高用户体验)。

5.4 Serial Old 收集器

​ Serial Old收集器是Serial收集器的老年代版本。

5.5 Parallel Old收集器

​ Parallel Scavenge收集器的老年代版本。

5.6 CMS收集器

​ CMS收集器是一种以获取最短回收停顿时间为目标的收集器。非常符合在注重用户体验的应用上使用。

5.7 G1收集器

​ G1(Garbage-first)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。

扩充:CMS收集器和G1收集器

  • CMS(Concurren Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,基于标记——清理实现。仅作用于老年代收集。步骤如下:

    1. 初始标记:独占CPU,stop-the-world,仅标记GCroots能直接关联的对象,速度比较快;
    2. 并发标记:可以和用户线程并发执行,通过GCroots Tracing标记所有可达对象;
    3. 重新标记:独占DPI,对并发标记阶段用户生成的垃圾对象进行标记修正,以及更新逃逸对象;
    4. 并发清理:可以和用户线程并发执行,清理在重复标记中被标记为可回收的对象。
  • CMS的缺点:

    • CMS收集器对CPU资源非常敏感,在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分CPU资源,如果在CPU资源不足的情况下应用会有明显的卡顿。
    • 无法处理浮动垃圾:在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收。如果在清理过程中预留给用户线程的内存不足就会出现‘Concurrent Mode Failure’,一旦出现此错误时便会切换到SerialOld收集方式。
    • CMS清理后会产生大量的内存碎片,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发FullGC。且在1.9后将其废除
  • G1收集器

    G1收集器的内存结构完全区别于CMS,弱化了CMS原有的分代模型,将堆内存划分成一个个Region,这么做的目的是在进行收集时不必在全堆范围内进行。它主要特点在于达到可控的停顿时间,用户可以指定收集操作在多长时间内完成,即G1提供了接近实时的收集特性。它的步骤如下:

    1. 初始标记(Initial Marking):标记一下GC Roots能直接关联到的对象,伴随着一次普通的Young GC发生,并修改NTAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,此阶段是stop-the-world操作。
    2. 根区间扫描,标记所有幸存者区间的对象引用,扫描 Survivor到老年代的引用,该阶段必须在下一次Young GC 发生前结束。
    3. 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行,该阶段可以被Young GC中断。
    4. 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,此阶段是stop-the-world操作,使用snapshot-at-the-beginning (SATB) 算法。
    5. 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,回收没有存活对象的Region并加入可用Region队列。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

    G1的特点

    • 并行与并发:G1充分发挥多核性能,使用多CPU来缩短Stop-The-world的时间,
    • 分代收集:G1能够自己管理不同分代内已创建对象和新对象的收集。
    • 空间整合:G1从整体上来看是基于‘标记-整理’算法实现,从局部(相关的两块Region)上来看是基于‘复制’算法实现,这两种算法都不会产生内存空间碎片。
    • 可预测的停顿:它可以自定义停顿时间模型,可以指定一段时间内消耗在垃圾回收商的时间不大于预期设定值。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值