Java 垃圾收集一 背景

Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的墙,墙外面的人想进去,墙里面的人想出来。

概述

说起垃圾收集(Garbage Collection),大部分人都把这项技术当做 Java 语言的伴生物。事实上,GC 的历史比 Java 久远,1960 年诞生于 MIT 的 Lisp 是第一门真正使用内存动态分配和垃圾收集技术的语言。当 Lisp 还在胚胎时期时,人们就在思考 GC 需要完成的 3 件事情:

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

经过半个多世纪的发展,目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了『自动化』时代,为什么我们还要去了解 GC 和内存分配呢?答案很简单:

当需要排查各种内存溢出、内存泄漏问题时;当垃圾收集成为系统达到更高并发量的瓶颈时;我们就需要对这些『自动化』的技术实施必要的监控和调节

《Java 运行时数据区域》 介绍了 Java 运行时的各个内存区域。其中程序计数器、Java 虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭,这几个区域的内存分配和回收都具有确定性。而堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样;一个方法中的多个分支需要的内存也可能不一样,JVM 只有在程序运行期间才能知道要创建哪些对象。因此,这部分内存分配和回收都是动态的,同时也是垃圾收集器关注的部分。

对象已经死了吗

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确认哪些对象还『存活』着,哪些已经『死去』(不可能再被任何途径使用的对象)。

引用计数算法

一个简单的实现就是给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的就是不可能再被使用的对象。

引用计数算法(Reference Counting)的实现简单,判定效率也很高。但是主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,因为它很难解决对象之间循环引用的问题。

例如:对象 objA 和 objB 都有字段 instance,赋值 objA.instance = objB 及 objB.instance = objA,除此之外,这两个对象无任何引用。实际上这两个对象不可能再被访问,但因为他们互相引用着对方,所以它们的引用计数都不为 0,导致引用计数算法无法通过 GC 收集器回收它们。

代码 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* jvm 运行参数: -verbose:gc -XX:+PrintGCDetails
*/
public class ReferenceCountingGC {
public static void main(String[] args) {

GCObject objA = new GCObject(); // Step1
GCObject objB = new GCObject(); // Step2

objA.instance = objB; // Step3
objB.instance = objA; // Step4

objA = null; // Step5
objB = null; // Step6

//假设在这行发生 GC,objA 和 objB 能否被回收?
System.gc();
}

public static class GCObject {
public GCObject instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个属性的唯一意义就是占点内存,以便能在 GC 日志中看清楚是否回收过。
*/
private byte[] bigSize = new byte[2 * _1MB];
}
}

如果采用的是引用计数算法,会产生什么结果呢?我们一起来分析一下。

来看 main 方法的前 6 个步骤:

  • Step1:GCObject 实例 1 的引用计数加 1,实例 1 的引用计数=1。
  • Step2:GCObject 实例 2 的引用计数加 1,实例 2 的引用计数=1。
  • Step3:GCObject 实例 2 的引用计数再加 1,实例 2 的引用计数=2。
  • Step4:GCObject 实例 1 的引用计数再加 1,实例 1 的引用计数=2。

执行到 Step4 时,GCObject 实例 1 和 实例 2 的引用计数都等于 2。继续执行 Step5 和 Step6 之后:

  • Step5:栈帧中 objA 不再指向堆,GCObject 实例 1 的引用计数减 1,实例 1 引用计数=1。
  • Step6:栈帧中 objB 不再指向堆,GCObject 实例 2 的引用计数减 1,实例 2 引用计数=1。

至此,发现 GCObject 实例 1 和实例 2 的引用计数都不为 0。如果采用引用计数算法,这两个实例所占的内存将得不到释放,这便发生了内存泄露。

代码 1 的执行结果

从日志中可以看到 6717K->560K,这意味着 Java 虚拟机并没有因为这两个对象互相引用就不回收它们,侧面说明了 Java 虚拟机不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

