Java 虚拟机 - GC 垃圾回收机制分析

Java 垃圾回收(Garbage Collection,GC)

Java支持内存动态分配、垃圾自动回收,而 C++ 不支持。我想这可能也是 为什么 Java 脱胎于 C++ 的一个原因吧。

GC 的历史

GC 的历史比 Java 更久远,比如 1960 年诞生的于 MIT 的 Lisp 就是第一门真正使用内存动态分配和垃圾回收的语言。

GC 需要考虑的 3 件事情

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

我们从这三个问题出发,来更深一层地看看 JVM GC 为我们做了哪些工作。

1、哪些内存需要回收?

我们都知道,JVM 栈和堆所使用的计算机内存都是由 JVM 统一管理的,只不过栈中元素的内存分配和回收是由 JVM 全权管理,而堆中对象创建时的内存分配则由我们 Java 程序员来控制,内存回收则由 JVM GC 负责。

JVM Stack 栈中元素的生命周期随着方法栈的结束或者线程栈的结束而结束,而且每一个栈中元素需要分配多少的内存空间在 Java 代码编译成 class 字节码文件的时候就已经确定了,因此 JVM 对于栈的内存管理相对于堆来说,是要简单一些的。在这里关于 JVM 栈的知识点就不做细致挖掘,以后再分享。本文只针对回收堆内存中的对象

通俗点讲,JVM 堆内存中的所有对象都是 JVM GC 回收的目标对象,但只有已经确定死掉的对象才会被 GC 回收,不然 JVM 中就乱套了,想想看:一个对象正在愉快地搬砖,突然被一只看不见的手给杀掉了,连尸体都没留下,它的亲人们就会很着急啊,对象失踪了,到底是死是活,活要见人死要见尸啊。

Java 世界是一个法制社会,做任何事情要有理有据,那么就引出了一个问题:

如何判断堆内存中的一个对象,是死是活?

只有被打上了“死亡”标记的对象,才会被 GC 回收掉。那么我们可以将 “哪些对象会被 GC 回收?” 的问题稍微转换一下角度:“如何判断一个对象已经死亡,并给它打上'死亡'标记?”

先来介绍两种给对象“判死刑”的算法:

  • 引用计数算法
  • 可达性分析算法

引用计数算法(Reference Counting)

原理:在创建对象时,给每个对象都添加一个“引用计数器”,每当有一个地方引用它时,计数器的值就加 1;反之,当一个指向该对象的引用失效时,计数器值就减 1。在任何时刻,计数器值为 0 时,就表示这个对象已经不可用了,或者说已经死掉了。

引用计数算法的原理实现起来很简单,而且对死亡对象的判定效率也很高,在大部分情况下,这都是一个很不错的判定算法。有一些很著名的应用案例:微软公司的COM(Component Object Model)技术、Python 语言都使用了引用计数算法。

但是!在主流的 JVM 中却没有选择 引用计数算法 来管理内存,其中最主要的原因:它很难解决对象之间相互循环引用的问题。

举个简单的栗子:

public class Test {
public Object obj = null; public static void main(String []args){
// 创建并初始化两个 Test 对象
Test a = new Test();
Test b = new Test();
// 让两个对象 相互循环引用
a.obj = b;
b.obj = a; // 接下来是关键点:让两个对象的引用失效
a = null;
b = null; // 假设执行了 GC
System.GC();
}
}

问题来了,执行了 System.GC(); 之后,a 和 b 两个对象会被回收吗?

我们可以通过配置 eclipse.ini 开启 Eclipse 工具打印 GC 日志的功能,然后对比执行 GC 之前的堆空间大小与执行 GC 之后的堆空间大小,就能得到答案:没有被回收。因此可以确定 JVM 没有使用引用计数算法作为对象是否存活的判定算法。

扩展:如何开启 eclipse 打印 GC 日志的功能


可达性分析算法(Reachability Analysis)

在主流的商用程序语言(Java、C#,还有前面提到的古老的Lisp)主要都是通过可达性分析(Reachability Analysis)来判定对象是否存活的。

算法基本思路:通过一系列称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用数学图论的话来说,就是从 GC Roots到这个对象不可达)时,则证明此对象是不可用的。

GC Roots 的图例:

Java 虚拟机 - GC 垃圾回收机制分析

以上图例中,object 5、object 6、object 7 三个对象虽然有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为可回收的对象。

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

  • JVM 栈中的栈帧引用的对象
  • 静态成员属性引用的对象
  • 常量引用的对象
  • 本地方法(Native)引用的对象

对可达性分析算法简单总结一下:只要 Java 堆中的对象与最后一个 GC Roots 断开了连接,这个对象就成为了 GC 的回收目标。

