JVM垃圾回收总结

Author Avatar
stormjie 10月 19, 2018
  • 在其它设备中阅读本文章

啥也不说了,这几天下来觉得最重要的还是身体,其他都好说。。

这两周可能做不到一周二更,以后有机会补上漏的。

这篇的主题是非常重要的,JVM学习的核心部分,所以这篇我会尽力写得更认真,更全面。

一、概述

说起垃圾回收(Garbage Collection,GC),很多人就会自然而然地把它和Java联系起来。事实上,GC的历史比Java久远,早在1960年Lisp这门语言中就使用了内存动态分配和垃圾回收技术。 在Java中,程序员不需要去关心内存动态分配和垃圾回收的问题,顾名思义,垃圾回收就是释放垃圾占用的空间,这一切都交给了JVM来处理。本文主要解答四个问题:

1、哪些内存需要回收?(对象是否可以被回收的两种经典算法:引用计数法和可达性分析算法)

2、如何回收?(三种经典垃圾回收算法:标记-清除算法、复制算法、标记-整理算法,以及分代收集算法)

3、使用什么工具回收?(垃圾收集器)

4、Java堆内存分配与回收策略是怎样的?(新生代:Eden区、From Survivor、To Survivor,以及老年代)

在探讨Java垃圾回收机制之前,我们首先应该记住一个单词:Stop The World。Stop The World意味着 JVM由于要执行GC而停止了应用程序的执行,并且这种情形会在任何一种GC算法中发生。当Stop The World发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成。事实上,GC优化很多时候就是指减少Stop The World发生的时间,从而使系统具有高吞吐 、低停顿的特点。

二、哪些内存需要回收?

我们都知道JVM的内存结构包括五大区域:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区。其中程序计数器、Java虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法。

1.JVM垃圾判定算法

常用的垃圾判定算法包括:引用计数算法,可达性分析算法。

(1)引用计数算法

引用计数算法是垃圾收集器中的早期策略。引用计数算法描述为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已“死”。

引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个比较好的算法。比如Python语言就是采用的引用计数法来进行内存管理的。但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因是引用计数法无法解决对象的循环引用问题。

public class Main {
    public static void main(String[] args) {
        MyTest test1 = new MyTest();//MyTest实例1的引用计数加1,实例1的引用计数=1;
        MyTest test2 = new MyTest();//MyTest实例2的引用计数加1,实例2的引用计数=1;

        //test1与test2存在相互引用
        test1.obj = test2;//MyTest实例2的引用计数加1,实例2的引用计数=2;
        test2.obj = test1;//MyTest实例1的引用计数加1,实例1的引用计数=2;

        test1 = null;//栈帧中test1不再指向Java堆,MyTest实例1的引用计数减1,结果为1;
        test2 = null;//栈帧中test2不再指向Java堆,MyTest实例2的引用计数减1,结果为1;

        System.gc();//回收
        //到此,发现MyTest实例1和实例2的计数引用都不为0,那么如果采用的引用计数算法的话,那么这两个实例所占的内存将得不到释放,这便产生了内存泄露。
    }
}

class MyTest{
    public Object obj = null;
}

虽然最后将test1和test2赋值为null,也就是说test1和test2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。运行程序,从内存分析看到,事实上这两个对象的内存被回收,这也说明了当前主流的JVM都不是采用的引用计数器算法作为垃圾判定算法的。

(2)可达性分析算法

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

上图红色为无用的节点,可以被回收。

目前Java中可以作为GC Roots的对象有:

  • 虚拟机栈中引用的对象(本地变量表);

  • 方法区中静态属性引用的对象;

  • 方法区中常亮引用的对象;

  • 本地方法栈中引用的对象(Native对象);

基本所有GC算法都引用可达性分析算法这种概念。

2.对象死亡前最后的机会

在可达性分析算法中,不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否需要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“不需要要执行”。注意任何对象的finalize()方法只会被系统自动执行1次。

如果这个对象被判定为需要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。因此调用finalize()方法不代表该方法中代码能够完全被执行。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。从如下代码中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。

