java虚拟机系列(二)- 对象存活判定与垃圾收集算法

java虚拟机系列(二)- 对象存活判定与垃圾收集算法

一、概述

通过前一篇文章我们已经知道,java运行时数据区的程序计数器、虚拟机栈、本地方法栈这三个区域随着线程而生而灭,虚拟机栈中的栈帧应该分配多少内存在类结构确定下来就已经已知了,因此这几个区域的线程分配和回收都具有确定性。

而java堆和方法区不一样,我们只有在程序运行期间才能知道创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的正是这部分。

二、对象存活判定

垃圾收集器在对堆进行回收时,一定要确定哪些还“活着”,哪些已经“死去”(即不可能再被途径使用的对象)。那么判断一个对象是否死亡一般有这两种算法:引用计数算法、可达性分析算法

2.1 引用计数算法

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

大多数情况下它都是一个优秀的算法,但是主流的java虚拟机里没有选用引用计数算法来管理内存的,主要原因就是他很难解决对象之间互相循环引用的问题。示例代码如下。

public class DemoGcPerson
{
    public Object instance;

    public static void main(String[] args)
    {
        DemoGcPerson demoNewPerson1 = new DemoGcPerson();
        DemoGcPerson demoNewPerson2 = new DemoGcPerson();
        demoNewPerson1.instance = demoNewPerson2;
        demoNewPerson2.instance = demoNewPerson1;
    }
}

2.2 可达性分析算法

在主流的程序语言如 Java、C# 的主流实现中,都是通过可达性分析算法来判定对象是否存活的。这个算法的根本思想就是通过一系列的称为“GC Roots”(所有的根节点对象)的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称之为引用链(References chain),当一个对象到GC Roots没有任何引用链相连的时候(就是从GC Roots到这个对象不可达),就证明此对象是不可达的。
在这里插入图片描述
在java语言中,可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的引用变量表)中引用的对象
  • 方法区中类静态属性所引用的对象
  • 方法区中的常量所引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)所引用的对象。

2.3 再谈引用

以上两种算法判定对象是否存活都与“引用”有关,在JDK1.2以前,java中对引用的定义很狭隘,即被引用没有被引用两个状态。
而我们希望能描述这样一类对象:

  • 当内存空间足够时就保留在内存之中
  • 如果内存空间在垃圾收集后还是非常紧张,则可以抛弃这些对象

在JDK1.2之后,java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,这四种引用强度逐渐减弱。

  • 强引用: 指在程序代码之中普遍存在的,类似“Object object = new Object()”这类的引用,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象。
  • 软引用: 软引用时用来描述一些还有用但不是必须的对象,对于软引用所关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。
  • 弱引用: 弱引用强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集之前,当垃圾回收器工作的时候,无论当前内存是否充足,弱引用所关联的对象都会被收掉。
  • 虚引用: 最弱的一种引用关系,一个对象是否有虚引用存在,完全不会对其生存时间构成任何影响,也无法通过虚引用来获取一个对象的实例。它的唯一目的就是能够在对象被垃圾收回器回收时受到一个系统通知。

2.4 生存还是死亡

即使是在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于缓刑阶段,要真正宣告一个对象是否死亡,至少要经历两个标记过程:第一次标记并进行一次筛选、第二次标记

2.4.1 第一次标记并进行一次筛选

如果对象在经过可达性分析后发现其到GC Roots不可达,那么它将会被第一次标记并且进行一次筛选,筛选的条件是
此对象是否有必要执行finalize()方法。有下面两种情况:

  • 第一种: 如果此对象没有覆盖finallize()方法,或者覆盖的finalize()方法已经被执行过,虚拟机将这两种情况视为“没有必要执行”finalize()方法(将会在第二次标记后直接进行垃圾回收)。
  • 第二种: 此对象覆盖了finalize()方法,并且没有被虚拟机调用过,虚拟机将这情况视为“有必要执行”finalize()方法(还有逃脱死亡的机会)。

2.4.2 第二次标记

  • 第一种: 如果对象经过第一次标记并筛选后是第一种情况,直接进行第二次标记,然后进行垃圾回收。

  • 第二种: 如果对象经过第一次标记并筛选后是第二种情况,那么对象会被插入到一个F-Queue队列中,由一个虚拟机自动创立的、低优先级的Finalizer线程触发其finalize()方法。finalize()方法是对象逃脱死亡的最后机会,GC会对队列中的对象进行第二次小规模的标记,假如对象在finalize()方法中成功拯救了自己——与引使用链上的任何一个对象建立了联络,那么在第二次标记时,对象会被移出“即将回收”集合。如果这时还没有逃脱,那基本上就是被回收了。

  • 值得一提的是低优先级的Finalizer线程并不承诺等待对象的finalize()方法执行结束,这样子做的原因是,如果finalize()方法执行很慢或者死循环,将会导致在F-Queue队列中的其它对象永久等待,严重甚至导致内存回收系统崩溃。

两次标记的过程如下图所示:
在这里插入图片描述

当对象重写finalzie()方法后第一次gc时逃脱死亡,第二次gc时真正死亡,演示代码如下:

