一文带你入门垃圾回收算法

305 阅读8分钟

[toc]

前言

Java与C++之间有一堵内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想要进去,墙里面的人却想出来。

前置知识

垃圾回收算法和垃圾回收器的区别
  1. 垃圾回收算法就是下面讲解的标记-清除、标记-复制、标记-整理算法。
  2. 垃圾收集器是垃圾回收算法的具体实现,Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大的差别。
Stop-the-world

Stop-the-world是指在等待所有用户线程进入安全点后并阻塞,做一些全局性操作的行为。就是暂停所有的用户程序,执行一些JVM的操作。

1、如何判断对象是否需要被回收

一个Java程序,以SpringBoot项目为例,里面有若干的类,那么如何判断这个是否是垃圾呢,或者说达到什么样的条件才会被回收呢,有一位大牛 John McCarthy 就提出了以下三个问题:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

先来看看判断对象是否需要回收的两个算法,一个是引用计数算法,另一个是可达性统计算法(现在JVM使用的)

1.1 引用计数算法

你可以简单的理解为,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一,当引用失效时,计数器值减一;任一时刻当计数器为0的对象就是不可能被使用的对象。这个算法远离简单,判定效率也高,但是它很难解决对象之间循环依赖的问题。

循环依赖:

  • 你有一个对象A,在A里面你又引用了B对象。
  • 你还有一个对象B,在B里面又引用了A对象。 这将会导致A、B的计数器始终不能为0,也就不能被回收,下面有一个简单的示意图:

image.png

  1. 蓝色圆圈是活动的对象,里面的数字表示他们的引用计数
  2. 灰色圆圈是没有被任何仍在使用中的对象引用的对象(这些对象直接被绿云引用)。因此,灰色对象是垃圾,可以由垃圾收集器清理。
  3. 可以看到还有一个被红色圈出来的循环依赖,这部分不可能被回收掉。
1.2 可达性统计算法

可达性统计算法的流程是大致可以描述为一个BFS/DFS搜索的过程(或者图的遍历过程),大概流程是:

  1. 将所有的GC Roots 里面的点加入队列
  2. 枚举队列里面对象的所有引用链关系,将没有没搜到的点加入队列,并将搜过的点打上标记
  3. 重复步骤2,直到队列为空。 此时内存中的对象,就会分为有标记和没有标记的对象。对于那些没有标记的对象就是应该被回收的。

image.png 对于GC Roots对象大概有如下几种:

  • 当前各线程执行方法中的局部变量(包括形参)引用的对象
  • 已被加载的类的 static 域引用的对象
  • 方法区中常量引用的对象
  • JNI 引用

即使被改算法判断为不可达对象,也不意味着它非死不可,它有一次自救的机会。离它死亡至少要经历两次标记。如果对象在可达性分析后发现没有有GC Roots 对象的直接或者间接连接时,那么它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果没有覆盖该方法,或者已经被执行过,就不再执行。执行完成后还没有逃脱,那么基本上就要被回收了。

2.分代假说

分代假说,实质是一套符合大多数程序运行实际情况的经验法则,它建立在如下三个法则上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。

  2. 强分代假说:熬过越多次垃圾收集的过程的对象就越难以消亡。 按照这如上两个规则,于是将Java 堆 划分出不同的区域,通常叫做:新生代(Young Generation)和老年代(Old Generation)顾名思义,新生代中,每次回收都有大量的对象死亡,经历多次没有被回收的对象就会进入老年代。懂事儿的朋友已经想到了,如果老年代和新生代对象有引用关系会怎么办?我们可以由1、2假说可得到第三条:

  3. 跨代假说引用:跨代引用相对于同代饮用来说只占及少数(很容易理解,如果有跨代引用,老年代不死,新生代的引用/被引用对象也不会死,熬过几轮后,也会晋升到老年代,变成同代引用)

对于如何找到那些跨代引用,最简单的办法是我们可以扫描老年代去找,这样的代价太大了。有几种解决办法(记忆集——Remember Set、卡表)等,此文章作为入门不做介绍,还有三染色算法、如何解决并发收集过程中的问题留到后续的文章。

3.垃圾回收算法

下面开始介绍3种回收的算法,他们之间各有优劣,适用于不同的场景。

3.1 标记-清除算法

从算法的名字可以看出它分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然当每次回收的对象多的时候,也可以反过来操作,标记存活的对象,没有标记的都清理掉。

image.png 这个算法的主要的缺点有:

  1. 执行效率不稳定,如果Java堆中包含大量的对象,且大量对象都需要回收的时候,这个时候需要进行大量的标记和清除的动作,它和堆中对象的大小成正比增加。
  2. 然后就是内存碎片化的问题,可以看到上图中,会产生许多不连续的内存碎片,当碎片过多的时候,来了一个大对象存不下去。就不得不触发另一次的垃圾回收动作。
3.2 标记-复制算法

复制算法是为了解决标记-清除面对大量可回收对象时执行效率低下的问题。换句话说,你标记-清除不是会产生大量碎片嘛,那我就把可用内存一分为二,始终只有一半空间可用,另一半空闲。然后将存活的对象进行标记,将标记的对象复制到没有使用的那一半去,然后清理掉当前这一半内存。话不多说上图:

image.png 引入一个新方法解决老问题,那么就必然也要接受新方法带来的问题,或者优化、解决新方法带来的新问题。可以看到内存碎片解决了,但是可用的空间却减少了一半,这样未免太浪费了一点,对此IBM公司曾有一项专门研究新生代的对象存活的问题———新生代的对象有98%活不过第一轮收集,因此完全不需要按照1:1的比例去划分新时代空间。

经过大佬们的研究提出了一种半区复制分代策略,具体做法是把新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次内存分配的时候只使用Eden和一块Servivor区域。发生垃圾收集时,将这两块区域的存活的对象复制到另一块没有使用的Servivor区上,如果复制过程中,存活对象大于没有使用的Servivor区,则一部分直接晋升至老年代。HotSpot虚拟机的默认Eden和Servivor默认比例是8:1,相对于内存总共分成10份,Eden区占8份,Servivor各占一份。可以通过

-XX:SurvivorRatio=6 // 调节Eden和Servivor比例

image.png

image.png

3.3 标记-整理算法

针对老年代对象存活的特点,提出了一种有针对性的算法:标记-整理,它的标记阶段和标记-清理阶段一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

image.png 可以看到要移动对象,那么相应对象的地址会被改变,那就需要更新所有引用这些对象的指针,这是一个非常耗时的操作,而且这些对象移动必须全部暂停用户应用程序才能进行也就是前面说到的Stop-the-world。 这三种算法各有千秋,适用于不同的场景,后面在介绍垃圾回收器的时候还会讲到。

参考文献:

  1. 《深入理解Java虚拟机》周志明老师
  2. 《HotSpot JVM 内存管理》javadoop.com/post/jvm-me…
  3. 《Java中9种常见的CMS GC问题分析与解决》(tech.meituan.com/2020/11/12/…)
  4. 《Java Garbage Collection Handbook》plumbr.io/handbook/wh…