2、什么时候回收?

因为 Java 堆中对象的创建是由我们 Java 程序员控制的,因此:

  • 创建时机不确定
  • 创建所需的内存空间不确定

创建时机不确定,到底需要多少的内存空间也不确定,那么 JVM 也就不知道该在何时为创建对象准备足够的资源空间,为了避免 当需要为创建对象分配内存空间时,却已经没有可用的内存空间 这种尴尬的情况发生,JVM GC 就需要适时地在暗地里操控 JVM 堆内存,回收被那些已经死掉的对象占用的内存空间。

那么这个“适时”到底是什么时候呢?大家都知道 GC 执行的时间不确定性,但这不代表 GC 就是在随性而为,下面我们来对这个 GC 时机 进行讲解:

Java 中,经过可达性分析算法判定后,成为 GC 回收目标的对象,并不是被判了死刑要立即执行,而是死缓。

要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 给已失去与 GC Roots 引用链的对象打上第一次标记,这个对象此时还有一次重生的机会
  2. 第一次标记后仍然没有加入引用链的对象,将被打上第二次标记,确定被回收

如果对象在经过可达性分析之后发现没有与 GC Roots 相连接的引用链,那它将会被 GC 打上第一次标记并且执行一次筛选判定,筛选的条件是该对象是否有必要执行 finalize() 方法。

当对象没有覆写 finalize() 方法,或者 finalize() 方法刚刚被 JVM 调用过了,那么 JVM 就会认为没有必要执行 finalize() 方法,也就失去了重生的机会。

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象就会被放置在一个叫 F-Queue 的队列中,并在稍后由一个低优先级的 Finalizer 线程去执行 finalize() 方法,只要在 finalize() 方法的执行过程中,将对象与引用链上的任何一个对象建立关联,那么在 GC 第二次标记时就会将该对象移除“回收名单”,若第二次标记时仍然没有可达连接,就将这个对象彻底回收。

关于 finalize 执行过程,可参考这里

一个对象的 finalize() 方法只会被系统自动调用一次,也就是说,这个复活技能只能使用一次。

关于 finalize() 方法,不建议使用,不确定性太大,无法保证各个对象的调用顺序,finalize() 能做的,try-finally 能做得更好、而且更及时。

我们换个通俗点的说法总结一下:第一次打标记就是给这个对象发法院的一审判决通知,这个对象要么上诉,上诉了还有胜诉的希望,要么什么都不做等待执刑,第二次打标记就是对那些没有胜诉的对象斩立决。

扩展:引用

前面提到的两种算法,无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活的关键,都与“引用”有关。在这里对“引用”这个概念做一下扩展。

在 JDK 1.2 以前,Java 中的引用的定义很传统:

如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

这也是我们作为 Java 初学者时常认为的引用概念,在这种定义下,一个对象只有被引用或者没有被引用两种状态,在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为:强引用、软引用、弱引用、虚引用,这 4 种引用的强度依次递减。

  • 强引用(Strong Reference):指在程序代码中普遍存在的,类似 “Object obj = new Object();” 这种创建的对象引用。只要强引用还存在,JVM GC 就不会回收掉被引用的对象。当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError 错误使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
  • 软引用(Soft Reference):用来描述一些还有用但并不是必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围之中,但不会立即回收,此时这些对象仍然可以被程序使用,只有当内存空间确实不足时,才会对回收范围内的对象进行回收处理。
  • 弱引用(Weak Reference):也是用来描述非必需对象的,但强度比软引用要更弱,生命周期更短暂,在 GC 线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会回收它。
  • 虚引用(Phantom Reference):虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被 GC 回收,当然我们也不能通过虚引用得到一个对象实例。为一个对象加上虚引用的唯一目的就是能在这个对象被 GC 回收时,可以收到一个系统通知。

3、如何回收?

因为篇幅问题,本文仅对实现垃圾回收的算法进行分析,不做过多实现细节上的描述。

垃圾回收目前常用的有 3 种算法思路:

  • 标记 - 清除 算法
  • 复制算法
  • 标记 - 整理 算法
  • 分代收集算法(以上三种思路的集合体)

① 标记 - 清除(Mark-Sweep) 算法

标记 - 清除算法是最基础的回收算法。该算法分为 “标记” 和 “清除” 两个阶段:

  1. 首先标记出所有需要回收的对象
  2. 在标记完成后,统一回收所有被标记的对象

之所以说它是最基础的算法,因为后续的回收算法都是基于这种思路,并且对其不足进行改进而来。

