Java 的垃圾回收算法

JVM 运行时的数据区域中了解到了 JVM 的内存模型,那么既然使用了内存,就要考虑如何回收内存。与 C 语言不同,Java 不需要开发人员人工回收内存,而是交给 Java 的垃圾回收机制来完成。

哪些内存需要回收

在 Java 中,GC 的对象是堆和方法区。栈中的栈帧随着方法的调用和退出,会自行完成压栈和出栈操作,每个栈帧所需的内存空间也是在类结构确定下来时就已知的,所以不怎么需要考虑内存的回收问题。但是堆和方法区则不一样,这部分的空间是动态分配和回收的,同时也只有在运行时才可得知要生成哪些对象以及需要多少空间。

判断对象是否可以被回收通常有两种算法:引用计数法可达性分析法

引用计数法

引用计数法会给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器就会加一;反之,每当一个引用失效时,计数器就会减一。任何时候,如果引用计数为 0,则说明这个对象可以被回收。

Reference counter

但是,这个算法有一个问题,那就是无法处理循环引用,即这样:

Circular reference

此时,对象1对象2对象3 都是不可达状态,理论上这三个对象都应该被回收,但是因为它们三个形成循环引用,引用计数器不为零,导致 GC 不会回收它们的空间。所以实际上,JVM 并没有采用这种判断方法。

可达性分析法 (根搜索算法)

可达性分析法的原理是,从根对象 (GC Root) 开始向下搜索,搜索走过的路径称为 “引用链”,对象与引用链可以形成一个图,当任一个对象没有到根对象的引用链,即在这个图中该对象是不可达的,那么就判定这个对象是可以被回收的。

Java 语言使用如下几种 GC Root 对象:

  1. 虚拟机栈 (栈帧中的本地变量表) 中引用的对象
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象

还是用上面这个循环引用作为示例:

Circular reference

此时虽然对象1对象2对象3 互相之间存在引用,但是从根对象开始无法找到到达它们的路径,即它们三个都是不可达的,也就是可以被回收的。

如何进行回收

在根搜索算法的基础上,现代虚拟机中实现了三种算法:标记-清除算法复制算法标记-整理算法

标记 - 清除算法

标记 - 清除算法把垃圾回收过程分成标记和清除两个阶段。在标记阶段,通过根节点标记所有可达的对象,也就是说,未被标记的对象都是不可达的对象。然后在清除阶段回收所有未被标记的对象。详细来说的话,就是当堆中的有效内存空间被耗尽时,就会停止整个程序 (stop the world),然后逐步开始标记和清除工作。

标记的过程,实际上是遍历所有的 GC Roots,并标记所有可达的对象。而清除的过程,则是遍历堆中所有的对象,并清除没有被标记的对象。

在回收过程中一定要停止程序运行的原因,是为了避免在标记完成而尚未开始清除时,有新的可达的对象被创建出来。一旦出现这种情况,因为新创建的对象没有被标记,所以在清除阶段这个对象又会被清除。如果停止了程序的运行,那么在清除过程中,对象的状态不会发生变化,也就不会发生前面说的这种问题。

这个算法尽管可以有效的回收内存,但是也有两个比较大的缺点:

  1. 遍历所有对象的效率比较低,导致程序停止运行的时间比较长
  2. 这种方法清理出的内存空间是不连续的,会造成空闲空间碎片化,并会影响数组分配空间。同时为了得知哪些空间是可用的,JVM 还需要额外维护内存闲置空间的信息。

复制算法

复制算法的思想,是将原有的内存空间分成两部分,每次只使用其中一部分。在垃圾回收时,会从正在使用的部分中,将标记的对象复制到另一块内存中,然后清除正在使用的内存块,并交换两块内存的角色,来完成空间的回收。

该算法比标记 - 清除算法的效率高,但是该算法不适合活动对象较多的场合,比如老年代空间。此外,该算法会造成一定程度的内存空间浪费,因为总是有一片内存空间是被闲置的。为了节省空间,考虑到新生代空间中的对象存活时间大多不会很长,所以虚拟机可以选择不将内存对半分,而是将内存分割成一块比较大的 Eden 空间和两块比较小的 Survivor 空间 (From SurvivorTo Survivor),每次同时使用 Eden 和其中一个 Survivor。比如 HotSpot 虚拟机默认为 Eden 分配 80% 的空间,为两个 Survivor 各分配 10% 的空间。

Eden 区,如其名字 “伊甸园” 一般,对象在被创建时,首先会放在这个区域;Survivor 区,也如其名字 “幸存者区” 一样,存放的是每次垃圾回收后被保留下来的对象。

在每次垃圾回收时,Eden 区中不能被回收的对象,和 From Survivor 区中不能被回收的对象,都将被复制到 To Survivor 区中,然后回收 Eden 区和 From Survivor 区的空间,并且幸存下来的对象的 age 属性会加一,最后 From Survivor 和 To Survivor 两者的角色对调。如果发生 Survivor 空间不足以存放所有活动对象时,则会使用老年代来进行分配担保,大的对象会跳过 Survivor 区直接进入老年代。

标记 - 整理算法

因为复制算法在活动对象较多时,会发生很多的复制操作,导致算法效率比较低,而老年代的特点就是活动的对象比较多。“标记 - 整理” 算法就是为了应对这一情况而诞生的。

标记 - 整理算法把垃圾回收过程分成标记和整理两个阶段。标记阶段的做法与 “标记 - 清除” 算法一样,遍历所有的 GC Roots 并标记出活动的对象;而在整理阶段,所有的活动对象都会向内存空间的一端移动 (比如全部从内存空间的其实位置开始排列),然后将边界以外的内存直接清理。

该算法的另一个优点是,因为该算法不会分割内存空间,而且每次回收后对象占用的空间肯定小于回收前所占用的空间,所以不再需要额外的空间进行分配担保。

分代收集算法

分代收集算法实际上就是根据不同内存空间的特性,一般是将堆分为新生代和老年代,并根据其各自的特点,在新生代使用复制算法回收,在老年代使用标记 - 整理算法回收。