1. 概述

这篇博客对应的是《深入理解 Java 虚拟机》第三章的部分内容。包括 GC 的简单介绍、判断对象存活的方法、强软弱虚四种引用,还有finalize()方法的简介。有关垃圾回收算法的部分在下篇中填坑。

2. 什么是垃圾回收?

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

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

回到我们熟悉的 Java 语言,不妨回顾Java内存运行时区域的各个部分。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器
进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的。)因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题。因为方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。

所以垃圾收集器所关注的是这部分(堆和方法区)内存。

3. 判断对象是否“存活”—— GC 算法简介

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

下面简要介绍两种判断对象存活的算法:引用计数和可达性分析。

3.1 引用计数

引用计数是一种简单的判断对象存活的算法。其算法是这样的:

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

客观地说,引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是,至少主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

举个简单的例子,见下面代码中的testGC()方法:对象 objA 和 objB 都有成员变量instance,令objA.instance = objBobjB.instance = objA。除此之外,这两个对象再无任何引用。也就是说实际上这两个对象已经不可能再被访问。

但是,它们因为互相引用着对方,导致它们的引用计数都为1,于是引用计数算法无法通知 GC 收集器回收它们。

(下面代码摘抄自《深入理解 Java 虚拟机》第二版 3.2.1 章节)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
*testGC()方法执行后,objA和objB会不会被GC呢?
*@author zzm
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024*1024;
/**
*这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
//假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}

运行结果(摘抄自书籍):

1
2
3
4
5
6
7
8
9
10
11
[FullGC(System)[Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
Heap
def new generation total 9216K,used 82K[0x00000000055e0000,0x0000000005fe0000,0x0000000005fe0000)
Eden space 8192K,1%used[0x00000000055e0000,0x00000000055f4850,0x0000000005de0000)
from space 1024K,0%used[0x0000000005de0000,0x0000000005de0000,0x0000000005ee0000)
to space 1024K,0%used[0x0000000005ee0000,0x0000000005ee0000,0x0000000005fe0000)
tenured generation total 10240K,used 210K[0x0000000005fe0000,0x00000000069e0000,0x00000000069e0000)
the space 10240K,2%used[0x0000000005fe0000,0x0000000006014a18,0x0000000006014c00,0x00000000069e0000)
compacting perm gen total 21248K,used 3016K[0x00000000069e0000,0x0000000007ea0000,0x000000000bde0000)
the space 21248K,14%used[0x00000000069e0000,0x0000000006cd2398,0x0000000006cd2400,0x0000000007ea0000)
No shared spaces configured.

很显然,日志中的”4603k -> 210k”说明这两个对象成功的被回收。这也从侧面说明了虚拟机并不是通过引用计数来实现垃圾回收的。

3.2 可达性分析

在目前的主流语言中,基本上都是通过可达性分析(Reachability Analysis)来判定对象的存活与否。

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

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

可达性分析判断回收
可达性分析判断回收

在Java中,作为 GC root 的引用一般包括:

  • 栈(栈帧中的局部变量)中引用的对象。
  • 方法区中类静态成员变量引用的对象。
  • 方法区中常量引用的对象。
  • native 方法栈中 JNI(Native 方法)引用的对象。

4. 强、软、弱、虚四种引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。Java 中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘。

我们希望描述下面这种对象:当内存空间足够时,保留在内存中。当空间十分紧张时,则可以回收这些对象。于是,在 jdk 1.2以后,Java 对引用的概念进行了扩充。将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

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

5. 生存还是死亡?—— finalize() 方法简介

虚拟机通常使用可达性分析的方式来判断对象能否回收。即使在可达性分析算法中不可达的对象,也并非是“非死不可”的。

这时候它们暂时处于“缓刑”阶段。要真正宣告一个对象死亡,至少要经历两次标记过程

第一次筛选的方式如下:

  • 如果对象在进行可达性分析后发现没有与 GC Root 相连接的引用链,那它将会被第一次标记并且进行一次筛选。
  • 筛选的条件是此对象是否有必要执行finalize()方法。
  • 当对象没有重写finalize()方法。或者finalize()方法已经被虚拟机调用过了,虚拟机将这两种情况都视为“没有必要执行”。

也就是说,执行finalize()的条件有两个:一是当前对象的类重写了finalize()方法,二是虚拟机先前还没有调用过该方法。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。

这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记

如果对象想要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的
成员变量,那在第二次标记时它将被移除出“即将回收”的集合。如果对象这时候还没有逃脱,那基本上它就真的被回收了。

从下面的代码中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。
(代码摘自《深入理解 Java 虚拟机》3.2章节)

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
*此代码演示了两点:
*1. 对象可以在被GC时自我拯救。
*2. 这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*@author zzm
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes,i am still alive:)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize mehtod executed!");
// 将自身赋值给一个静态成员变量,进行自我拯救
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
// 将SAVE_HOOK置null,使对象不可达。触发自救
SAVE_HOOK = null;
System.gc();
// 因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead:(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead:(");
}
}
}

运行结果:

1
2
3
finalize mehtod executed!
yes,i am still alive:)
no,i am dead:(

从结果可以看出,第一次SAVE_HOOK 对象的finalize()方法确实被 GC 收集器触发过,并且在被收集前成功逃脱了。
另外一个值得注意的地方是,main函数代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败。这是因为任何一个对象的finalize()方法都只会被系统自动调用一次
如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

6. 回收方法区

很多人认为方法区(或者 HotSpot 虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中, 但是当前系统没有任何一个 String 对象是叫做“abc”的, 换句话说,就是没有任何 String 对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

7. 小结

  • 判断对象能否回收的算法包括引用计数和可达性分析两种。前者实现较为简单,但是会有循环引用的问题。目前的主流语言使用的都是后者。在 Java 中,GC root 包括栈中的引用对象,方法区中的 static 引用和常量、native 方法中引用的对象。

  • 强、软、弱、虚四种引用强度依次减弱。其中,强引用即代码中最常见的赋值引用,强引用只要存在就不会被回收。后面三种引用都是用 Java 中提供的类来使用。其中软引用会在系统即将发生内存异常时列入回收范围,当回收后内存依然不够时,会被回收。弱引用只要 GC 工作,无论内存足够与否都会被回收。虚引用无法通过其获得对象示例,只是会在被回收时通知。

  • 在 GC 时调用finalize()需要满足两个条件:当前对象的类重写了finalize()方法,二是虚拟机先前还没有调用过该方法。如果这个对象被判定为有必要执行finalize()方法,会放置在 F-Queue 中由低优先级线程 Finalizer 来执行。如果对象想要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。每个对象的finalize()只能执行一次,也就是说只有一次机会。

最后引用书本上的一段话结束这篇博客:

需要特别说明的是,上面关于对象死亡时 finalize() 方法的描述可能带有悲情的艺术色彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对这个方法用途的一种自我安慰。finalize()能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉Java语言中有这个方法的存在。