深入理解JVM—垃圾回收器(Grabage Collector)基础篇

1、如何判断对象可以被回收

1.1 引用计数法

       给对象添加一个引用计数器,每当有一个地方引用该对象的时候此计数器+1;当一个引用失效后计数器-1,当计数器的值减为0了的对象就不在可能被使用了

  • 优点: 实现简单,判定效率高。
  • 缺点:当对象之间的相互循环引用时,会导致GC失效,从而造成内存泄漏。
public class ReferenceCountGCTest{

    private Object instance=null;

    public static void main(String[] args){
        ReferenceCountGCTest obj1=new ReferenceCountGCTest();
        ReferenceCountGCTest obj2=new ReferenceCountGCTest();
        obj1.instance=obj2;
        obj2.instance=onj1;
        obj1=obj2=null;
        //发生GC后,obj1和obj2都没法在引用计数法实现的GC中被清除
        System.gc();
    }
}
1.2 可达性分析算法

以一系列“GC Roots”为起点,从这些对象开始向下搜索,当一个对象到GC Roots没有任何引用链相连接(GC Roots沿着引用链到达不了目标对象)时,GC就会标记这些对象不可用。目前主流的商用程序语言(Java、C#等)在主流的实现中,都是通过可达性分析来判定对象是否存活的。

如上图所示,GC Roots到Object1、Object2、Object3、Object4都是可达的(可达意为GC Roots沿着某条路径(引用链)一定可以找到该对象),但是GC Roots到Object5、Object6、Object7没有可达的引用链,因此它们将会被判断为可回收的对象。

  • 优点:更加精确和严谨,可以分析出循环数据结构相互引用的情况;
  • 缺点:实现比较复杂、需要分析大量数据,消耗大量时间、分析过程需要GC停顿(引用关系不能发生变化),即停顿所有Java执行线程(称为"Stop The World",是垃圾回收重点关注的问题)。

在Java语言中可以作为GC Roots的对象有以下几种

(1)虚拟机桟桟帧中的局部变量表所引用的对象。
(2)方法区中类的静态的属性所引用的对象。
(3)方法区中常量引用的对象。
(4)本地方法桟中本地方法所引用的对象。

GC Roots细节
当一个对象被GC Roots标记为不可用之后,这个对象并不是非死不可的,要宣告一个对象的死亡还需要进过两个过程:

(1)当一个对象通过可达性分析发现它没有与GC Roots相连接的引用链,那他会被第一次标记并且进行第一次筛选,筛选的条件是此对象的finalize()方法是否有必要执行。当对象的finalize方法没有重写或已经被执行过了,那么将不会再执行,那么此对象就再也没有“自救”的机会。

(2)如果判断对象有必要执行finalize()方法,那么这个对象将会被放入一个名为F-Queue队列中,并在稍后通过一个由虚拟机自动创建的、低优先级的名为Finalizer的线程去自动调用finalize()方法。finalize方法是一个对象逃脱死亡的最后几机会,并且也只有这一次机会,在调用finalize之后,GC会对F-Queue进行第二次小规模的标记,如果对象在finalize方法中将自己和引用链上的任何一个对象建立关联即可逃过此次GC,否则对象就可以被宣告死亡,接下来就会被当成垃圾回收掉。

finalize机制的一些总结

(1)如果一个类A重写了finalize()方法,那么每次创建A类的对象的时候都会多创建一个Finlizer对象(java.lang.ref.Finlizer)指向刚刚新建的对象;如果类没有重写finalize()方法,这没有这一步;

(2)Finalizer对象内部维护了一个unfinalized链表,每次创建的Finalizer对象都会插入到该链表中;

(3)如果类没有实现finalize()方法,那么进行垃圾回收的时候,可以直接从堆内存中释放该对象,效率非常高;

(4)如果一个类实现了finalize()方法,进行GC的时候,如果发现某个对象只被Finalizer对象引用,那么会将该Finalizer对象加入到Finalizer类的引用队列中,并从unfinalized链表中删除该节点。这个过程是JVM在GC的时候自动完成的。

(5)含有finalize()的对象从内存中释放,至少需要经过两次GC。

(6)Finalizer是JVM内部的守护线程,优先级很低。Finalizer线程的职责很明确,就是不停的循环等待F-Queue中新增对象,一旦发现队列中出现了新对象,他会弹出该对象,调用他的finalize()方法,将该引用从Finalizer类中移除,因此下次GC在执行的时候,这个Finalizer实例以及它所引用的对象都被回收了。

(7)使用finalize容易导致OOM,因为如果创建对象的速度很快,那么Finalizer线程的回收速度赶不上创建速度,就会导致内存中垃圾越来越多。

2、 Java中的几种引用

(1)强引用
  • 类似Object obj=new Object()这样的引用方式就是强引用,只有所有的GC Roots对象都不通过强引用引用该对象的时候,该对象才能被垃圾回收
(2)软引用
  • 仅有软引用引用一个对象时,在垃圾回收后系统内存任然不足,将会再次发生GC把这些软引用对象清除,如果回收后内存还是不足,才会抛出内存溢出异常。
  • 一个软引用可以使用SoftReference<V>类来实现。软引用可以配合引用队列来释放软引用自生。

       软引用可以用于存放一些重要性不是很强又不能随便让清除的对象,比如图片编辑器、视屏编辑器、缓存等。如下实例就可以说明:

/**
 * 软引用的应用: 假如现在有多幅图片需要显示,如果是使用直接引用,这段代码在设置-Xmx20m后一定会报内存溢出, 但是根据实际应用场景,图片加载被看过后就可以不需要了,因此我们可以把这些引用设置为软引用或弱引用
 * VM Options:-Xmx20m -XX:+PrintGCDetails -verbose:gc
 *
 * @author :huangxin
 * @modified :
 * @since :2020/02/22 14:23
 */
public class JavaReferencesTest {


    private static final int _4MB = 1024 * 1024 * 4;

    public static void main(String[] args) {
        /**设置-Xmx20m后运行报堆内存溢出异常
        List<byte[]> lists=new ArrayList<>();
        for(int i=0;i<=5;i++){
            lists.add(new byte[_4MB]);
        }*/
        softReferenceTest();
    }

  
    public static void softReferenceTest() {
        //List强引用SoftReference,SoftReference软引用byte[]
        List<SoftReference<byte[]>> lists = new ArrayList<>();
        for (int i = 1; i <=5; i++) {
		    //这里reference任然是个强引用,但是byte数组是个弱引用 
            SoftReference<byte[]> reference = new SoftReference<>(new byte[_4MB]);
            System.out.println(reference.get());
            lists.add(reference);
            System.out.println(lists.size());
        }
        System.out.println("===============循环结束============");
        for (SoftReference<byte[]> ref : lists) {
            System.out.println(ref.get());
        }
    }
}

运行结果及分析
运行结果

在图中处系统内存发现内存紧张了,因此发生了一次GC,此时系统内存挪一挪还可以放下一个4MB大小的byte[]对象,但是当第5次申请内存的时候发现系统内存又不用了,我们也看到了,此时对于20M的堆内存已经用了4*4MB了,再加上其他的对象占用一点内存,堆上是真的没有足够的空间分配给第5个4MB大小的byte[]对象了,此时我们软引用的作用就发挥出来了,从图中可以看到最后一次Full GC直接把新生代中对象清空了,并且老年代的内存占用也大幅减少,而清除掉的这些正是前面分配的被软引用的对象(从最终打印的几个null值可以看出),如果没有这种机制,那么前面几个对象是被List所引用的,GC是没有办法清除它们的。

使用引用队列释放软引用/弱引用对象自身
在上面的例子中验证了软引用第一条特点:被软引用引用的对象会在系统内存紧张的时候被回收,但是SoftReference这个对象并没有回收,这也会对系统内存造成浪费,因此下面我们就使用引用队列来把这个对象也清除了。

/**
 * 软引用的应用:
 * VM Options:-Xmx20m -XX:+PrintGCDetails -verbose:gc
 *
 * @author :huangxin
 * @modified :
 * @since :2020/02/22 14:23
 */
public class JavaReferencesTest {


    private static final int _4MB = 1024 * 1024 * 4;

    public static void main(String[] args) {
        softReferenceTest();
    }

    //使用引用队列删除SoftReference
    public static void softReferenceTest() {
        List<SoftReference<byte[]>> lists = new ArrayList<>();

        //引用队列:
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

        for (int i = 1; i <= 5; i++) {
            //在构造器中传参数引用队列,使一个SoftReference对象和引用队列关联,当软引用所关联的byte[]被回收后,软引用对象自己会把自己加入到引用队列中
            SoftReference<byte[]> reference = new SoftReference<>(new byte[_4MB], queue);
            System.out.println(reference.get());
            lists.add(reference);
            System.out.println(lists.size());
        }

        //加入引用队列中的都是无用的SoftReference对象,在这里直接遍历删除
        Reference<? extends byte[]> poll = queue.poll();
        while (poll != null) {
            lists.remove(poll);
            poll = queue.poll();
        }

        System.out.println("===============循环结束============");
        for (SoftReference<byte[]> ref : lists) {
            System.out.println(ref.get());
        }
    }
}

运行结果:
运行结果

(3)弱引用
  • 仅有弱引用引用一个对象时,在垃圾回收的时候GC一定会回收该对象。
  • 一个弱引用可以使用WeakReference<V>类来实现。弱引用可以配合引用队列来释放弱引用自生

JDK中的解决ThreadLocal内存泄漏问题就用到了弱引用,详细的分析见对ThreadLocal的理解?ThreadLocal如何解决内存泄漏问题?

(4) 虚引用
  • 它是最弱的是一种引用关系,必须配合引用队列来使用,主要配合ByteBuffer使用,被引用的对象回收的时候,会将虚引用入队,有Reference Handler线程调用虚引用相关方法释放直接内存。

最后通过一幅图来说明一下JVM回收机制究竟如何区别对待各种引用类型:
JVM回收机制究竟如何区别对待各种引用类型

3、垃圾回收算法

3.1 标记-清除算法(Mark-Sweep)

标记-清除算法分为两个阶段:标记和清除。首先使用上面介绍的可达性分析算法标记出所有需要回收的对象。在标记完成后统一回收所有标记的对象,实质是将回收的对象的起始和结束地址记下来,下次在给新的对象分配内存的时候如果大小合适就覆盖这片内存。标记清除算法比较简单粗暴,实现也比较简单。但是它留下了两个比较麻烦的问题

  1. 效率问题,标记和清除两个过程效率都不高
  2. 空间问题,标记清除过后会产生大量的内存碎片,太多的内存碎片会在需要给大对象分配内存的时候,无法找到足够的连续空间而不得不触发另一次GC。
    标记清除算法
3.2 复制算法(Copying)

复制算法把一块可用的空间分为大小均等的两块区域,每次只使用其中的一块,当这块内存快用完时同样是需要先做标记,然后将有用对独向复制到另一个区域,之后把已使用过的内存一次清理掉。复制算法解决了标记清理算法带来的内存碎片问题,并且实现简单,运行高效;但是这种的算法的代价是“浪费”了一半的内存可用空间。
复制算法

       在现代的商用JVM堆内存的新生代采用了复制算法,在Sun HotSpot虚拟机中新生代分为Eden空间和两块较小的Survivor空间,每次使用Eden空间和一个Survivor空间,每次GC的时候都会将Eden和Survivor的From区中的有效对象进行标记,一同复制到Survivor的To区。然后彻底清除原来的Eden区和From区的内存对象。与此同时To区就是下一次回收的From区。这一过程中新生代的内存只有90%再被使用,总会有10%的空间”浪费“。

3.3 标记-整理算法( Mark-Compact)

复制算法需要一块额外的内存空间,用于存放幸存的内存对象。这无疑造成了内存的浪费。因此在原有的标记清除算法的基础上,提出了优化方案。也就是标记到的可用对象整体向一侧移动,然后直接清除掉可用对象边界以外的内存。这样既解决了内存碎片的问题。又不需要原有的空间换时间的硬件浪费。由于老年代中的幸存对象较多,而且对象内存占用较大。这就使得一旦出现内存回收,需要被回收的对象并不多,碎片也就相对的比较少。所以不需要太多的复制和移动步骤。因此这种方法常常被应用到老年代中。

标记整理算法的缺点: 标记整理算法由于需要不断的移动对象到另外一侧,而这种不断的移动其实是非常不适合杂而多的小内存对象的。每次的移动和计算都是非常复杂的过程。因此在使用场景上,就注定限制了标记整理算法的使用不太适合频繁创建和回收对象的内存中。
标记整理算法

3.4 分代垃圾回收机制(Generational Collection)

       这种算法就是将内存以代的形式划分,然后针对情况分别使用性价比最高的算法进行处理。在Java中,一般将堆分为老年代和新生代。新创建的对象往往被放置在新生代中。而经过不断的回收,逐渐存活下来的对象被安置到了老年代中。越新的对象越可能被回收,越老的对象反而会存活的越久。因此针对这两种场景,新生代和老年代也会分别采用前文所述的两种算法进行清理。

4、 内存分配与回收策略

4.1 对象优先在Eden分配
4.2 大对象直接进入老年代
4.3 长期存活的对象将被晋升到老年代
4.4 动态对象年龄判定
4.5 空间分配担保(Handle Promotion)

       通过上面的介绍我们了解到新生代采用的垃圾回收算法主要是复制算法,然而我们并不能保证上一次新生代收集下来的存活对象都可以在另一个Survivor空间中可以装得下,当另外一块Survivor空间的内存不够的时候,我们就要依赖其他内存进行分配担保(Handle Promotion)。分配担保中如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入到老年代。内存担保的过程具体如下:

  1. 每次发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么这次Minor GC就是安全的。
  2. 如果条件不成立,那么将会查看HandlePromotionFailure设置值是否允许内存担保失败,如果允许,那么将会检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均水平大小,如果大于,那么将会冒着风险尝试进行Minor GC;如果小于或者JVM参数设置HandlePromotionFailure不允许内存担保失败,那么就会不执Minor GC而执行Full GC

留言区

还能输入500个字符