对Java虚拟机的系统性学习: 垃圾回收算法与垃圾回收器

概述

什么是垃圾?一般指运行过程中没有被任何对象所引用的对象。内存中不使用的对象如果不被回收,那么将一直占用内存,而内存在资源是有限的,因此应运而生垃圾回收技术,这也是Java相较于C/C++最重要的区别。

一般垃圾回收技术都会分为两个阶段,其一是对垃圾进行标记,其二是对垃圾对象清除。

垃圾标记算法

垃圾标记即判定对象是否被其他对象所引用,一般有引用计数法与可达性分析算法两种。 其中引用计数法在对象被引用时会在其内部设置一个引用计数器,当被引用加一,反之减一,当被引用的数量为0时,即为垃圾。其优点在于实现简单、效率高,但需要为每个对象去维护一个计数器将占用很多空间,以及系统将频繁的修改计数器数字带来的性能消耗。最重要的是其无法解决两个对象互相引用产生的问题。

比如Python就使用了引用计数法,但其内部通过一些措施规避掉了互相引用问题,而Java并未采用。

而可达性分析算法的思路是根据系统内活跃的引用对象组成一个"GC Roots"集合,以此为起点对其引用对象进行搜索,其中一般包括虚拟机栈(栈桢中的本地变量表)中的引用的对象,以及方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象。

因此所有被搜索到对象被标记为可达对象,反之不被标记的对象即为不可达对象。以此就完成了垃圾标记操作,其后会枚举堆中所有对象,并将不可达对象存入待清除对象队列之中。其最大的优点便是简单与高效,且无循环引用问题。Java和C#均采用该算法。

不过,要想使用可达性分析算法则必须要保证一个能保证一致性的快照中,否则将无法保证其结果准确性。这是垃圾回收将导致 stop the world 的原因,即使是号称不会停顿的垃圾收集器在标记时其实也会停顿。

finalize

当JVM开启进行垃圾回收时,一般会有两次垃圾标记。当第一次判定对象为不可达对象的时候会去查看是否重写了finalize方法,如果重写则会由Finalizer线程去触发对应的finalize方法。在此期间可以对对象进行收尾操作,如关闭文件套接字、数据库连接,但最有意思的是可以在此方法中为该对象复活。

主要复活的方法是在方法中让其他的可达对象去引用当前对象,使当前对象变成可达对象。因此在第二次进行垃圾标记的时候会发现当前对象成为了可达对象,便会将其移除待清除对象队列。不过该方法在整个生命周期中只允许执行一次。

具体落地算法

标记-清除算法 mark-sweep

首先使用可达性分析去标记所有可达对象,其后从头到尾遍历堆内存空间判断对象是否为可达对象,如果为不可达对象则直接收回此对象对应的空间。

因此该算法最大的缺点就是由可达性分析和堆内存空间产生了两次遍历带来的效率问题。其次是直接回收对象空间产生了空间不规整问题,因此不得不需要维护一个空闲空间表去维护哪些地址的区间是可供占用的。

复制算法 coping

该算法主要将内存空间分成两份,每次仅使用一半,当触发GC后将A空间可达对象依照空间顺序的方式移至B空间,再对A空间作以整体回收,以此循环往复。

其优点在于高效简单,且空间规整无碎片。但缺点便是需要消耗两倍的空间,且GC后所有对象的地址都会发生改变,后续还要更改其他对象引用该对象的指向地址。

因此如果想要保证复制算法的高效,则要保证其空间内可达对象的数量偏少,否则其效率并不能称之为高效。

标记-整理算法 mark-compact

其主要是在标记-清除算法之上的改进。其首先标记空间内可达对象,其后将可达对象移动到空间的起始位置,依照顺序存放,其后回收可达对象边界外的空间。

其优点在于空间规整且无碎片,但因为其移动对象导致需要另外进行对该对象的引用对象的地址指向的修改任务。

分代收集算法

