Java-垃圾收集器

引用计数法

在对象中添加一个引用计数器,每当有一个对他的引用,计数器值加一;引用失效时,计数器减一;任何时刻计数器为 0 的对象都是不可能再被使用的对象。

引用记数算法 (Reference Counting) 虽然占用了一些额外的内存空间来进行记数,但原理简单,判定效率高,大多数情况下都是不错的算法。也有一些著名的应用案例,如微软 COM (Component Object Model) 技术、使用 ActionScript 3 的 FlashPlayer、Python 语言以及在游戏脚本领域得到许多应用的 Squirrel 中都使用了引用记数算法进行内存管理。

但是,在 Java 领域,至少是主流的 Java 虚拟机里都没有选用引用计数算法进行内存管理,这个看似简单的算法有很多例外情况要考虑,必须配合大量额外处理才能保证其正确工作。

请看如下的 testGC():这两个对象最终都不可被访问 (最后都置 nul),但是因为他们存在相互引用 (对象 objA 和 objB 都有字段 instance,并利用该字段进行相互引用),引用记数算法将无法回收他们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1 << 20;
private final byte[] bigSize = new byte[_1MB]; // 占点内存以方便得知是否被回收

public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

// 建议 gc 进行回收
System.gc();
}
}

(Ryuu: 我这里用的是 G1 回收器,gc 日志跟作者的不一样)

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
[0.007s][info][gc] Using G1
[0.010s][info][gc,init] Version: 16.0.2+7-67 (release)
[0.010s][info][gc,init] CPUs: 12 total, 12 available
[0.010s][info][gc,init] Memory: 32717M
[0.010s][info][gc,init] Large Page Support: Disabled
[0.010s][info][gc,init] NUMA Support: Disabled
[0.010s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.010s][info][gc,init] Heap Region Size: 4M
[0.010s][info][gc,init] Heap Min Capacity: 8M
[0.010s][info][gc,init] Heap Initial Capacity: 512M
[0.010s][info][gc,init] Heap Max Capacity: 8180M
[0.010s][info][gc,init] Pre-touch: Disabled
[0.010s][info][gc,init] Parallel Workers: 10
[0.010s][info][gc,init] Concurrent Workers: 3
[0.010s][info][gc,init] Concurrent Refinement Workers: 10
[0.010s][info][gc,init] Periodic GC: Disabled
[0.010s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bb0000-0x0000000800bb0000), size 12255232, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.010s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.010s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 3, Narrow klass range: 0x100000000
[0.088s][info][gc,task ] GC(0) Using 10 workers of 10 for full compaction
[0.088s][info][gc,start ] GC(0) Pause Full (System.gc())
[0.088s][info][gc,phases,start] GC(0) Phase 1: Mark live objects
[0.089s][info][gc,phases ] GC(0) Phase 1: Mark live objects 0.833ms
[0.089s][info][gc,phases,start] GC(0) Phase 2: Prepare for compaction
[0.090s][info][gc,phases ] GC(0) Phase 2: Prepare for compaction 0.761ms
[0.090s][info][gc,phases,start] GC(0) Phase 3: Adjust pointers
[0.090s][info][gc,phases ] GC(0) Phase 3: Adjust pointers 0.477ms
[0.090s][info][gc,phases,start] GC(0) Phase 4: Compact heap
[0.091s][info][gc,phases ] GC(0) Phase 4: Compact heap 0.571ms
[0.092s][info][gc,heap ] GC(0) Eden regions: 2->0(1)
[0.092s][info][gc,heap ] GC(0) Survivor regions: 0->0(0)
[0.092s][info][gc,heap ] GC(0) Old regions: 0->1
[0.092s][info][gc,heap ] GC(0) Archive regions: 0->0
[0.092s][info][gc,heap ] GC(0) Humongous regions: 0->0
[0.092s][info][gc,metaspace ] GC(0) Metaspace: 488K(704K)->488K(704K) NonClass: 462K(576K)->462K(576K) Class: 25K(128K)->25K(128K)
[0.092s][info][gc ] GC(0) Pause Full (System.gc()) 4M->0M(16M) 4.173ms
[0.093s][info][gc,cpu ] GC(0) User=0.00s Sys=0.00s Real=0.00s
[0.093s][info][gc,heap,exit ] Heap
[0.094s][info][gc,heap,exit ] garbage-first heap total 16384K, used 1039K [0x0000000600c00000, 0x0000000800000000)
[0.094s][info][gc,heap,exit ] region size 4096K, 1 young (4096K), 0 survivors (0K)
[0.094s][info][gc,heap,exit ] Metaspace used 491K, committed 704K, reserved 1056768K
[0.094s][info][gc,heap,exit ] class space used 25K, committed 128K, reserved 1048576K