/**   
 * 此代码演示了两点:   
 * 1.对象可以在被GC时自我拯救。   
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次   
 */    
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();    
        //因为finalize方法优先级很低,所以暂停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();    
        //因为finalize方法优先级很低,所以暂停0.5秒以等待它    
        Thread.sleep(500);    
        if(SAVE_HOOK != null) {    
            SAVE_HOOK.isAlive();    
        } else {    
            System.out.println("no, i am dead :(");    
        }    
    }    
}

运行结果:

finalize mehtod executed!    
yes, i am still alive :)    
no, i am dead :(

从运行结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器调用过,且在被收集前成功逃脱了。另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

三、如何回收?

1.垃圾回收算法

(1)标记-清除算法(Mark-Sweep)(DVM使用的算法)

标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

(2)复制算法(Copying)

复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

复制算法在存活对象比较少的时候,极为高效,但是带来的成本是牺牲一半的内存空间用于进行对象的移动。所以复制算法的使用场景,必须是对象的存活率非常低才行,而且最重要的是,我们需要克服50%内存的浪费。

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

标记-整理算法采用标记-清除算法一样的方式进行对象的标记、清除,但在回收不存活的对象占用的空间后,会将所有存活的对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。

标记-整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。

(4)分代收集算法(Generational Collection)

当前JVM垃圾收集都采用的是分代收集算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。

一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法,新生代发生的GC也叫做Minor GC;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用标记-清理算法或者标记-整理算法,老年代发生的GC也叫做Major GC即Full GC。

面试题: 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗?

  • Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。

  • Full GC又称为老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

四、使用什么工具回收?

1.垃圾回收器简介

需要注意的是,每一个回收器都存在Stop The World的问题,只不过各个回收器在Stop The World时间优化程度、算法的不同,可根据自身需求选择适合的回收器。

整理一下新生代和老年代的收集器。

新生代收集器:

  • Serial (-XX:+UseSerialGC)

  • ParNew(-XX:+UseParNewGC)

  • ParallelScavenge(-XX:+UseParallelGC)

  • G1 收集器

老年代收集器:

  • SerialOld(-XX:+UseSerialOldGC)

  • ParallelOld(-XX:+UseParallelOldGC)

  • CMS(-XX:+UseConcMarkSweepGC)

  • G1 收集器

这里就不详细介绍目前的7种垃圾收集器了,贴个博文,写得很详细,JVM(HotSpot) 7种垃圾收集器的特点及使用场景

五、Java堆内存分配与回收策略

Java堆一般分为2个大的区域: 默认的新生代(Young generation)、老年代(Old generation)。所占空间比例为 1 : 2 。

1.新生代空间的构成与逻辑

为了更好的理解GC,我们来学习新生代的构成,它用来保存那些第一次被创建的对象,它被分成三个空间:

  • 一个伊甸园空间(Eden)

  • 两个幸存者空间(From Survivor、To Survivor)

默认新生代空间的分配:Eden : From : To = 8 : 1 : 1

一般情况下,新创建的对象都会被分配到Eden区(一些大对象直接进入老年代),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到From Survivor区。在之后GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,经过一次GC每个存活对象年龄+1,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置,默认15)的对象会被移动到老年代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到To区不够容纳来自Eden区和From区的存活对象,就会发生空间分配担保,将To区无法容纳对象直接进入老年代中。

从上面的步骤可以发现,两个Survivor区,必须有一个是保持空的。如果两个Survivor区都有数据,或两个Survivor区都是空的,那一定是你的系统出现了某种错误。

2.老年代空间的构成与逻辑

老年代空间的构成其实很简单,它不像新生代空间那样划分为几个区域,它只有一个区域,里面存储的对象并不像新生代空间绝大部分都是朝闻道,夕死矣。这里的对象几乎都是从Survivor 空间中熬过来的,它们绝不会轻易的狗带。因此,Full GC(Major GC)发生的次数不会有Minor GC 那么频繁,并且做一次Major GC 的时间比Minor GC 要更长(约10倍)。


好了,关于JVM垃圾回收就总结这么多了,还有GC日志如何查看和一些JVM参数可以查看这两篇博文:(转)java中新生代和老年代java堆内存–新生代和老年代

参考资料:《深入理解Java虚拟机:JVM高级特性与最佳实践》