JVM探秘:四种引用、对象的生存与死亡

Java虚拟机的内存区域中,程序计数器、Java栈和本地方法栈是线程私有的,随线程而生随线程而灭,因此这几个区域的内存回收和分配都有确定性,所以主要探究的是Java堆和方法区的内存分配及回收。

Java堆

在Java堆中存放着所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是判断这些对象中哪些还存活,哪些已经死去(即不会再被使用到的对象)。

Java中的引用

JDK1.2及之前,关于引用的定义是这样的:如果一块内存中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表一个引用(reference)。但是这种定义比较狭隘,一个对象就只有被引用和没有被引用两种状态。还有这样一种“食之无味,弃之可惜”的对象:当内存空间充足时,则能继续保留在内存中,如果内存空间在垃圾收集后非常紧张,则可以抛弃这些对象。很多缓存功能都符合这样的应用场景。

JDK1.2之后,对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次递减:

  • 强引用(Strong Reference)就是在代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。

  • 软引用(Soft Reference)是用来描述有用非必需的对象。软引用关联的对象,在系统将要发生内存溢出之前,将会对这些对象进行二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。上面所说的“食之无味,弃之可惜”的对象就是属于软引用。

  • 弱引用(Weak Reference)是用来描述非必需的对象,但是比软引用更弱一些,弱引用关联的对象只能生存到下一次垃圾收集发生之前。当下一次垃圾收集时,无论内存是否足够,都会回收掉被弱引用关联的对象。

  • 虚引用(Phantom Reference)也称为幽灵引用或者幻影引用,它是最弱的一种引用。一个对象是否有虚引用存在,完全不会对其生存时间造成任何影响,也无法通过虚引用获得一个对象实例。为对象设置虚引用的目的,就是能在这个对象被收集器回收时收到一个系统通知。

引用计数算法

很多书中判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当一个地方引用它,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0 的对象就是不再被使用的。

引用记数算法虽然实现简单,判定效率也高,但是有一个弊端,就是它很难解决对象之间相互循环引用的问题。下面的代码中,objA和objB互相引用,如果使用引用计数法,这两个对象的引用计数器值都为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
/**
* 引用记数算法测试
* VM Args: -XX:+PrintGCDetails
* Run With JDK 1.8
* */
public class ReferenceCountingGC {

public Object instance = null;
private static final int _1M = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1M];

public static void main(String[] args) {
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
[GC (System.gc()) [PSYoungGen: 7432K->728K(38400K)] 7432K->736K(125952K), 0.0012008 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 728K->0K(38400K)] [ParOldGen: 8K->667K(87552K)] 736K->667K(125952K), [Metaspace: 3491K->3491K(1056768K)], 0.0044445 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 38400K, used 333K [0x00000000d5c00000, 0x00000000d8680000, 0x0000000100000000)
eden space 33280K, 1% used [0x00000000d5c00000,0x00000000d5c534a8,0x00000000d7c80000)
from space 5120K, 0% used [0x00000000d7c80000,0x00000000d7c80000,0x00000000d8180000)
to space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)
ParOldGen total 87552K, used 667K [0x0000000081400000, 0x0000000086980000, 0x00000000d5c00000)
object space 87552K, 0% used [0x0000000081400000,0x00000000814a6cf0,0x0000000086980000)
Metaspace used 3497K, capacity 4498K, committed 4864K, reserved 1056768K
class space used 387K, capacity 390K, committed 512K, reserved 1048576K

从运行结果看,GC日志中包含“7432K->736K”,意味着虚拟机并没有因为两个对象互相引用就不回收它们,而说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

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

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

image

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

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。

对象的自我救赎

在可达性分析算法中不可达的对象也不是“必死无疑”的,这时它们会暂时处于“缓刑”阶段,要真正宣告死亡,至少要经历两次标记过程:第一次标记是当进行可达性分析后发现没有与GC Roots相连的引用链,就标记一次;然后如果对象覆盖了finalize()方法并且还未执行过,对象就会被放入一个叫F-Queue的队列中,会有一个单独的线程依次执行队列中对象的finalize()方法,finalize()方法是对象最后一次自我救赎的机会,只要跟GC Roots引用链上的任意对象建立关联,就可逃脱死亡,F-Queue的队列中的对象会被第二次标记。两次标记过后如果对象还没有逃脱,那基本上它就真的被回收了。

以下代码是对象一次自我救赎的演示:

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
45
/**
* 对象的一次自我救赎
* 1. 对象可以在GC时自我救赎
* 2. 这种机会只有一次,因为一个对象的finalize()方法至多会被调用一次
* */
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 method executed");
//把自己赋值给类变量,即与GC Roots建立了关联
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws Throwable{
SAVE_HOOK = new FinalizeEscapeGC();

//对象第一次自我救赎
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead");
}

//第二次自我救赎失败,因为finalize()只执行一次
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 method executed
yes, i am still alive
no, i am dead

从运行结果可知,SAVE_HOOK对象的finalize()方法确实被垃圾收集器触发过,并且在被回收之前成功逃脱了。代码中两段相同的代码,第二次没有成功逃脱,是因为一个对象的finalize()方法只会被系统自动调用一次。另外,finalize()方法运行代价高昂,不确定性大,无法保证对象的调用顺序,所以不建议使用此方法,可以用try-finally替代。

方法区

方法区也存在垃圾收集,只不过这块内存区域的垃圾收集效率比较低。在JDK1.6及之前,方法区的垃圾收集主要回收两部分内容:废弃常量和无用的类。但在JDK1.7的时候运行时常量池挪到了Java堆中,所以现在方法区主要是回收无用的类。运行时常量的回收跟堆内存中其他对象的回收方法基本一致。

同时满足以下三个条件,才会被判定为无用的类:

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

虚拟机可以对满足以上3个条件的无用类进行回收,也仅仅是“可以”,并不是一定会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景,都需要虚拟机具备类卸载的功能,以保证方法区不会溢出。

本文代码的 Github Repo 地址:https://github.com/cellei/JVM-Practice