gc 日志中可见 “Pause Full (System.gc()) 4M->0M(16M) 4.173ms”,这说明虚拟机并没有因为两个对象存在相互引用就放弃回收他们,也说明了 Java 虚拟机并不是通过引用记数算法进行对象存活判断的。

可达性分析算法

当前主流的商用程序语言 (Java、C#、Lisp) 的内存管理子系统,都是靠通过可达性分析 (Reachability Analysis) 算法来判定对象是否存活。其基本思路是通过一系列 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走的路径被称为 “引用链” (Reference Chain),如果某个对象到 GC Roots 间没有任何引用相连,用图论的话说,从 GC Roots 到该对象不可达,证明此对象是不可能再被使用的。

在 Java 技术体系中,固定可作为 GC Roots 的对象包括以下几种:

  • 虚拟机栈 (栈帧中的本地变量表) 中引用的对象,如当前正在运行的方法所使用的参数、局部变量、临时变量等。
  • 方法区中的静态属性引用对象,如 Java 类的引用类型静态变量。
  • 方法区中常量引用对象,如字符串常量池 (String Table) 里的引用。
  • 本地方法栈中 JNI (通常被称为 Native 方法) 引用的对象。
  • Java 虚拟机内部引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象 (如 NullPointException、OutOfMemoryError) 等,还有系统类加载器。
  • 所有被同步锁 (synchronized 关键字) 持有的对象。
  • 反应 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合外,根据用户所选的垃圾收集器已经当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成完整 GC Roots 集合。如后文提到的分代收集和局部回收 (Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时 (如典型的,只针对新生代的垃圾收集),必须考虑到内存区域只是虚拟机的实现细节,他们不是孤立封闭的,所以某个区域的对象完全有可能被位于堆中其他区域的对象引用,这时就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。

目前最新的几款垃圾收集器 (例如 OpenJDK 中的 G1、Shenandoah、ZGC 以及 Azul 的 PGC、C4) 无一例外的都具备局部回收的特征,为了避免 GC Roots 包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理 (见后文)。

关于引用

无论是通过引用计数法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象存活都和引用脱不开关系。在 JDK 1.2 版本前,Java 中的引用定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。但是一个对象不应该只有 “被引用” 和 “未被引用” 两种状态。例如这种情况:当内存空间还足够,就保留在内存中,如果内存空间在完成 GC 后还是十分紧张,就抛弃这些对象 —— 很多系统缓存功能都符合此场景。

在 JDK 1.2 版本后,Java 对引用的概念进行了扩充,将引用分为强引用 (Strongly Reference)、软引用 (Soft Reference)、弱引用 (Weak Reference) 和虚引用 (Phantom Reference) 四种,四种引用强度依次减弱

  1. 强引用 (Strongly Reference)

    “最传统” 的引用定义,指在程序代码中普遍存在的引用赋值,类似 “Object obj = new Object()” 的这种引用关系。无论何种情况,只要强引用还在,垃圾回收器就永远不会回收掉被引用的对象。

  2. 软引用 (Soft Reference)

    描述一些还有用,但非必要的对象,系统将要内存溢出前,会把这些对象列入回收范围中进行二次回收,如果回收后内存依然不足,才会抛出内存溢出异常。

  3. 弱引用 (Weak Reference)

    非必要的对象,其强度比软引用更弱。其对象只能生存到下一次 GC 发生前。当 GC 发生时,无论内存是否足够,都会回收掉仅被弱引用关联的对象。

  4. 虚引用 (Phantom Reference)

    也被称为 “幽灵引用” 或 “幻引用”,是最弱的引用关系。完全不会对其生存时间造成影响。也无法通过该引用获取实例。为一个对象设置虚引用仅是为了能让此对象被回收时受到一个系统通知。

对象死亡判定

即使是在可达性分析算法中判定为不可达对象,也并非 “非死不可”。一个对象真正死亡,最多会经历两次标记,如果在进行可达性分析时没有在 GC Roots 的引用链上,将第一次被标记。之后判断该对象是否有必要执行 finalize()。若对象没有覆盖 finalize(),或者 finalize() 已被虚拟机调用,那么虚拟机将这两种情况都视为 “没有必要执行”。(Ryuu:注意,一个对象的 finalize() 只会被调用一次。)

若有必要执行 finalize(),那么该对象将会被放置在一个名为 F-Queue 的队列中,并在稍后由虚拟机自己建立的,低调度优先级的 Finalizer 线程去执行它们的 finalize()。这里的 “执行” 仅指虚拟机会触发这个的方法开始运行,并不承诺一定等待他们运行结束。这是因为,若 finalize() 执行的太慢,或者极端的发生了死循环,将导致 F-Queue 的其他对象永久处于等待,甚至导致整个内存回收子系统崩溃。finalize() 方法是对象逃脱死亡的最后机会,稍后收集器将对 F-Queue 中的对象进行二次标记,若对象要在 finalize() 中避免死亡 —— 只需重新与引用链上的任何一个对象建立关联即可,例如把 this 赋给某个类变量或者对象成员的成员变量,那么它将会在二次标记时被移除 “即将回收” 的集合;如果它没有这么做,那就真的会被回收了。

如下是对象执行 finalize(),依然存活的示例:(Ryuu:当然,一般情况下没人会在 finalize() 里做除了释放资源以外的事。)

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
// DON'T DO THIS!
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive(){
System.out.println("alive");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}

/**
* finalize method executed!
* alive
* dead
*/
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc();
// 因为 GC 的优先级低, 先让此线程暂停,以等待 GC
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("dead");
}

SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("dead");
}
}
}