标记 - 清除算法有两个明显的不足:

  • 效率问题。标记和清除两个过程的效率都不高
  • 空间问题。标记清除之后,会产生大量不连续的内存碎片,内存碎片太多可能会导致以后需要分配较大对象时,无法找到足够的连续内存,而不得不提前触发另一次垃圾回收动作,执行垃圾回收也是一个耗费资源的动作,执行频率过高,会影响到程序的整体执行效率。

我们来看看使用“标记 - 清除”算法执行前后的内存变化:

Java 虚拟机 - GC 垃圾回收机制分析


② 复制(Copying)算法

为了解决“标记 - 清除算法”的效率问题,诞生了“复制算法”。

复制算法的思路:将可用内存按容量划分为大小相等的两,每次只使用其中的一。每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块上,然后再把已使用过的内存空间一次性清理掉。

这样使得每次 GC 都是在对整个堆内存的一半区域进行操作,进行内存分配时也就不用考虑内存碎片等复杂问题,每次分配只需要移动堆顶的指针,按顺序分配内存即可,实现简单,运行高效。

缺点也很明显:将可用内存缩小为原来的一半,代价太大了。

但是目前主流的虚拟机都是采用复制算法来进行垃圾回收的。为什么大家还要用缺点这么明显的算法呢?这牵扯到另一个问题:JVM 堆内存分代。

在这里我们简单描述下堆内存分代的概念:

JVM 堆内存并不是一锅乱炖的大杂烩,而是将堆内存进行了分代(新生代、老年代),分代的目的也就是为了优化 GC 的性能,就好比硬盘要分区,要建文件夹管理文件一样,方便寻找和管理资源。

HotSpot 版本的 JVM 将新生代内存区域分为了三个部分: 1 块较大的 Eden 区和 2 块较小的 Survivor 区(分别名叫 from 和 to)。HotSpot JVM 中默认 Eden 和 Survivor 空间大小的比例是 8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的 90%( 80% +10% ),要说浪费,也只有其中的 10% 被浪费了。

新生代中实际可用的内存区域只有: Eden 和 其中的一块 Survivor(第一次是 from,from 满后转移到 to)。

一般情况下,新创建的对象都会被分配到 Eden 区(先放到 Eden 区是因为有些对象比较大,但不一定是常驻对象),Eden 中的对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC,年龄就会增加 1 岁,当它的年龄增加到一定程度时,就会被移动到老年代中。

扩展:关于新生代中的 Eden 和 两个 Survivor

另外做一个关于 新生代 和 老年代 的扩展:

新生代 和 老年代:

  • 新生代:刚创建、存活时间较短的对象,一般都存放在新生代堆区
  • 老年代:在新生代中存活超过了一定年龄的对象,就会被转移到老年代堆区

新生代 GC 和 老年代 GC:

  • 新生代 GC(Minor GC):指发生在新生代的垃圾回收动作。因为 java 对象大多都具备“朝生夕灭”的特性,所以 Mirnor GC 非常频繁,回收速度也比较快。
  • 老年代 GC(Major GC / Full GC):指发生在老年代的垃圾回收动作。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

③ 标记 - 整理(Mark - Compact)算法

使用复制算法,在对象存活率比较高时,要复制的内容就多了,相应的操作效率就会降低。另外,如果内存空间整体的使用率要求超过一半,比如内存中 100% 的对象都是存活状态的极端情况,用复制算法就不可靠了,特别是在 老年代 中,不能使用复制算法,这就催生而出另一种符合 老年代 特点的算法:标记 - 整理算法。

该算法的思路:标记的过程与 “标记 - 清除”算法是一致的,区别在于后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉内存另一端边界以外的内存,用图来说话:

Java 虚拟机 - GC 垃圾回收机制分析


④ 分代收集(Generational Collection)算法

分代收集算法是目前商业虚拟机的垃圾回收主要采用的算法。

其实分代收集算法并没有什么特别的新思想,只是根据对象存活周期的不同,将内存划分为新生代和老年代,然后根据不同的年代内存区域,采用符合各自特点的回收算法。比如:在新生代中,因为每次 GC 都会发现大量的死对象,只有少量存活,选用复制算法回收效率更高;而在老年代中的对象存活率高、也没有额外的空间为其冗余,就必须采用 “标记 - 清除” 或 “标记 - 整理”算法进行回收。

至此,关于 Java 虚拟机垃圾回收的知识点分享就到这里,谢谢。

参考资料:《深入理解Java虚拟机:JVM高级特性与最佳实践》 - 周志明

上一篇:c# 之 System.Type.GetType()与Object.GetType()与typeof比较


下一篇:使用Tcl脚本分配FPGA管脚