垃圾收集理论与算法

570 阅读9分钟

1. 引用计数与可达性分析

在堆内存中,重点关注无用对象的回收。

引用计数法(Reference Counting)是一种非常简单的判断对象是否被使用的方法。其在每个对象中添加了一个引用计数器,当该对象被外部所使用(引用)时,计数器的值加一;同理,当取消引用时,计数器的值减一。只要计数器的值大于零,都表示该对象是可用的,不能被回收。

但是,引用计数法无法解决对象之间循环依赖的问题,如下:

UselessA a = new UselessA();
UselessB b = new UselessB();
a.setB(b);
b.setA(a);

a = null;
b = null;

// 此时对象a和b实际上已经是无用的了,但它们的计数器值都为1,不能被标记为"可回收"状态

因此,在主流的Java虚拟机中都没有选择引用计数法来管理内存,而是使用的可达性分析算法(Reachability Analysis)

可达性分析是指从一系列被定义为GC Root的对象作为起始点,根据引用关系向下搜索,搜索走过的路径被称为Reference Chain。如果某个对象与GC Root之间没有任何Reference Chain相连时,则该对象即为无用对象。

Reachability Analysis的理论上可以看出,定义GC Root是非常重要的。

在Java中可固定作为GC Root的对象包括:

  • 在虚拟机栈中引用的对象;
  • 方法区中静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法中JNI所引用的对象;
  • Java虚拟机内部引用,如基本数据对应的Class对象、常驻异常对象等;
  • 所有被同步锁持有的对象;
  • 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等;

除了这些固定的GC Root外,还有可能临时性的加入一些对象。例如,新生代(概念见3.1分代收集理论)中执行垃圾收集时,新生代的对象可能会被老年代所引用。此时就需要将老年代的对象也加入到GC Root中(不一定是全量加入)。

2. 常量回收与类型卸载

《Java虚拟机规范》没有要求一定要在方法区中实现垃圾收集。不过,现在应用都大量的使用了动态代理、CGLIB字节码生成等框架,会在应用运行的过程中,动态生成类并交由虚拟机加载使用。在这样的场景下,就需要虚拟机具备类型卸载的能力,从而避免对方法区造成过大的压力。

方法区中能够回收的对象只有两类:无用的常量和不再使用的类型。

常量回收的过程跟堆中回收对象的过程和条件基本一致。但是回收类(也就是卸载类)的条件就比较苛刻了,需要同时满足以下3个条件:

  • 该类的所有实例(包括派生实例)都已经被回收;
  • 加载该类的类加载器已经被回收;
  • 该的的Class对象没有在任何地方被引用,且没有任何地方通过反射访问该类的方法;

一般情况下,方法区垃圾收集的性价比是非常低的。

3. 引用类型

用"被引用"和"未被引用"两种状态来作为垃圾收集的依据太过于绝对。例如:我们期望可用内存充足时可以保留缓存,可用内存空间不足时也可以将缓存回收掉。那么用"被引用"和"未被引用"两种状态来作为缓存的回收条件就不太合适了。

Java在1.2之后对引用的概念进行了扩展,分为:

  • 强引用(Strongly Reference) :是最传统的引用的定义。当通过new关键字所创建的引用即为强引用。在强引用关系对象处于**GC Root**可达的条件下时,垃圾收集器永远不会回收掉该对象
  • 软引用(Soft Reference) :描述有用但非必须的对象,通过SoftReference类来使用。在系统即将发生**OutOfMemoryError时,会回收掉所有的软引用对象(如果本次回收后内存空间任然不足才会产生OutOfMemoryError****)**;
  • 弱引用(Weak Reference) :其强度比软引用更弱,通过WeakReference类来使用。所有的弱引用对象会在下一次垃圾收集时回收掉
  • 虚引用(Phantom Reference) :最弱的一种引用关系,通过PhantomReference类来使用;虚引用不会影响到对象的生存时间。其唯一的作用是可以在该对象被回收时收到一个系统通知

4. "死亡"对象声明过程

经过可达性分析之后,部分对象就会被声明为"死亡"对象,不过,在声明过程中,"死亡"对象还有一次抢救的机会。

声明一个对象"死亡”要经历两次标记的过程。当发现对象与GC Root不可达时,该对象会被第一次标记。此后,虚拟机会判断对象是否有重写finalize()方法且该方法未被虚拟机执行过。若达成条件,则该对象会被虚拟机加入到一个被称为F-Queue的队列中,并以优先级非常低的Finalizer线程执行该对象的finalize()方法,但并不保证方法能够完整的执行(以避免finalize()方法存在非常慢的代码或死循环)。稍后,虚拟机会对F-Queue中的对象进行第二次标记,如果对象没能在finalize()方法中成功"拯救"自己(例如将this赋值给某个变量),那它就会被真正的声明为"死亡"。

finalize()方法只会被虚拟机调用一次,也就是说,对象"自救"的机会也只有一次。

注意:finalize()方法目前已经被官方明确声明为不推荐使用的语法,不要再去使用了。

5. 分代收集理论

现代垃圾收集器大多都遵循了"分代收集"的理论进行设计。"分代收集"的理论主要包含三个假说:

  • 若分代假说:绝大多数对象都是朝生夕灭的;
  • 强分代假说:绝大多数对象都是朝生夕灭的;
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数;

根据"分代收集"理论所设计的Java虚拟机,一般会把堆内存划分为新生代(Young Generation)和老年代(Old Generation)两个区域。每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

同时,由于堆的划分,导致了"部分收集(Partical GC)"的出现。Partical GC包含以下几种类别:

  • Minor GC/Young GC:指目标只是新生代的垃圾收集;
  • Major GC/Old GC:指目标只是老年代的垃圾收集,目前只有CMS垃圾收集器有单独的老年代回收行为;
  • Mixed GC:指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为;
  • Full GC:收集整个Java堆和方法区的垃圾收集;

6. 标记-清除算法

是最早出现,也是最基础的垃圾收集算法。该算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。

该算法有两个主要的缺点:

  • 执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;

  • 会产生内存碎片:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存,如下:

图一.png

3.3 标记-复制算法

属于一种"半区复制"的垃圾收集算法。其将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

其能够产生"规整"的内存空间,在给新对象分配内存空间时,可以使用简单高效的"指针碰撞(Bump The Pointer) "方式。不过其将可用内存缩小了一半,浪费了大量的存储空间。

现代Java虚拟机大多采用了这种算法来实现新生代的垃圾收集。根据IBM公司的研究,新生代中的对象有98%熬不过第一轮收集。因此,一般情况下不会按照1:1的比例来划分新生代的存储空间。

HotSpot虚拟机中的SerialParNew等新生代垃圾收集器会按照8:1:1的比例将新生代划分为较大的一块Eden区和较小的两块Survivor区。每次分配内存只使用Eden和其中一块Survivor,并在发生垃圾收集时,将EdenSurvivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot还有一个被称为"逃生门"的机制:当Survivor区不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。

3.4 标记-整理算法

其工作过程跟"标记-清除"算法一致,也分为"标记"和"清除"两个步骤。但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,如下图所示:

image.png

移动存活对象虽然能够产生"规整"的内存区域,但在大量对象存活的情况下(如老年代垃圾收集),移动这些对象并更新其对应的引用是一项负担非常大的操作。