vlambda博客
学习文章列表

垃圾回收器和内存回收策略

概述


我么都知道垃圾回收是为了什么,通俗的说就是,程序运行结束之后,jvm内存中在加载以及运行时产生的数据清除掉,使得,jvm内存中能有更多的空间存储新产生数据。

在java中,有多套成熟的垃圾回收算法,来帮助我们实现垃圾回收,这是区别C++的地方。接下来我们对各个模块垃圾回收来看下,上一篇笔记说明了java内存运行时的各个区域,其中jvm栈,本地方法栈,成熟计数器这三篇空间,随着线程生而生,随着线程消亡而消亡。因此这三个区域的回收就很具有确定性。不需要考虑什么回收策略,当方法结束或者线程结束时,内存自然跟着一起回收,而堆空间是线程共享区域,具有很多不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的条件分支所需的内存也不一样。只有在程序运行时,才能知道创建了多少对象,创建了哪些对象。这部分内存的分配和回收是动态的。我们重点关注的是这部分的回收机制。


1.0对象死了吗

         垃圾回收我们总要确定哪些对象是垃圾吧,在jvm里常见的有两种标记垃圾的策略:

    1.0.1引用计数法

    在对象中添加一个引用计数器,有个引用指向它时,引用就会加一,减少一个引用指向它时,引用就减一,,任何时候计数器为0时,表示不能再被引用,也就是所谓的垃圾对象。这种标记策略看似合理,也确实有一些公司在使用,但是还是有一定的缺陷的,举个例子,代码如下


public class ReferenceCountingGc{public Object instance=null;private static final int _1MB=1024*1024;private byte[] bigSize=new byte[2*_1MB];public static void test(){ReferenceCountingGc obj1=new ReferenceCountingGc();ReferenceCountingGc obj2=new ReferenceCountingGc();obj1.instance=obj2;obj2.instance=obj1;obj1=null;obj2=null;System.gc();}}

上面这串代码一步一步分析,一共生成两个对象,此时计数器各为1,之后,各自的instance交叉指向。引用计数器又各自加一为2.然后令obj1和2分别为null,想要回收,但是此时计数器各自为一,不能看做垃圾对象,这就是我们说引用计数法不能解决循环引用。

    1.0.2可达性分析算法

        这个算法的基本思路是通过一系列称为“GC ROOTS”的根对象最为起始节点集,从这些根节点根据引用关系向下搜索,搜索中走过的路径称为引用连,如果某个对象不再这些引用链上,就说这个对象是不可达的,即垃圾对象。

    有哪些对象可称为GC ROOT:

            ·虚拟机栈的引用对象,譬如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量

            在方法区中类静态属性引用的对象,譬如java类的引用类型静态变量

            在方法区中的常量引用对象,譬如字符串常量池里的引用

            在本地方法栈中的JNI

            java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象还有系统类加载器

            所有被同步锁持有的对象

    java中的引用分为强引用弱引用软引用虚引用(不是很重要)

    1.0.3对象的自我救赎

           这里主要是讲一个finalize()方法。,在可达性分析中标记了一个对象如果为不可达对象,相当于判了“死缓”,是否真正的清除,还有一个逻辑要走。第一次筛选(标记)后,可能会有第二次筛选,筛选的条件是是否有必要执行finalize方法,假如对象没有实现此方法,或者虚拟机已经调用过一次。则不会产生第二次筛选。这个对象只能等死了。需要说明的是这种自救机会只有一次,因为虚拟机只会对同一个对象的finalize方法调用一次。那么如何自救呢,只需要在finalize方法中,把当前对象重新加入到GC可达的引用链上

    1.0.4方法区回收

        方法区主要回收废弃的常量和不再使用的类型。

        举个常量池中字面量回收的例子,如果常量池中有一个字符串“xxx”,如果没有引用指向它,则认为此字面量为垃圾,可以回收,常量池中的类,接口,方法,字段的符号引用也与此类似。

        如何判断无用的类型呢?要同时保证下面三个条件。

        第一是内存中已经找不到该类的任何实例对象。

        第二是加载该类的类加载器已经全部被回收。

        第三是该类的java.lang.Class对象没有任何地方被引用。

   

了解了对象是如何被标记为垃圾的之后,那么对象具体是怎么被回收的,下面看一下比较经典的垃圾回收算法。

