深入理解 Java 垃圾回收算法

225 阅读11分钟

垃圾收集器

今天我们来一起探讨一下JVM中的垃圾收集器,这是Java面试者几乎必问的一个知识点,因此有必要对它进行深入的学习。我们先来看看大纲。

mark

如何判断一个对象是垃圾?

引用计数法

引用计数法的方法很容易思考,虚拟机根据每个对象的引用情况来判断这个对象是否为垃圾,比如A对象被B,C对象,引用,那么A被两个对象引用,就不会被当成垃圾,但这个方法比较难解决循环引用的问题,比如AB对象之间相互引用,尽管我们知道这两个对象都不会再被使用,但虚拟机仍不会将其当作垃圾。当然,问题是死的人是活得,这个问题还是有解决方案,只不过比较复杂,因此这里就不做深入讨论。

既然引用计数法并不能很好的判断对象是否为垃圾,那么是否有其他的算法呢?

可达性分析算法

可达性分析算法是商用程度比较大的垃圾回收算法,JVM的经典实现HotSpot使用的就是可达性分析算法。我们来看看它的概念是什么。

判断一个对象是否为垃圾,判断的依据依然是根据引用,但算法会从一系列的GC Roots出发,通过GC Roots来判断哪些是垃圾,哪些不是。

Java里面,能被称为GC Roots的有:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻内存的异常对象等,还有类加载器。
  • 所有被同步锁(synchronize 关键字)持有的对象。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

不知道大家有没有仔细思考过 Java 中的引用,对于垃圾回收器来说,它要怎么判断这个对象是不是垃圾呢?根据可达性分析算法,我们可以找到被 GC Roots 引用的对象,(以下是我个人的理解),我以前的理解是 Java 中所有的引用就是强引用(可能也是很多小伙伴的普遍想法),即被 GC Roots 引用的对象绝对不会被当成垃圾回收。

如果这么简单的话,Java 应该也不会被称为最受欢迎的语言之一了,它其实还有另外三种引用关系,软引用、弱引用、虚引用,以下来讲讲他们的作用:

  • 强引用:普遍意义上的引用,在 Java 中,正常的引用即为强引用;
  • 软引用:使用SoftReference类来实现软引用,只要被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常;
  • 弱引用:使用WeakReference类来实现弱引用,它的强度比软引用还要再低一点,被弱引用关联的对象只能生存到下一次垃圾收集发生为止;
  • 虚引用:使用PhantomReference类来实现虚引用,这是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了让这个对象被回收时收到一个系统通知。

标记几次才会被清除?

一个对象要被当成垃圾清除,至少要经历两次标记。

垃圾收集算法

分代收集理论

分代收集理论基于三个假说

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

由上面两个分代假说奠定了收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

划分成新生代啊、老年代,也就促使了Minor GCMajor GCFull GC的产生。

  • Minor GC:针对新生代的垃圾回收
  • Major GC:针对老年代的垃圾回收
  • Full GC:针对全堆的垃圾回收

针对不同的区域,催生出了不同的垃圾回收算法,分别是标记 - 清除、标记 - 复制、标记 - 整理。

  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

既然有跨代引用,其实虚拟机就要扫描整个老年代去发现是否存在跨代引用的。这是一个比较原始的想法。

基于跨代引用假说,我们有理由不扫码整个老年代,因为跨代引用极其的少,而应该维护一个全局的数据结构,在这个数据结构中记录哪些是被老年代的对象引用的,在后面发生 Minor GC 时,这些引用了新生代的老年代对象就会被加入到 GC Roots 进行扫描。

标记 - 清除算法

过程概述:

  • 标记阶段:与可达性分析算法一致
  • 清除阶段:清除未被标记的对象(至少经历两次标记过程都没有被标记)

缺点:

  • 效率不稳定,由于 Java 堆中对象的数量以及大小在垃圾回收之前都无法确定,因此无法控制算法的时间,导致效率不稳定。
  • 内存碎片多,由该算法的描述可知,清除对象后,算法并不会对 Java 堆进行整理工作,因此容易产生内存碎片,这就导致堆中空间足够时,却无法为大对象分配内存空间。

标记 - 复制算法

该算法也被称为“半区复制”的垃圾收集算法。

过程概述:

  • 标记阶段:与可达性分析算法一致
  • 清除阶段:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

延申至JVM的新生代(伊甸区)

看着 标记 - 复制 算法,有没有很像 Java 堆新生代中的survivor1区以及survivor2区?

Java 堆中的surviver区其实是对 标记-复制 算法的优化。由上面的描述可以知道,标记 - 复制 算法将一个空间一分为二,也就是说,也会一半的内存空间是不能使用的,这未免有点过于浪费了。

实际上,JVM中的新生代被划分为三个部分,较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。默认情况下,Eden空间的大小与一块Survivor空间的大小比值为8:1

标记 - 整理算法

这个算法可以视作 标记 - 清除 算法的一个优化,但对于垃圾算法来说,其实没有什么好的算法与坏的算法的区别,更重要的是把算法根据需求以及特点用到对的地方。

过程概述:

  • 标记阶段:与可达性分析算法一致
  • 整理阶段:将所有存活的对象都向内存空间一端移动,然后后直接清理掉边界以外的内存

经典垃圾收集器

Serial 收集器

这是一个针对新生代的垃圾收集器,使用 标记 - 复制 算法

这是一个单线程工作的收集器,单线程也意味着该收集器在进行垃圾收集时,JVM必须暂停其他所有工作线程,直到它收集结束,这个状态也被称为"Stop The World"。

Serial Old 收集器

这是一个针对老年代的垃圾收集器,使用 标记 - 整理 算法,除此之外,其他基本与Serial收集器一致。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS收集器基于 标记 - 清除算法,是一款针对老年代的垃圾收集器。它的垃圾收集过程被分为四个阶段:

  • ==初始标记==
  • 并发标记
  • ==重新标记==
  • 并发清除

CMS中,初始标记以及重新标记仍然需要Stop The World

初始标记仅仅只是标记以下GC Roots能直接关联到的对象,速度很快;

并发标记就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但不需要停顿用户线程,可以与垃圾收集线程一起并发运行;

重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生标动的那一部分对象的标记记录,这个部分需要的时间比初始标记长,且需要Stop The World,但总体的时间远比并发标记的时间短;

并发清除阶段,清理删除掉标记阶段已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

G1 收集器

G1收集器基于Rigion的堆内存区域进行垃圾回收,G1收集器虽然创新性的使用了分区垃圾回收,但仍然保留的分代的概念,在每个Rigion中仍然有新生代、老年代的概念。除此之外,G1收集器是面向全堆进行垃圾回收的,且G1会根据每个区域的垃圾回收收益,每次回收收益最大的区域。

G1收集器的四个阶段:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

由于G1收集器同样追求更短的停顿时间,因此其垃圾回收的四个阶段与CMS有着异曲同工之妙,为了解决并发标记阶段收集线程与用户线程相互干扰的问题,CMS采用增量更新算法,而G1则使用原始快照SATB实现。除此之外,G1为每一个Rigion设计了两个名为TAMS(Top at Mark Start)的指针,把Rigion中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置上。

初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Rigion中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,因此G1收集器在这个阶段实际并没有额外的停顿;

并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象;

最终标记:对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录;

筛选回收:负责更新Rigion的统计数据,对各个Rigioin的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Rigion构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的的移动,必须暂停用户线程,由多条收集器线程并行完成。

参考文献

《深入理解 Java 虚拟机》 周志明