见以上代码段,SAVE_HOOK 对象的 finalize() 的确被执行,并且成功逃脱回收了。

另外一点是,一个对象的 finalize() 只会被调用一次,所以第二次的自救失败了。

上文的代码不是让读者去用此方法拯救对象。应该尽量避免使用 finalize(),它并不等同于 C 和 C++ 中的析构函数,这是 Java 刚诞生时为了使传统 C、C++ 程序员更容易接受 Java 所做的妥协。其代价是极高的。 finalize() 能做的工作,使用 try-finally 或者其他的方式能够做的更好、更及时

(Ryuu:finalize() 自 Java 9 被弃用。其运行代价高,可能导致死锁、挂起。即使不在需要 finalize(),finalize() 也无法取消。不同对象的 finalize() 执行顺序没有保证,并且执行完成时间也没有保证。)

方法区回收

有人认为方法区 (如 HotSpot 虚拟机中的元空间或永久代) 是没有垃圾收集的,JLS 中提到,可以不要求虚拟机实现方法区中的垃圾收集,确实也有未实现或未完全实现方法区类型卸载的收集器存在 (如 JDK 11 时期的 ZGC 收集器就不支持类卸载),方法区垃圾收集的付出/收获比也通常是较低的:在 Java 堆中,尤其是新生代中,对与常规应用进行一次 GC 通常可以回收 70% - 99% 的内存空间,相比之下,方法区回收有着苛刻的判定条件,其垃圾收集效果却往往很低。

方法区的垃圾收集主要回收两种内容:废弃的常量和不再使用的类型。回收废弃常量和回收 Java 堆中对象非常类似。例如字符串 “Java” 进入了常量池,但当前系统没有任何一个字符串对象的值是 “Java” (也就是说 “Java” 没有被任何字符串对象引用),且虚拟机中也没有其他地方引用此字面量。如果此时发生 GC,若垃圾收集器判断有必要,这个 “Java” 将会被系统清理出常量池,常量池中的其他类 (接口)、方法、字段的符号引用也与此类似。

判定一个类型是否属于 “不再被使用的类” 的条件就比较苛刻了。需同时满足条件:

  1. 类所有的实例被回收,Java 堆中不存在该类及其派生子类的实例。
  2. 加载该类的类加载器已被回收。(此条件一般很难满足,除非是精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等)
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法。

Java 虚拟机被允许对满足以上三个条件的无用类进行回收,仅是 “被允许”,并不是和对象一样,没有了引用就必然会回收。

关于是否要进行类型回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以通过 -verbose:class 以及 -XX:+TraceClass-Loading、 -XX:+TraceClassUnLoading 查看类加载和卸载信息,其中 -verbose:class 和 -XX:+TraceClass-Loading 可在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参数需要 FastDebug版虚拟机支持。

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