2.0垃圾回收算法

    2.0.1分代收集理论

        弱分代假说:绝大多数对象都是朝生夕灭的。

        强分代假说:熬过越多次垃圾收集的过程的对象越难以消亡。

        根据上面这两个特性,java堆把空间划分成不同的区域,每个区域的对象有不同的特征,这样每次回收的时候至于要回收特定的区域就可以了。

        现代的商用虚拟机大多把堆空间分为新生代(Young Generation)和老年代(Old Generation),新生代会有大量的对象刚创建好就死去,老年代存放长久存活的对象。

        这个时候难免有一些问题,说好的只回收某一区域的对象,比如新生代,但是新生代的对象完全有可能被老年代引用了,那还要再遍历一次老年代来保证gc可达性的准确性吗,这样未免太消耗性能了。这个时候提出了第三条分代收集理论。

        跨代引用假说:跨代引用相对于同代引用只占少数。

        根据这条假说,我们没必要对老年代全部遍历,只需要在新生代建立一个全局数据结构(记忆集),这个结构把老年代分成若干小块,标识出老年代的哪一块内存会被加入到GC ROOT进行扫描。

    下面再说几个常见的概念:

           部分收集(Partial GC)

                新生代收集(Minor/Young GC)

               老年代收集(Major/Old GC)只有CMS收集器会有单独收集老年代的行为

                混合收集(Mixed GC)只有G1支持 

            整堆收集(Full GC)收集整个Java堆和方法区的垃圾收集

    2.0.2标记清除算法

        算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完 成后统一回收所有被标记的对象。它是最基础的收集算法,效率也很高,但是会 带来两个明显的问题: 

        效率问题(垃圾对象太多)

        空间问题(标记清楚后产生大量不连续碎片)


    2.0.3标记复制算法

        为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两 块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到 另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内 存区间的一半进行回收。

    

垃圾回收器和内存回收策略

2.0.4标记整理算法

    根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一 样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一段移 动,然后直接清理掉端边界以外的内存。

    

垃圾回收器和内存回收策略

3.0HotSpot的算法实现细节

    3.0.1根节点枚举

        固定可作为GC ROOT的结点主要在全局性引用之中(例如常量池,类的静态属性)与执行上下文(栈帧中的本地变量表),在我们确定GC ROOT的过程中,虚拟机不应该一个不漏的检查完上述两个空间,在HotSpot的解决方案中,使用了一组OopMap的数据结构来达到这个目的。一个类加载完成后,HotSpot就会把对象偏移量上是什么类型的数据计算出来保存在这个数据结构中。

    3.0.2安全点

        有了OopMap,可以快速的找到所有的GC ROOT,但是,造成这个数据结构改变的指令有很多,不可能每个指令都记录一次。于是有了安全点的概念,规定只有在特定的位置才有OopMap记录这样的信息。也就是说用户线程只有到了安全点才能进行垃圾回收。那么安全点一般设置在哪些位置?特征是“让程序长时间执行“,长时间执行最明显的特征就是指令序列复用(我不太理解),例如方法调用,循环跳转,异常跳转等。

        如何让运行着的程序尽快跑到安全点,有两种做法:

        抢断式:发生垃圾收集时,系统把所有的线程全部中断,然后发现如果有线程不再安全点上,就恢复,让它执行一会再中断,直到跑到安全点上。这种方式不需要线程的执行代码主动去配合。

        主动式:不直接对线程操作,而是设置一个标志位,各个线程执行时轮询去访问这个标志位,一旦标志为真时,就在自己最近的安全点主动挂起。,轮询标志的地方和安全点是重合的。

    3.0.3安全区域

        上面安全点讨论的是程序执行时,那么程序没有在执行比如sleep了,,这时无法响应中断请求。不能走到安全点,这个时候就有了安全区域的概念。

        安全区域指的是在一块区域中引用关系不会发生变化,因此这个区域任何地方收集垃圾都是安全的。

        当用户线程进入安全区域之后,首先会标识自己进入安全域,这段时间发生垃圾收集时就不用管已声明在自己安全区域内的线程。当线程离开安全区域时,会检测虚拟机是否完成了根节点的选举。如果完成了,就没事,如果没完成,就一直等待,直到收到信号才可以离开安全区域。

    3.0.4记忆集与卡表

        之前讲分代收集理论的时候,为了解决跨带引用的问题,提出了在新生区建立名为记忆集的数据结构。避免扫描整个老年代。这里用的精度是卡精度,每个精度记录一块内存区域,该区域有对象存在跨代指针。为了实现这种方式,有了卡表的概念,卡表就是记忆集的一种具体体现,卡表最简单的实现方式可以只是一个自己数组。字节数组的每一个元素都对应着其标识的内存区域块特定大小的内存块。,这个内存块称为卡页,一个卡页中包含不止一个对象,只要卡页对应的内存区域存在跨代指针,则整个区域标记为1,否则为0.

      3.0.5并发的可达性分析

            可达性分析要求全过程都基于一个能保证一致性的快照中才能进行分析,这意味着必须全部冻结用户线程的进行,GC ROOT的集合相比整个堆还算少数,但是标记GC ROOT引用链下面的时间就和堆大小有关了。

            这里提出了三色标记的概念:

                白色:标识对象未被垃圾收集器访问过,显然在可达性分析刚开始阶段,所有对象都是白色的,若在结束阶段还是白色,则表明不可达

                黑色:表示对象已经被垃圾收集器访问过,且这个对象的引用都被扫描过,黑色代表扫描过,它是安全的。

                灰色:标识对象已经被垃圾收集器访问过,但是该对象至少有一个引用没被访问过。

            Wilson证明了,当下面两个条件同时满足时,会产生对象消失问题,即本来应该黑色的对象标成了白色。   

                1赋值器插入了一条或者多条从黑色对象到白色对象的引用

                2赋值器删除了全部灰色对象到白色对象的直接引用或者间接引用

        解决上述问题有两个方案:增量更新和原始快照

            增量更新:破坏第一个条件,当黑色对象插入指向白色对象的引用关系时,把这个操作记录下来,并发扫描结束时,再将这些记录过的引用关系中的黑色对象为根,重新扫描(相当于把黑色变成灰色)。

            原始快照:破坏第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中灰色对象为根,重新扫描一次。

              

    4.0经典垃圾收集器

      前面介绍了各种垃圾回收算法,但是,它只是方法论,真正工作的是,垃圾收集器。

    4.0.1Serial收集器 

    Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一 个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃 圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。

    

