什么是垃圾回收(Garbage Collection,GC)?

在程序的运行过程中,我们所创建的对象都会申请内存资源,当这个对象没有用处的时候,我们就需要将它的内存资源释放,否则造成内存资源的浪费,所以我们需要对内存资源进行管理。

在C/C++语言中,没有自动的垃圾回收机制,通过手动申请内存资源及手动释放内存资源来达到内存资源的控制,如果没有手动释放资源,则申请的对象占用的内存资源会一直占用,最终可能会导致内存溢出。

为了使得我们可以更专注与代码的实现而不用过多的考虑内存资源释放的问题,就需要一个垃圾回收机制来帮助我们在合速的时间,进行对垃圾对象的内存资源回收,将垃圾对象的内存资源释放。

什么对象是垃圾

我们创建的对象实例都是放在Java堆中的,所以垃圾收集器的大部分时间也是在堆中的,垃圾收集器会判断堆中的哪些对象还在使用,哪些对象是垃圾,可以被回收。

方法区中也可以进行垃圾收集,回收废弃常量及无用的类,但是方法区中的垃圾收集性价比太低,所以在Java虚拟机规范中也不要求虚拟机在方法区实现垃圾收集。

引用计数法

引用计数法是一个很经典的垃圾收集算法,它的实现很简单,就是给对象添加一个引用的计数器,有其他地方引用它时,计数器的值就增加1,当引用失效时,这个引用就减1。当对象的引用计数器为0时,这个对象就是不可能再被使用的状态。

如上图,对象A不再引用对象B后,对象B的引用计数器就会减1且到达0值,这时对象B不会再被其他任何对象使用到了,所以对象B需要被回收。

引用计数法的判断效率很高,在大部分情况下效果其实都不错,但是它确有个大问题,也就是引用计数法无法解决对象之间相互循环引用的问题。

如上图,实际上对象B和对象C已经不会再被使用到了,但是对象B和对象C的引用计数器都不为0。

可达性分析法

在Java虚拟机的主流实现中,一般都是通过可达性分析来判断对象是否为垃圾。可达性分析法的基本思路为通过一系列被称为GC Roots的对象为起始点,从这些节点向下搜索,搜索到的路径称为引用链,如果一个对象到GC Roots没有任何引用链时,则说明这个对象是不可用的对象,可以被回收。

如下图,对象D、对象E、对象F虽然有引用关系,但是他们到GC Roots没有任何引用链,是不可达的对象,所以它们会被判断为可回收的对象。

在Java中,可作为GC Roots的对象包括下面几种:

  • Java栈和本地方法栈中引用的对象。
  • 方法区中类静态属性和常量引用的对象。

引用

在判断对象是否是垃圾时,无论是引用计数法和可达性分析法,都是与引用有关。如果一个对象只有被引用和没有被引用两种状态,那么我们对于对象的引用操作空间就很小,例如我们希望在内存空间充足的时候保存一些对象,但是在内存资源比较紧缺的时候可以抛弃这些对象,这样的应用场景可以用在缓存上。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为了强引用、软引用、弱引用、虚引用4中引用。

  • 强引用就是我们最常使用的引用,例如Object object = new Object就是强引用。
  • 软引用用来描述在内存溢出前可以丢弃的对象,可以使用SoftReference类实现软引用。
  • 弱引用用来描述下一次GC被回收的对象,可以使用WeakReference类实现弱引用。
  • 虚引用是最弱的引用关系,一个对象是否有虚引用都不会对它的生命周期造成影响,虚引用的唯一目的就是在对象被回收时会受到通知,可以使用PhantomReference类实现虚引用。

对象复生

如果对象实现了finalize()方法,且该方法还未被虚拟机调用过,那么这个对象在回收前会被加入F-Queue的队列中,在稍后由一个虚拟机建立的低优先级的Finalizer线程去执行。但虚拟机虽然会触发这个方法,但并不保证这个方法的执行结束,防止这个方法的执行过程太慢或者发生死循环等情况导致队列中的其他对象处于等待。

对象的finalize()方法只能被调用一次,如果在下一次被回收时对象的finalize()方法已经被调用过了,那么该方法不会再次执行,而且这个方法也是不建议使用的方法,我们往往有更好的方案来替代它。

垃圾收集算法

各个平台的虚拟机操作内存的方法细节可能会有不同,但是垃圾收集的算法思路都是类似的。

标记清除法

标记清除法是垃圾回收算法中的思想基础,标记清除法将垃圾回收分为标记和清除两个阶段。首先标记出从根节点开始可达的对象,未标记的对象就是可回收的对象,然后将这些对象进行回收。

但是标记清除法的最大问题就是会产生大量不连续的空间碎片,如下图所示,回收后的空间是不连续的:

复制算法

复制算法算法将原有的内存空间分为两块,每次只使用其中的一块,在垃圾回收时,将还存活着的对象复制到另外一块上面,然后将这一块内存空间的所有对象清除。

如果垃圾对象很多,需要复制的存活对象数量就会相对较少,因此复制算法的效率还是很高的,并且将对象复制到新的内存空间中也保证了不会有空间碎片的存在;但是复制算法的代价是将内存折半。

如下图,将内存空间分为A、B两块,在A进行垃圾回收时,将A空间中的存活对象复制到B中,并将A空间清空:

在大多数时候采用复制算法来回收年轻代中的垃圾对象,而且因为大部分的对象死亡周期非常短暂,所以可以不用直接将内存折半,而是将内存使用8:1:1的比例分为了一个较大的Eden空间及2块Survivor空间。

