0%

垃圾回收算法

一、概述

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行。

二、判断对象已死

垃圾回收需要回收堆和方法区中的已死的对象,也就是已经变为垃圾的对象。但是我们需要判断哪些对象可回收哪些对象不可以。下面我们介绍两种判断方法,即引用计数法和可达性分析法。

引用计数法

引用计数法的大致执行过程为:首先,给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
主流的Java虚拟机里面不会选用引用技术算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

比如下图,虚拟机栈中objectA和objectB分别持有对堆中对象实例的引用,而堆中两个对象相互也有引用,此时堆中的两个对象实例的引用计算器值都为2。objectA和objectB不再持有对象实例的引用时,堆中的对象实例可以被视为垃圾,但是此时堆中的两个对象实例的引用计算器值却都为1,因此其很难解决对象之间相互循环引用的问题。

下面是相关的验证代码,运行参数设置为-verbose:gc -XX:+PrintGCDetails,这两个参数用于打印垃圾回收日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ReferenceCountingGC {

public Object instance = null;

// 起到占用内存的作用
private byte[] bigSize = new byte[20 * 1024 * 1024];

public static void main(String[] args) {
ReferenceCountingGC objectA = new ReferenceCountingGC();
ReferenceCountingGC objectB = new ReferenceCountingGC();
objectA.instance = objectB;
objectB.instance = objectA;

objectA = null;
objectB = null;

System.gc();
}
}

可以发现新生代被回收了,从22476K变为616K,也说明堆中的对象是有被回收的,也说明主流的Java虚拟机里面不会选用引用技术算法来管理内存。

可达性分析算法

通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。

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

Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 可作为 GC Roots 的对象包含以下几种:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用4种。

(一)强引用

被强引用关联的对象不会被垃圾收集器回收,我们可以使用 new 一个新对象的方式来创建强引用。

1
Object obj = new Object();

(二)软引用

​ 软引用是用来描述一些还有用但并非必需的对象。被软引用关联的对象,只有在内存不够的情况下才会被回收,使用 java.lang.ref.SoftReference 类来创建软引用,具体代码如下:

1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; // 使对象只被软引用关联

(三)弱引用

若引用也是用来描述非必需对象的,但是他的强度比软引用更弱一些,被弱引用关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次垃圾收集,可以使用 WeakReference 类来实现弱引用。

1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<>(obj);
obj = null;

(四)虚引用

​ 虚引用又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。可以使用 PhantomReference 来实现虚引用。

1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<>(obj);
obj = null;

finalize方法

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

  1. 对象在进行可达性分析后发现没有与 GC Roots相连接的引用链,那它将会被第一次标记并进行一次筛选的条件是此对象是否有必要执行finalize方法。
  2. 当对象没有覆盖finaliza() 方法,或者finaliza() 方法已经被虚拟机调用过,虚拟机则会回收这个对象。否则执行finalize方法,此过程可能会将宣判“死亡”的对象“救活”。

对象复活

如果实例对象被判定为有必要执行finaliza() 方法,那么此对象将会放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发此方法,但并不承诺会等待它运行结束。

因为如果一个对象在finaliza() 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致F-Queue 队列中的其它对象永久处于等待,甚至导致整个内存回收系统崩溃。

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

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

代码示例:

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
46
47
48
49
50
51
/**
【一次对象自我拯救的演示】
* 此代码演示了两点:
* 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();

// 1. 对象第一次成功拯救自己
SAVE_HOOK = null;
//gc时会调用finalize方法
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}

// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
// 同样会再gc时调用finalize方法,但是finalize执行过一次了
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}

运行结果:

finalize method executed!
yes, i am still alive :)
no, i am dead :(

由以上结果可知,SAVE_HOOK 对象的finalize() 方法确实被GC收集器触发过,并且在收集前成功逃脱了。

另一个值得注意的地方,代码中有两段一模一样的代码段,执行结果却是一次逃脱成功,一次失败。

这是因为任何一个对象的finalize() 方法都只会被系统调用一次,如果对象面临下一次回收,它的finalize() 方法不会再被执行,因此第二次逃脱行动失败。

执行过程:

有关finaliza()方法的建议:

  • 需要特别说的是,finalize() 方法,不建议开发人员使用这种方法拯救对象。

  • 应当尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做的一个妥协。

  • 它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

  • 有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对此方法用途的一种自我安慰。

  • finalize() 能做的工作,使用try-finally 或者其它方法都更合适和及时,所以作者建议大家可以忘掉此方法存在。

回收方法区

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

  • 废弃常量的回收:回收废弃常量与回收Java堆中的对象非常类似。以常量池中字符串常量的回收为例。例如一个字符串“abc”已经进入常量池,但是无任何String对象引用常量池的此常量,也无其它引用此字符串常量,“abc”常量会被系统清理出常量池。

  • 无用类的回收:常量池中的其他类(接口)、方法、字段的符号引用也是如此。

    判定一个类是否是“无用类”的3个条件:

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

三、垃圾收集算法

标记-清除算法

参看下图,标记-清除算法将存活的对象进行标记,然后清理掉未被标记的对象。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

标记-整理算法

如下图所示,标记-整理算法让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,该算法主要用于老年代。

复制算法

复制算法将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。参见下图。

复制算法主要不足是只使用了内存的一半,代价较高。

现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和较小的 From Survivor 和 To Survivor空间,参见下图。每次使用 Eden 空间和一块 From Survivor。在回收时,将 Eden 和 From Survivor 中还存活着的对象一次性复制到To Survivor 空间上,最后清理 Eden 和使用过的From Survivor。HotSpot 虚拟机的 Eden 和 From Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么To Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

分代收集算法

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。一般将 Java 堆分为新生代和老年代。

  • 新生代使用:复制算法
  • 老年代使用:标记 - 清理 或者 标记 - 整理 算法
------ 本文结束------