在Java中,不同的对象有着不同的生命周期,因此我们可以根据生命周期的不同特性去选择合适的算法。目前几乎所有的垃圾回收算法都是这样。Hospot据此为堆空间划分出了年轻代和老年代,年轻代对应着生命周期短、存活率低、回收频繁,适合使用复制算法的对象。而老年代中的对象则存放生命周期长、存活率高的对象,那么其就适合搭配标记清除及标记整理算法进行使用。

不过具体的算法落地,则要看对应的垃圾回收器是什么。

垃圾回收器

垃圾回收器有很多种,以应对不同的场景。其中有一些特点,作以特征归类:

  • 串行于并行,指回收线程
  • 并发式与抢占式,是否产生stop the world
  • 压缩与非压缩,是否产生内存碎片
  • 年轻代与老年代,根据不同的分代作以划分

对于回收器的评价标准大多有两种,其一为吞吐量 用户线程执行时间 / 用户线程执行时间 + 垃圾回收消耗时间,其二为暂停时间。要想增加吞吐量,则必须要减少GC次数,即加大堆空间大小,但相应的会导致每次GC出现更长的暂停时间。因此二者不可兼得。而对于JVM调优的标准而言,就是在吞吐量优先的情况下,降低停顿时间。

Serial GC

较为古老的垃圾回收器,其负责新生代的垃圾回收,使用复制算法,单个垃圾回收线程。并提供基于标记整理的Serial Old 老年代垃圾回收器。一般由于其单线程将会产生非常久stw的缘故,并不会使用它。

Par(Parallel) New GC

Serial基础上的并行版本,可设置线程数量。具体实现都与Serial相同。

Parllel Scavenge GC

在ParNew GC的基础上实现了”自适应调节策略“, 自动根据系统状态去调节堆空间的分配,以此可以设置自己预期的吞吐量及暂停时间。另外其提供了使用标记整理的Parallel Old GC。

CMS GC(老年代)

第一个真正意义上的并发收集器,使用标记清除算法,允许垃圾收集线程与用户线程同步执行。因此其最大特点就是低延时,几乎没有暂停时间。

  1. 初始标记,找到GC roots中所有的根对象(出现短暂stw)
  2. 并发标记,在用户线程运行的同时去根据GC roots去标记可达对象
  3. 重新标记在并发标记期间由不可达对象变为可达对象的对象(出现短暂stw)
  4. 并发清理,在用户线程运行的同时去清理对应的垃圾对象。由于其与用户线程同步进行,所以在此期间用户线程仍会产生垃圾对象,该对象称之为浮动垃圾,但无法清除,只能等待下次GC

G1 GC

// TODO
区域化分代,将新生代与老年代整体的内存空间划分为一个个小的区域,每次仅对一小块区域做回收。其在JDK7中提供,在JDK9默认启用。在提高吞吐量的前提下延迟可控。对应的空间分配依赖于设定的“最大停顿时间”而调整。

杂乱的概念

安全点与安全区域

程序只能运行在特定的位置才可以进行暂停去做GC,其成为安全点.一般指令的执行很短暂,因此安全点的选择一般是在指令执行所需时间较长的时候,一般是方法调用、循环跳转、异常跳转。

当线程因为被挂起(睡眠)时无法执行到对应的安全点,所以此时需要声明当前线程处于安全区域,JVM会忽略标识此线程。但同时如果线程被唤醒了,但也仍需等待GC线程执行完毕的信号。

对象的引用类别

对象不同的引用类别对应着当发生GC时是否被回收。

  • 强引用,对象默认引用,除非被定义为垃圾,否则不回收
  • 软引用,当执行一次GC后空间仍然不足,则会回收所有软引用对象,使用 SoftReference 进行包装
  • 弱引用,只要发生GC就会被清除,适合保存可有可无的缓存数据,使用WeakReference进行包装
  • 虚引用,仅作为在对象被回收时定义一些时间而使用,如记录回收时间、资源释放
# JavaJVM