package com.example.demo.javasebase.jvmtest;

/**
 * Create by likaihai 2019/5/8
 */
public class FinallizeGCTest
{

    private static FinallizeGCTest finallizeGCTest = null;

    public static void main(String[] args) throws InterruptedException
    {
        finallizeGCTest = new FinallizeGCTest();

        finallizeGCTest = null;
        System.gc();

        Thread.sleep(5000);

        if (finallizeGCTest != null){
            finallizeGCTest.isAlive();
        } else {
            System.out.println("no,I am dead");
        }

        finallizeGCTest = null;
        System.gc();

        Thread.sleep(5000);

        if (finallizeGCTest != null){
            finallizeGCTest.isAlive();
        } else {
            System.out.println("no,I am dead");
        }   
    }

    @Override
    public void finalize() throws Throwable
    {
        super.finalize();
        finallizeGCTest = this;
        System.out.println("run finalize method");
    }


    public boolean isAlive()
    {
        System.out.println("yes, I am still alive");
        return true;
    }
}

特别注意的是采用这种方式来拯救对象并不鼓励,因为他的运行代价高昂,不确定性大,很多教材中说可以在finalize()方法中做“关闭外部资源”之类的工作,完全是对这种方法用途的一种安慰。finalize()方法能做的,try-finally都可以做得更好更及时。

2.5 回收方法区

方法区的垃圾手机主要分为两部分内容:废弃常量和无用的类。

2.5.1 回收废弃常量

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

2.5.2 回收无用的类:

“无用的类”需要满足下面三个条件:

  • java堆中该类的所有实例都已经被回收
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地点被引用

虚拟机可以对满足上述三个条件的类进行回收,是否对类进行回收,hotSpot虚拟机提供了-Xnoclassgc参数进行控制。

大量使用动态代理、反射、动态生成JSP等场景都需要虚拟机具备类卸载的功能。

三、垃圾收集算法

3.1 标记清除算法

“标记-清除”算法也是最基础的收集算法,首先标记出所有对象,在标记完成之后再统一回收所有被标记对象,标记过程在上一节已介绍过。

它主要有两个不足:

  • 一个是效率问题: 标记和清除两个过程的效率都不高(“标记”系列的算法有一个缺点,就是在分配了大量对象,并且其中只有一小部分存活的情况下,所消耗的时间会大大超过必要的值,这是因为在清除阶段还需要对大量死亡对象进行扫描)。
  • 一个是空间问题: 标记和清除之后会产生大量的内存碎片(因此分配新的内存时就需要采用空闲列表的方法),当对象太大时,无法找到足够的连续内存不得不得提前触发另一次垃圾收集动作。

“标记-清除”算法执行过程如下图所示:
在这里插入图片描述

3.2 复制算法

为了解决“标记-清除”算法的效率问题,“复制”(Copying)收集算法出现了,它的主要思想是将内存划分为容量大小相等的两块,每次只使用其中一块,当这块用完了,就将还存活的对象复制到另一块上面。然后把之前使用过的这一块的内存空间一次性清理掉。

在这种算法中,会将从根开始被引用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复制下去。

这种方式不用再考虑内存碎片等复杂情况,内存空间是连续的,所以是使用指针碰撞的方式分配新的内存,实现简单,运行高效。但是带来的代价就是内存使用率仅为50%。复制算法的执行过程如下图所示。
在这里插入图片描述
HotSpot虚拟机就是采用这种方式收集算法来回收新生代,由于新生代中大多数对象是“朝生夕死”的,所以实际上不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大Eden空间和两块较小的Survivor空间,每次使用其中Eden和一块Survivor。HotSpot虚拟机默认Eden和Survivor(两块中的一块Survivor)的大小比例为8:1。

新生代每次回收时,就将Eden和Survivor中还存活的对象一次性复制到另外一个Survivor中,最后清理掉Eden和之前Survivor空间中的所有对象。当另外一块Survivor没有足够的内存空间存放上一次新生代收集下来的存活对象时,这些对象将会通过分配担保机制进入老年代。

3.3 标记-整理算法

复制收集算法在对象存活率过高时就需要进行较多的复制操作,效率将会降低,更关键的是,不能浪费那50%的内存。“标记-整理”算法与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是将所有存活对象都向一端移动,然后清理掉端边界以外的内存,所以内存空间是连续的,使用指针碰撞的方式分配新的内存。
“标记-整理”算法的执行过程如下。
在这里插入图片描述

3.4 小结

“存活”对象比例很少时使用标记算法(多了清除/整理阶段,耗时长)开销较大,这时应该使用复制算法,只需要付出少量存活对象的复制成本就可以完成收集(新生代gc概率大,所以牺牲内存换取性能,不能采用标记-整理这种耗时的算法)。

“存活”对象比例较高的时候使用复制算法所需要开销比较大(对象存活率高,使用复制算法的内存担保机制得不偿失),这时应该使用标记-整理算法。


  • 参考资料:《深入理解java虚拟机》

猜你喜欢

转载自blog.csdn.net/lixiaohai_918/article/details/90172735