《JVM 垃圾收集策略与算法》

原创
10/26 18:45
阅读数 4.1K

对象已死

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事是确定这些对象之中哪些还"存活"着,哪些已经"死去"了。

1. 引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,这个计数器值就+1;当引用失效时,计数器值就-1;任何时刻计数器为零的对象就是不可能在被使用的。但是在Java领域,至少主流的Java虚拟机里边都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外的情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

public class ReferenceCountingGc {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 这个成员属性唯一的意义是占点内存,以便能在GC日志中看清楚是否有回收
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {

        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();

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

        objA = null;
        objB = null;

        System.gc();
    }
    
    public static void main(String[] args) {
        testGC();
    }
}

2. 可达性分析法

当前主流的商用程序语言(Java、C#等)的内存管理系统,都是通过可达性分析算法来判定对象是否存活的。这个算法的基本思路是通过一系列称为"GC Roots"的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径被称为"引用链",如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达,则证明此对象是不可能再被使用的。

在Java技术体系中,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈中引用的对象
  • 在本地方法栈中引用的对象
  • 在方法区中类静态属性引用的对象
  • 在方法区中常量引用的对象
  • 所有被同步锁(synchronize关键字)所持有的对象
  • .......

3. 再谈引用

在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次减弱。

  • 强引用是最传统"引用"的定义,是指在程序代码中普遍存在的引用赋值,即类似Object object = new Object() 这种引用关系。无论任何情况下,只要强引用关系存在,垃圾回收器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但是非必须的对象。 只被软引用关联的对象,在系统将要发生内存溢出前会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被软引用关联的对象只能生存到下次垃圾收集为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也被称为"幽灵引用",它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

4. 生存还是死亡

即使在可达性分析法中判定为不可达的对象,也不是"非死不可"的,这时候它们暂时还处于"缓刑"阶段,要真正宣布一个对象死亡,只要要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会第一次被标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况视为"没有必要执行"。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列中,并在稍后由一个由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的"执行"是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者跟极端的发生了死循环,将很可能导致F-Queue队列中的其它对象永久的处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个变量或者对象的成员变量,那在第二次标记时它将被移出"即将回收"集合;如果对象这个时候还没有逃脱,那基本上它真的要被回收了。

public class FinalizeEscapeGc {

    public static FinalizeEscapeGc SAVA_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");
        FinalizeEscapeGc.SAVA_HOOK = this;
    }

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

        // 对象第一次成功拯救自己
        SAVA_HOOK = null;
        System.gc();

        // 因为Finalizer方法的优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVA_HOOK != null) {
            SAVA_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead");
        }

        // 下边的代码与上边的完全相同,但是这次自救失败了
        SAVA_HOOK = null;
        System.gc();

        // 因为Finalizer方法的优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVA_HOOK != null) {
            SAVA_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead");
        }
    }
}

输出:
finalize method executed
yes, i am still alive
no, i am dead


从上述代码可以看出,SAVA_HOOK对象的finalize() 确实被垃圾收集器触发过,并且在被收集前成功逃脱了

另外值得注意的是,代码中有两段完全相同的片段,执行结果却是一次逃脱一次失败了。这是因为任何一个对象的finalize() 都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。

5. 回收方法区

在Java堆中尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收由于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃的常量跟Java堆中的对象非常类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻。需要同时满足一下三个条件:

  • 该类所有的实例都已经被回收,也就是说Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个矫健除非是精心设计的可替换类加载器的场景,如:OSGi、JSP的重加载等,否则通常是很难达成的
  • 该类对应的java.lang.Class对象没有在任何地方被引用过,无法在任何地方通过反射访问该类的方法

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区早晨过大的内存压力。

垃圾收集算法

  • 弱分代假说:绝大多数对象都是朝生夕灭
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
  • 新生代收集:Minor GC/Young GC
  • 老年代收集:Major GC/Old GC
  • 混合收集:Mixed GC
  • 整堆收集:Full GC

1. 标记-清除算法

算法分为“标记”和“清除”两个部分,首先标记出所有需要回收的对象,在标记完成之后,统一回收掉所有被标记的对象。也可以反过来标记存活的对象,统一回收所有未标记的对象。

主要有两个缺点:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清楚的动作,导致标记和清楚两个过程的执行效率都随对象数量增长而降低;第二个是内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序中需要分配较对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2. 标记-整理算法(老年代)

 

其中的标记过程与“标记-清除”算法一样,但是后续的步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。

这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低。

3. 复制算法(新生代)

它将可用内存按容量划分为大小相等的两部分,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活着,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺点也显而易见,这种复制回收算法的代价是将可用内存缩小为原来的一半,空间浪费未免太多了一点。

在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、parNew等新生代收集器均采用了这种策略来设计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已使用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor大小比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%新生代是会被“浪费”的。当Survivor空间不足以容纳一次MInor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。

内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时偿还贷款,只需要有一个担保人能保证如果我们不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来存活的对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

4. 增量算法

增量算法的基本思想是,如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

展开阅读全文
打赏
3
11 收藏
分享
加载中
更多评论
打赏
0 评论
11 收藏
3
分享
返回顶部
顶部