垃圾回收器和内存回收策略

    虚拟机的设计者们当然知道Stop The World带来的不良用户体验,所以在后续的垃圾收集器设计 中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。但是Serial收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的 单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用 途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是 作为CMS收集器的后备方案。 

    4.0.2ParNew收集器

        ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为 (控制参数、收集算法、回收策略等等)和Serial收集器完全一样。默认的收集线程数跟cpu核数 相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。新生代采用复制算法,老年代采用标记-整理算法。

垃圾回收器和内存回收策略

4.0.3Parallel Scavenge收集器

    Parallel Scavenge 收集器类似于ParNew 收集器,是Server 模式(内存大于2G,2个cpu)下的 默认收集器那么它有什么特别之处呢?Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点 更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时 间与CPU总消耗时间的比值。Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿 时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完 成也是一个不错的选择。新生代采用复制算法,老年代采用标记-整理算法。

垃圾回收器和内存回收策略

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算 法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。

4.0.4CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它 非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器, 它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它 的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤: 

        初始标记:暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快      

        并发标记:同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段 结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新 引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引 用更新的地方。 

        重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记 产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍 长,远远比并发标记阶段时间短 

        并发清理:开启用户线程,同时GC线程开始对未标记的区域做清扫。

    从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面 几个明显的缺点:对CPU资源敏感(会和服务抢资源);无法处理浮动垃圾(在并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理 了);它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然 通过参数-XX:+UseCMSCompactAtFullCollection 可以让jvm在执行完标记清除后再做整 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触 发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回 收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收.

4.0.5G1收集器

    G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机 . 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征.

    

G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以 用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集 合。默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存, 对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统 运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以 通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前 一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应 100个。一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是 说Region的区域功能可能会动态变化。G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象 的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的 Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按 照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且 一个大对象如果太大,可能会横跨多个Region来存放。

    G1收集器一次GC的运作过程大致分为以下几个步骤:初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用 的对象,速度很快 并发标记(Concurrent Marking):同CMS的并发标记 最终标记(Remark,STW):同CMS的重新标记 筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行 排序根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制 定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本 次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个 Region刚好需要200ms,那么就只会回收800个Region,尽量把GC导致的停顿时间控制在 我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一 部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代 或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个 region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制 算法回收几乎不会有太多内存碎片。


    


G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃 圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面 这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集 器在有限时间内可以尽可能高的收集效率。

 4.0JVM内存分配与回收

    4.0.1JVM优先在Eden区分配

    大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分 配时,虚拟机将发起一次Minor GC。我们来进行实际测试一下。在测试之前我们先来看看 Minor GC和Full GC 有什么不同呢?        Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。 

    Major GC/Full GC:一般会回收老年代,年轻代,方法区的垃圾, Major GC的速度一般会比Minor GC的慢10倍以上。

    4.0.2大对象直接进入老年代

        大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 - XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小 会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集 器下有效。比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 - XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代 为什么要这样呢?为了避免为大对象分配内存时的复制操作而降低效率。

    4.0.3长期存活的对象入老年代

        既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪 些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每 个对象一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象 在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定 程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈 值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

    4.0.4对象动态年龄判断

        当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总 大小大于这块Survivor区域内存大小的50%,那么此时大于等于这批对象年龄最 大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象, 年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就 会把年龄n以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活 的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。

    4.0.4老年代的空间分配担保机制

        年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间 如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) 就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是 否设置了 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放 新的对象就会发生"OOM" 当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老 年代可用空间,那么也会触发full gc,full gc完之后如果还是没用空间放minor gc之后的存活对象,则也会发生“OOM”。

     4.0.4Eden From To默认8:1:1

        大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99% 以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor 区,下一次eden区满了后又会触发minor gc,把eden区和survivor去垃圾对象 回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代 的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适 的,让eden区尽量的大,survivor区够用即可 JVM默认有这个参数-XX:+UseAdaptiveSizePolicy,会导致这个比例自动变 化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

    4.0.4Minor GC之后survivor装不下

        这种情况会把存活的对象部分挪到老年代,部分可能还会放在Survivor区