在一些语言(Java、C#,Lisp)的主流实现中,都是通过可达性分析(Reachability Analysis)来判定对象是否存活的。这个算法的基本思路就是通过一系列称为『GC Roots』的对象作为起始点,从这些节点开始向下搜索,搜索经过的路径称为引用链(Reference Chain)。当一个对象到 『GC Roots』没有任何引用链相连时(用图论的话来说,就是从『GC Roots』到这个对象不可达),则证明此对象是不可用的。

如下图所示,对象 object 5、object 6、object 7 虽然相互关联,但是它们到 GC Roots 是不可达的,因此它们被判定为可回收的对象。

在 Java 中,固定的『GC Roots』对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象
    • 譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象
    • 譬如字符串常量池里的引用。
  • 在本地方法栈中 JNI(即 Native 方法)引用的对象
  • Java 虚拟机内部的引用
    • 如基本数据类型对应的 Class 对象;一些常驻的异常对象,譬如 NullPointException、OutOfMemoryError 等;还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象

除了这些固定的 GC Roots 集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其它对象『临时性』地加入,共同构成完整的 GC Roots 集合。

再谈引用

在 JDK 1.2 之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些狭隘了。一个对象在这种定义下只有『被引用』或者『未被引用』两种状态,对于描述一些食之无味,弃之可惜的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保存在内存之中;如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象(很多系统的缓存功能都符合这样的应用场景)。

在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为以下 4 种,引用强度依次减弱:

  • 强引用(Strongly Reference)是最传统的引用的定义。
    • 是指在程序代码中普遍存在的引用赋值,即类似 Object obj = new Object() 这种引用关系。
    • 在任何情况下,只要强引用关系还存在,垃圾收集器就不会回收掉被引用的对象
  • 软引用(Soft Reference)是用来描述一些还有用、非必须的对象。
    • 只被软引用关联着的对象,在系统将要发生 OOM 异常前,会把这些对象列进回收范围中进行第二次回收,如果这次回收还没有足够的内存,才会抛出 OOM 异常。
    • JDK 1.2 之后提供了 SoftReference 类来实现软引用。
  • 弱引用(Weak Reference)也是用来描述那些非必须的对象,但是它的强度比软引用更弱一些。
    • 当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
    • JDK 1.2 之后提供了 WeakReference 类来实现弱引用。
  • 虚引用(Phantom Reference)也称为『幽灵引用』或者『幻影引用』,它是最弱的一种引用关系。
    • 一个对象是否有虚引用,不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
    • 为一个对象设置虚引用的唯一目的就是为了能在这个对象被收集器回收时收到一个系统通知
    • JDK 1.2 之后提供了 PhantomReference 类来实现虚引用。

回收方法区

有人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java 虚拟机规范》中提到过不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 ZGC 收集器就不支持类卸载)。方法区垃圾收集的性价比通常是比较低的:在 Java 堆中,尤其是新生代中,对常规应用进行一次垃圾收集通常可以回收 70% 至 99% 的内存空间,相比之下,方法区回收存在苛刻的判定条件,其区域垃圾收集的回收成果也远远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类。

举个常量池中字面量回收的例子:假如一个字符串 “java” 曾经进入常量池中,但是当前系统有没有任何一个字符串对象的值是 “java”。如果在这时发生内存回收,而且垃圾收集器判断有必要的话,这个 “java” 常量就将会被系统清理出常量池。常量池中其它类、方法、字段的符号引用也是如此。

判断一个常量是否废弃还是相对简单,而要判断一个类型是否属于『不再被使用的类』的条件就比较苛刻了。需要同时满足三个条件:

  • 该类的所有实例都已经被回收。
    • 也就是 Java 堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收。
    • 这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用。
    • 无法在任何地方通过反射访问该类的方法。

Java 虚拟机允许对满足上述三个条件的无用类进行回收,这里仅仅是『允许』,而不是和对象一样,没有引用了就必然会回收。

HotSpot 虚拟机提供了 -Xnoclassgc 参数控制是否要回收类。

在大量使用反射、动态代理、CGLib 等字节码框架;动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

引用