当进行垃圾回收时,将Eden空间和Survivor空间中存活的对象复制到另一块Survivor空间上,然后清除Eden空间和刚刚使用的Survivor空间。每次使用的内存比例达到了百分之九十,只有百分之十的Survivor空间作为被复制的内存空间,每次复制后,两个Survivor空间的角色互换。

当Survivor空间的内存不够将对象复制进来时,需要老年代进行分配担保。也就是另外Survivor空间没有足够的内存存放收集到的存活对象时,这些对象直接会进入老年代。

标记压缩法

复制算法如果在对象的存活率较高时,效率就会变低,在老年代中,大部分对象都是存活对象,所以复制算法的成本就会提高,所以基于老年代对象的特性,就需要使用其他算法。

基于老年代对象的特性,标记压缩法是在标记清除法的基础上优化得来的一直垃圾回收算法。和标记清除的过程一样,首先标记存活的对象,但之后并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端。之后清理边界外的所有空间。

这种方法避免了空间碎片的产生,也不需要两块内存空间,因此性价比较高,标记压缩法的过程如下图所示:

垃圾收集器

在Java虚拟机中,在什么情况下要使用什么类型的垃圾收集器以及这些垃圾收集器会造成什么样的影响,这是是我们需要了解的,只有了解了这些垃圾收集器的特点和使用方法,我们才能在具体的应用中使用合适的垃圾收集器。

串行收集器

串行收集器是使用单线程进行垃圾回收的垃圾收集器,并且在进行垃圾回收时,必须暂停其他所有的工作线程,知道串行收集器的垃圾回收结束。

串行收集器可以在年轻代及老年代的内存空间中进行垃圾回收。

如下图所示,串行收集器在工作时,应用程序中的所有线程都需要停止工作,这种现象被称为Stop The World

年轻代的串行收集器采用复制算法进行回收,老年代的串行收集器采用标记压缩法进行回收,串行收集器的实现相对简单且不用进行线程切换。在单CPU的硬件下性能还不错。

并行收集器

并行收集器是串行收集器的多线程版本,并行收集器使用多个线程同时进行垃圾回收,对于并行能力较强的硬件情况下可以提高性能。

并行收集器同样可以在年轻代及老年代的内存空间中进行垃圾回收,回收策略与使用的算法都与串行收集器一样。

并行收集器工作过程如下图所示:

并行收集器由于存在线程交互的开销,在单CPU的环境中,串行收集器的效果会更好。但是随着CPU数量的增加,并行收集器的效率也会随之提升。

Parallel收集器

Parallel收集器也可以在年轻代及老年代的内存空间中进行垃圾回收,回收策略与使用的算法都与并行收集器一样,且也是多线程的垃圾收集器。

Parallel收集器的特点是它的关注点与其他收集器不同,Parallel收集器的目标则是达到一个可控制的吞吐量。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

可以在JVM参数中配置Parallel收集器相关的参数进行控制。

CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,且在可以在应用程序运行过程中进行垃圾回收。

CMS收集器是基于标记清除法实现的,它的运作步骤如下:

  • 初始标记,标记根对象
  • 并发标记,标记回收对象
  • 预处理,清理垃圾对象前的装备及控制停顿时间,也可以关闭预处理步骤
  • 重新标记,修改并发标记中的对象
  • 并发清除,清除垃圾

其中,初始标记、重新标记这两个步骤仍然需要Stop The World

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作的,下图比较清楚地展示了CMS收集器的运作步骤中并发和需要停顿的时间:

CMS是一款优秀的收集器,它的主要优点就是并发收集及低停顿。但是CMS对CPU资源的要求较高且会出现大量的空间碎片。

G1收集器

G1收集器对内存进行了分区,虽然也会区分年轻代和老年代,但是G1对内存中的对象进行分区的回收,同时,G1收集器的特点如下:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,并且G1收集器的部分工作可以和应用程序同时运行。
  • 分代收集:G1收集器能够同时管理年轻代和老年代,其他收集器都需要根据算法的不同为年轻代和老年代使用不同的垃圾收集器。
  • 空间整理:G1从整体来看是基于标记压缩法实现的收集器,从分区上来看是基于复制算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行。
  • 可预测性:G1可以只选择部分分区进行垃圾回收,达到对全局停顿的控制。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代。而G1收集器的Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立分区(Region)。

G1收集器的内存模型如下:

在G1收集器中,使用记忆集合(Remembered Set,简称RSet)来避免全堆扫描。每个分区都有一个RSet,程序在对引用对象类型的数据进行写操作时,会产生一个Write Barrier(屏障)暂时中断写操作,检查这个引用的对象是否处于不同的分区之中;如果是,便把相关引用信息记录到被引用对象所属的分区的RSet之中。进行内存回收时,在GC根节点的枚举范围中加入RSet即可保证不对全堆扫描也不会有遗漏。

每个分区会被分为多个Card,所以在RSet中会记录对应分区中的Card,如下图所示

G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记,标记根对象
  • 并发标记,标记回收对象
  • 重新标记,修改并发标记中的对象
  • 分区清理

其中,初始标记、重新标记这两个步骤和CMS收集器一样仍然需要Stop The World,在重新标记阶段会重新更新对象的记忆集合(Remembered Set),最后在分区清理阶段首先对各个分区的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,根据区域的优先级进行回收。