深入理解 Java 虚拟机(二)垃圾收集算法

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011330638/article/details/82669441

程序计数器、虚拟机栈、本地方法栈的生命周期与线程相同,且一个栈帧分配多少内存在编译期就已经确定,方法执行完毕后内存即被回收,因此在这几个区域不需要过多考虑回收的问题。

而 Java 堆和方法区则不同,内存的分配和回收都是动态的,因此垃圾收集器所关注的是这部分内存。

对象已死吗

引用计数算法

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

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

可达性分析算法

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

可达性分析算法

在 Java 语言中,可作为 GC Roots 的对象包括以下几种:
1) 虚拟机栈(栈帧中的本地变量表)中引用的对象
2) 方法区中类静态属性引用的对象
3) 方法区中常量引用的对象
4) 本地方法栈中 JNI 引用的对象

再谈引用

在 JDK 1.2 之前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就成这块内存代表着一个引用。

JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种:

1) 强引用。即程序代码中最普通的,使用 new 创建的对象。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

2) 软引用。Java 提供了 SoftReference 来实现软引用。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

3) 弱引用。使用 WeakReference 实现。弱引用关联的对象只能生存到下一次垃圾收集发生之前。

4) 虚引用。使用 PhantomReference 实现。一个对象是否有虚引用,不会对其生存时间造成影响,也无法通过虚引用来获取一个对象的示例。为一个对象设置虚引用关联的唯一目的是在这个对象被收集器回收时受到一个系统通知。

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是非死不可的。要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那么它会被第一次标记;之后会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法,finalize 是对象逃脱死亡命运的最后一次机会,并且只能使用一次,如果对象没有覆盖 finalize 方法,或者 finalize 已经被虚拟机调用过,那么它会被直接回收。

/**
* 此代码演示了两点:
* 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();

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

任何一个对象的 finalize 方法都只会被系统自动调用一次,但建议避免使用这个方法,这个方法是 Java 设计之初为了让 C/C++ 程序员更容易接受而设计的,它运行的代价高昂,不确定大,无法保证各个对象的调用顺序,建议完全忘掉这个方法的存在。

回收方法区

方法区(也是 HotSpot 虚拟机的永久代)的垃圾收集主要分为两部分:废弃常量和无用的类。

判定一个常量是否为废弃常量很简单,比如字符串 “abc”,只需检查有没有 String 对象为 “abc” 即可。

但要判定一个类是否是无用的类,条件则相对苛刻许多:
1) Java 堆中不存在该类的任何实例
2) 加载该类的 ClassLoader 已经被回收
3) 该类对应的 java.land.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机可以对满足上述 3 个条件的无用类进行回收,至于是否回收,HotSpot 还提供了 -Xnoclassgc 参数进行控制。

垃圾收集算法

标记 - 清除算法

即首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象:

标记 - 清除算法

它的主要不足有 2 个:
1) 效率问题,标记、清除两个过程的效率都不高
2) 空间问题,标记、清除后会产生大量不连续的内存碎片

复制算法

复制算法是为了解决效率问题而出现的 ,它将可用的内存按容量划分大小相等的两块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等情况,实现简单,运行高效,代价是将内存缩小为了原来的一半,未免高了点。

复制算法

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司研究表明,新生代中的对象 98% 都是“朝生夕死”的,因此并不需要按照 1:1 的比例来划分内存空间。而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor,当回收时,Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的比例为 8:1,即新生代中可用内存空间为整个新生代容量的 90%,只有 10% 的空间会被“浪费”。当然,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其它内存(老年代)来进行分配担保。

标记 - 整理算法

复制收集算法在存活率较高的区域时要进行较多的复制操作,且需要有额外的空间进行分配担保,因此老年代一般不能直接使用这种算法。根据老年代的特点,有人提出了 “标记 - 整理” 算法,让所有存活的对象都向一端移动,然后直接清除端边界以外的内存:

标记 - 整理算法

分代收集算法

当代商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。新生代中,每次垃圾收集都有大量对象死去,只有少量对象能够存活,因此采用复制算法。老年代中对象存活率高,没有额外空间进行分配担保,因此必须使用标记 - 整理算法。

HotSpot 的算法实现

枚举根节点

以可达性分析算法中从 GC Roots 节点寻找引用链为例,可作为 GC Roots 的节点主要是全局性的引用(常量或静态属性)与执行上下文(例如栈帧的本地变量表)。现在很多应用仅仅方法区就有几百兆,如果要逐个检查,必然要消耗很多时间;此外,GC 进行时必须停顿所有 Java 执行线程(Sun 把这件事称为 Stop The World)。

目前的主流 Java 虚拟机使用的都是准确式 GC,系统停顿后并不需要一个不漏地检查完所有执行上下文和全局的引用位置,在 HotSpot 的实现中,是使用一组 OopMap 的数据结构来达到这个目的的,类加载完成的时候,HotSpot 就会把对象内什么偏移量对应什么类型的数据计算出来,在 JIT 编译过程中,会在特定的位置记录引用的地址,这样在 GC 扫描时就能直接获取这些信息了。

安全点

前面提到,HotSpot 会在“特定的位置”记录引用信息,这些位置称为安全点(SafePoint)。即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点的时候才能暂停。安全点的选定是以程序“是否具有让程序长时间执行的特征”为标准决定的,长时间执行的最明显特征是序列复用,如方法调用、循环跳转、异常跳转等,因此具有这些功能的指令才会产生 SafePoint。

对于 SafePoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程都跑到安全点上再停顿下来。解决方案有两个,一个是抢先式中断,即 GC 时首先把所有线程全部中断,如果发现线程中断的位置不在安全点上,就恢复线程,让它跑到安全点的位置,现在几乎没有虚拟机采用这个方案。

另一个是主动式中断,即 GC 时不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,标志为真时就自己中断挂起,轮询标志的位置和安全点是重合的。

安全区域

使用 SafePoint 还没有完美解决进入 GC 的问题,因为程序可能不在执行状态,即线程可能处于 Sleep 或 Blocked 状态,此时就无法响应中断请求。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指一段代码中,引用关系不会发生变化(线程处于 Sleep 或 Blocked 的状态时,引用关系不会发生变化),在这个区域的任意地方开始 GC 都是安全的。在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当 JVM 发起 GC 时,就不用管 Safe Region 中的线程了。当线程离开 Safe Region 时,它要检查是否已经完成了根节点枚举(或者整个 GC 过程),如果完成了就继续执行线程,否则必须等待直到收到可以离开 Safe Region 的信号为止。

猜你喜欢

转载自blog.csdn.net/u011330638/article/details/82669441