《深入理解Java虚拟机》 垃圾收集算法

———— JDK 1.7
words: 3.4k    views:    time: 12min
GC


Java内存运行时的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不絮地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管JIT编译器会进行一些优化,但大体可认为是编译期可预知的),因此这个几个区域内存的分配和回收都具备确定性。

Java堆方法区不一样,一个接口的多个实现类需要的内存可能不一样,一个方法的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,因此垃圾收集器所关注的主要就是堆内存以及方法区。

1. 垃圾标记算法

1.1. 引用计数

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

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

ReferenceCountingGC
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
/**
* GC VM Args:
* -XX:+PrintGC 输出GC日志
* -XX:+PrintGCDetails 输出GC的详细日志
* -XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
* -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
* -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
* -Xloggc:../logs/gc.log 日志文件的输出路径
*/
public class ReferenceCountingGC {

public Object instance = null;

private static final int _1MB = 1024 * 1024;

private byte[] bytes = new byte[2 * _1MB]; // 占点内存

public static void main(String[] args) {

ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();

objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

System.gc(); // 测试objA和objB能否被GC
}
}
1
2
3
4
5
6
7
8
9
10
11
[GC [PSYoungGen: 6697K->568K(75776K)] 6697K->568K(248320K), 0.0008632 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC [PSYoungGen: 568K->0K(75776K)] [ParOldGen: 0K->468K(172544K)] 568K->468K(248320K) [PSPermGen: 2563K->2562K(21504K)], 0.0074430 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
Heap
PSYoungGen total 75776K, used 1950K [0x00000007ab900000, 0x00000007b0d80000, 0x0000000800000000)
eden space 65024K, 3% used [0x00000007ab900000,0x00000007abae7ba8,0x00000007af880000)
from space 10752K, 0% used [0x00000007af880000,0x00000007af880000,0x00000007b0300000)
to space 10752K, 0% used [0x00000007b0300000,0x00000007b0300000,0x00000007b0d80000)
ParOldGen total 172544K, used 468K [0x0000000702c00000, 0x000000070d480000, 0x00000007ab900000)
object space 172544K, 0% used [0x0000000702c00000,0x0000000702c75178,0x000000070d480000)
PSPermGen total 21504K, used 2569K [0x00000006fda00000, 0x00000006fef00000, 0x0000000702c00000)
object space 21504K, 11% used [0x00000006fda00000,0x00000006fdc82700,0x00000006fef00000)

从运行结果可以看出,并没有因为两个对象相互引用就不对它们进行回收。

1.2. 可达性分析

基本思想是通过一系列的称为GC Roots的对象作为起始,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的

Java语言中,可作为GC Roots的对象包括:

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

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,它们可以暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少经历两次标记过程。

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

如果对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。这里所谓的执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(极端情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,必须重新与引用链上的任何一个对象建立关联,那么在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

FinalizeEscapeGC
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
public class FinalizeEscapeGC {

public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive(){
System.out.println("yes, i am alive.");
}

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

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc(); // 第一次GC,可以在finalize()中拯救自己

Thread.sleep(500); // 执行finalize()方法的线程优先级很低,等待0.5秒
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no, i am dead.");
}

SAVE_HOOK = null;
System.gc(); // 第二次GC,不再执行finalize(),拯救失败

Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no, i am dead.");
}
}
}
1
2
3
finalize executed.
yes, i am alive.
no, i am dead.

两次结果不一样,是因为任何一个对象的 finalize() 都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法将不再被执行。

要说明的是,finalize()只是Java刚刚诞生时为了使C/C++程序员更容易接受它所做的一个妥协,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。并且,finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以基本可以忘掉这个方法的存在。

2. Java引用

无论是引用计数还是可达性算法,其判断对象是否存活都是通过引用来判定的

在JDK 1.2以前,Java中引用定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。但是它无法描述这样一类对象:当内存空间足够时,可以保留在内存中;而当垃圾收集后内存还是非常紧张时,则抛弃这些对象

很多系统的缓存功能都符合这样的应用场景,所以在JDK 1.2之后,对Java引用的概念进行了扩充,分为强引用、软引用、弱引用、虚引用

  • 强引用:类似Object obj = new Object()这样的引用,只要强引用还在,垃圾收集器永远不会回收被引用的对象;

  • 软引用:SoftReference描述一些还有用,但并非必需的对象。对于软引用关联的对象,在系统将要发生内存溢出时,会把这些对象列进回收范围进行二次回收,如果仍然没有足够内存,则抛出溢出异常;

  • 弱引用:WeakReference也用来描述非必须的对象,但它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉这些对象;

  • 虚引用:PhantomReference是最弱的一种引用,一个对象是否有虚引用的存在,完全不影响它的生存时间,也无法通过虚引用来取得一个对象实例。它唯一的目的就是能在这个对象被收集器回收时收到一个系统通知;

3. 方法区回收

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

回收废弃常量与回收Java堆中的对象类似。以常量池中字面量的回收为例,如果一个字符串abc已经进入了常量池,但是当前系统没有任何一个String对象叫做abc的,换句话说,就是没有任何String对象引用常量池中的abc常量,也没有其他地方引用这个字面量。那么在内存回收时,如果有必要的话,这个abc常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

回收类的条件比较苛刻,判定一个无用的类需要3个条件:

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

但即使满足上面3个条件,也仅仅是可以回收,不是和对象一样,不使用了就必然回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class,以及-XX:+TraceClassLoading-XX:TraceClassUnLoading查看类加载和卸载信息, 其中-verbose:class-XX:+TraceClassLoading可以在product版的虚拟机中使用,而-XX:TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证方法区不会溢出。

4. 垃圾收集算法

4.1. 标记-清除算法

先标记出所有需要回收的对象,在标记完成后再统一回收,其标记过程就是基于上面的可达性分析算法。

之所以说这是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的不足有两个:

  • 标记和清除过程效率不高;
  • 标记清除之后会产生大量不连续的内存碎片,导致后续分配大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作;

4.1. 复制算法

为了解决效率问题,将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,不会产生碎片等情况,只要移动堆订的指针,按顺序分配内存即可。实现简单,运行高效,但它的代价是将内存缩小为原来的一半。

在商业虚拟机中都采用这种收集算法,统计表明在新生代中98%的对象都是朝生夕死,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,然后清理掉Eden和刚使用过的Survivor

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,这样每次新生代中的可用内存为90%,只保留10%的内存空间。当然,如果Survivor空间不够用,则需要依赖其他内存(老年代)进行分配担保。

4.2. 标记-整理算法

复制算法在对象存活率较高时需要进行较多的复制动作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。所以在老年代一般不能直接选用复制算法,而是采用标记-整理算法,与“标记-清除”算法类似,只是在清理完无用对象后会让所有存活的对象都向一端移动,并更新引用其对象的指针。

它的代价是在标记-清除的基础上还需进行对象的移动,成本相对较高,好处则是不会产生内存碎片。

因此,商业虚拟机对于垃圾收集一般采用“分代收集”算法,把java堆分为新生代和老年代。对于新生代,每次垃圾收集都有大批对象死去,仅少量存活,所以选用复制算法。而老年代中因为对象存活率高、没有额外的空间对它进行分配担保,所以就用标记-清理或者标记-整理算法。


参考:

  1. Copyright ©《深入理解java虚拟机》