这里介绍的垃圾回收相关算法,主要解决的问题:
判断哪些内存是垃圾(需要回收的)?
常用的两种算法:
- 引用计数
- 可达性分析(GC Root)
首先介绍算法前,得定义:
如何判断一个对象的死亡?
我们一般这样定义:当一个对象不再被任何存活的对象继续引用的时候,这个对象就死亡了。
引用计数
引用计数算法,是给每一个对象添加一个计数器,当有对象引用它的时候,计数器+1,当有对象取消对它的引用时,计数就会-1。
当计数器的值为 0 时,即说明没有对象引用它,也就是这个对象死亡了。
这种算法很简单,但是有个重大缺陷,那就是无法解决循环引用的问题。
什么是循环引用问题呢?
比如对象A 引用 对象B,对象B 引用 对象A,那么 对象A 和 对象B 的计数器都为1。但是如果后续的运行环境再也用不到对象A 和 对象B,那么就造成了内存泄漏。
上图就是循环引用的例子。对象引用 Obj1 和 Obj2 在栈中,然后分别指向在堆中的具体实例。然后两个相互实例中的成员互相引用。那么对于堆中的对象而言,就有2个引用。一个是来自Obj1,一个来自堆对象的另一方。
如果,现在将 Obj1 指向 nu l l,那么就如下图:
这个时候,引用已经不可用了,但是堆中的对象仍然相互引用,他们的计数器不为0,所以无法死亡。
但是,Java 没有使用这种算法,而是使用了我们后面说的可达性算法,所以接下来的演示,GC 会将这种情况的内存给其清理。
package GC;
public class ReferenceCountGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
// 每个对象中包含2M的成员,方便观察
private byte[] bigSize = new byte[2 * _1MB];
public static void main(String[] args) {
ReferenceCountGC objA = new ReferenceCountGC();
ReferenceCountGC objB = new ReferenceCountGC();
objA.instance = objB.instance;
objB.instance = objA.instance;
//取消对对象的引用
objA = null;
objB = null;
// 是否进行垃圾回收
System.gc();
}
}
这段代码实现的就是上面图片所描述的情况。
首先,我们将 System.gc() 注释掉,也就是我们在默认情况下,不去触发垃圾回收。并在运行的时候,添加参数 -XX:+PrintGCDetails。我们观察输出结果
可以看到,这个时候,占用的空间为8M左右。
如果我们取消注释,也就是主动去调用垃圾回收器,那么运行结果为:
占用空间为2M左右。
可以看出来,Java 的垃圾回收,并非采用我们上面介绍的引用计数方式。
可达性分析
可达性算法,还有一系列的别名:根搜索算法,追踪性垃圾收集,GC Root。
之后,看到原理,其实这些别名都是描述原理的。
首先,我们选取一些对象,这些对象是存活的,也被称为 GC Roots,然后根据这些对象的引用关系,凡是直接或者间接跟 GC Roots 相关联的对象,都是存活的。就像图中的连通性判断一样。
这个算法的想法不难。难的是,如何确定 GC Roots。
我们考虑,我们什么时候需要用到对象?(我们需要对象的时候,肯定需要这个对象是存活的)
- 栈中保存着,我们当前或者之后需要运行的方法及相关参数,所以,栈上所引用的堆中对象肯定是存活的。
- 类中的一些属性,比如,静态属性,因为它不依赖于具体的类
- 一些常用的对象,以免清理后,又要重复加载,比如常用的异常对象,基本数据类型对应的 Class 对象。
除此之外,还有很多零零碎碎的。
在堆结构周围的一些结构,其中引用的对象可以作为GC Roots
具体 GC Roots 可以概括为:
-
虚拟机栈上(确切的说,是栈帧上的本地变量表)所引用的对象
-
本地方法栈引用的对象
-
方法区中的静态属性,常量引用
-
Java 虚拟机的内部引用,常用数据类型的 Class 对象,常驻的异常对象,系统类加载器
-
所有被同步锁持有的对象
除此之外,还有一些临时的 GC Roots 可以加入进来。这里涉及到新生代老年代。
比如老年代中的对象一般都存活时间比较久,也就是大概率是活着的对象,也可临时作为 GC Roots。
可达性算法的一些细节
前面说了可达性算法,我们根据 GC Roots 来进行标记对象的死活。
但是,被判定为不可达的对象,并不立刻死亡。它仍然有次机会进行自救。
这个自救的机会,是需要重写 finalize()进行自救。
也就是可达性算法的逻辑大致是这样的:
- 第一次进行标记,凡是不可达 GC Roots 的对象,都暂时判定为死亡,只是暂时
- 检查暂时被判定为死亡对象,检查是否有重写 finalize()方法,如果有,则触发,对象可以在里面完成自救。
如果没有自救成功 或者 没有重写 finalize()方法,则宣告这个对象的死亡。
除此之外,这个对象中的 finalize()方法,只能被调用一次,一生只有一次自救机会。
这个方法,官方并不推荐,所以不必细究。
接下来,演示下上面的两次标记过程以及自救过程。
(个人认为,《深入理解 Java 虚拟机》中的此章节代码,略有点不够完善,故略微改动)
package GC;
import javax.swing.tree.VariableHeightLayoutCache;
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
private byte[] bigSize = new byte[5*1024*1024];
public void isAlive(){
System.out.println("Yes, i am alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("Finalize method executed");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
System.gc();
}else {
System.out.println("Dead");
System.gc();
}
}
}
在这个程序中,我们给这个类,添加名为 bigSize 的属性,其占用 4M 大小的空间。
大致分析下代码逻辑:
- 创建了一个对象,其中有成员占用 4M 的空间
- 取消对这个对象的引用
- 调用垃圾回收(第一次标记)
- 调用 finalize 方法进行自救
- 之后再次调用垃圾回收(第二次标记)
所以演示的时候,分为两种情况:
- FinalizeEscapeGC.SAVE_HOOK = this; 未注释,完成自救
运行时,参数仍然设置为 +XX:PrintGCDetails,可以看到输出结果:
第一次调用垃圾回收,仍然占用 5M,说明此时即便失去引用,但是仍然未被清理。
在 finalize()中完成自救后,第二次调用垃圾回收的时候,仍然占用 5M 的内存大小。说明自救成功。
- FinalizeEscapeGC.SAVE_HOOK = this; 注释,无法完成自救
第一次垃圾回收,占用 5M,保留了对象。无法完成自救,然后第二次被清理掉。
所以我发现以下表述也许更为确切:
- 当对象重写了 finalize()方法的时候,第一次垃圾回收的时候,如果为不可达对象,对其进行暂缓,并不清理。
- 当对象没有重写 finalize()方法的时候,且为不可达对象的时候,直接判定死亡。