垃圾收集算法与垃圾收集器

  到目前为止,GC和内存分配这块技术已经发展的相当成熟了,无需我们在花费大量的精力继续研究改进,那我们为什么要还要了解这块的知识呢?因为当需要排查各种内存溢出,内存泄露问题时,当垃发量的瓶颈时,我们就需要需要对GC和内存分配这方面有一定的了解和认识,才能会更好的解决问题!!

一, 确定对象死亡

     在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。那如何确定呢?就是采用一些算法来确定的,接下来就给大家介绍这些算法。

1.1 引用计数算法(Reference Counting)

       引用计数算法可以简单概括为:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当有引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,即该对象就可以被垃圾收集器回收掉。

       虽然此算法实现简单,在确定失效对象时的效率也很高,但它有一个弊端就是很难解决对象间相互循环引用的问题,以至于当今主流虚拟机都没有使用它来管理内存。

       那什么是对象相互循环引用的问题?下面给大家用代码举例下。

public class  ReferenceCountingGC {
    public  Object instance=null;
    /**
     * 定义数组仅仅是为了占用内存,使在GC日志中能明显看出对象是否被回收的效果
     */
    private  byte[] bigSize=new byte[1024*1024];
    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();
    }
}

  从代码可以看出,对象objA的instance变量引用着对象objB,对象objB的instance变量引用着对象objA,并且底下已经将这两个对象都置为null了,但是如果采用引用计数算法来管理内存空间,虽然这两个对象已经没什么用来了,但是因为还被其他对象引用着,计数一直大于0,所以无法被回收掉。

1.2 可达性分析算法(Reachability Analysis)

       当前主流使用算法(Java.C都在用),基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,就可以被回收掉了。如下图中的对象object5,object6和object7,他们之间虽然还存在着引用关系,但是它们从GC Roots是不可达的,因此它们三个就可被判定为可回收对象。

        注意在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们只是处于“缓刑”阶段,要真正判断宣告一对象已死亡,至少要经历两次标记的过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它就将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者该方法已经被虚拟机调用过了,虚拟机会认为是没有必要执行,那么该对象就可被回收了;如果该对象被判断有必要执行finalize()方法,那么就会将该对象放置到一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。

  这里所谓的这个“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,是为了防止该对象在finalize()方法中执行缓慢影响F-Queue队列中其他对象的执行。也可以说finalize()方法是对象逃脱死亡的最后一次机会,当GC对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()方法中成功拯救自己(只要重新与引用链上的任何一个对象建立关联即可),那么在第二次标记时它将被移除“即将回收”集合;如果该对象这时候还没有逃脱,那基本上就可被回收了。

Java中可以固定作为GC Roots的对象:

  • 虚拟机栈中引用的对象;
  • 方法区中类静态属性引用对象;
  • 方法区常量引用对象,譬如字符串常量池里的引用;
  • 本地方法栈中JNI(Native方法)引用的对象;
  • Java虚拟机内部引用;
  • 被同步锁持有的对象; 
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

       从图中可以注意到,对象实例1,对象实例4,对象实例6都是可作为GC Roots的对象,而对象实例5因为从GC Roots可达,所以也不可被回收。而对象实例2,对象实例3因为从GC Roots不可达,所以就可被回收掉。

1.3引用的定义

      JDK1.2之后,引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。那为什会有这么多分类?因为在这之前,引用的定义太过于狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于一些“食之无味,弃之可惜”的对象就显得无能为力。所以在出现了强,软,弱,虚这几种引用之后,我们就可以对对象引用的各种状态进行描述,如:内存不足,是否可被回收等等。

强引用(Strongly Re-ference):在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

软引用(Soft Reference):是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

弱引用(Weak Reference):也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

虚引用(Phantom[ˈfæntəm] Reference):也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

二, 垃圾回收算法

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

  分为标记和清除两个阶段:首先标记需要回收的对象,在标记完成后统一回收,是最基础的垃圾收集算法,其他垃圾收集算法都

是基于它进行改进而实现的。

  它有两个不足点:一是效率低,二是清除后会产生大量不连续的内存碎片,可能会出现无法继续存放一个较大的对象而不得已提前触发另一次垃圾回收的情况。

2.2 复制算法(Mark-Copy)

  一般将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块并整齐排列,此后新产生的对象都存放在这块内存空间中。然后在将那块已使用过的内存一次清理掉,作为预留区域下次使用。这样使得每次都是对半个内存区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。但是这种算法也有弊端,就是每次只能使用内存空间的一半大小,降低了系统资源利用率,且当存活对象较多时,复制的工作量会太大而影响效率。

 

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

  标记-整理算法和标记-清除算法的过程有点相似之处,两者的标记过程是相同的,但对于标记整理算法来说,后续的步骤不是直接对可回收的对象进行清除回收,而是将它们统一移动到一端,然后直接清除掉端边界以外的内存。使用这种算法避免了内存空间利用率低的问题。

2.4 分代收集算法

  当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代(年轻代)和老年代,因为新生代中的对象98%是”朝生夕死“的,所以又将新生代的内存划分为一块较大的Eden空间和两块较小的Survivor空间,Eden空间用来存放每次新生的对象,Survivor空间来存放没有被垃圾收集器回收掉的对象,在每次进行回收时,先将Eden区和Survivor中还存活的对象一次性复制到Survivor的另一块空间中,并将对象的年龄值加一,当有对象的年龄值大于15时,就可将它移动到老年代,接着清除掉Eden区和Survivor中刚被用的空间区中的对象。HotSpot虚拟机默认Eden区和Survivor区中两块空间的大小比例为8:1:1。

  要注意在回收过程中,当Survivor空间不够存放本次存活的对象时,就需要依赖其他内存(老年代)进行担保。也就是说如果Survivor区中的另一块空间不够存放本次存活的对象,这些对象将直接通过分配担保机制进入老年代。当老年代也满了的时候,就会进行一次Full GC,就是将Eden区,Survivor区和老年代中的内存都进行一次垃圾收集。(新生代中使用的是Minor GC)

  采用这种分代处理的好处是可以根据各个年代的特点采用最适合的收集算法。在新生代中,每次手机发现有大批对象死亡,只有少量存活,那就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集,而老年代中因为对象存活率高没有额外空间对它进行担保,就必须使用“标记-清理‘或”标记-整理“算法来进行。

  下面就模拟一下垃圾收集的过程:

  

  先将Eden区和Survivor区的from区中本次仍存活的对象复制到to区中,并将对象的计数值加一,当对象的值为15时就将其复制到老年代中,

然后将Eden区中和from区中的对象清除掉,结果如下图。

     

  当Eden区中无法继续存储时,就再次进行垃圾回收,步骤和上面类似。

       

2.5 分代收集理论

1.一般把Java堆分为新生代(Young Generation)和老年代(Old Generation),在新生代中又分为Eden区和Survivor区,通常

比例为8:1:1,每次只保留10%的空间作预留区域,然后将90%的空间可以用作新生对象。

2.每一次垃圾回收之后,存活的对象年龄加1,当经历15次还存活的对象,我们就让它直接进入老年代。

3.另外一种进入老年代的方式是内存担保机制,也就是当新生代的空间不够的时候,让对象直接进入到老年代。

4.新生代的垃圾回收叫Minor  GC,老年代的叫Full  GC。

三, 垃圾收集器(JDK7-JDK11)

  如果说收集算法是内存回收的方法论,那么垃圾是收集器就是内存回收的具体实现。因为Java虚拟机规范中对垃圾收集器应如何实现没有任何规定,因此不同厂商提供的垃圾收集器都会有很大的差别,所以用户使用时都需要通过设置参数来决定自己要使用垃圾收集器。

  下图中展示了7种作用不同分代的垃圾收集器,存在连线的代表可以搭配使用,另外,目前没有一个真正最好的收集器出现,我们选择的只是对具体应用最合适的收集器。。

3.1 Serial收集器

  Serial收集器是最基本,发展最悠久的收集器。它是单线程的,也就是说它只会使用一个CPU或一条收集线程去完成垃圾收集的工作,并且在它进行垃圾收集时,必须暂停其他所有工作的线程,直到它收集结束(Stop  the  world)。

  可以看出该垃圾收集器工作效率是很低的,虽然在这之后又研发出了Parallel收集器和CMS收集器,减少了工作线程因内存回收而造成的停顿时间。但Serial收集器仍是不可替代的,并且到目前为止,它依然是虚拟机运行在Client模式下的默认新生代收集器。因为相对其他收集器,它简单高效,关键对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销(比如ParNew是多线程GC,所以工作时需要不停切换),专门做垃圾收集自然可以获得最高的单线程收集效率。

3.2 ParNew收集器

  ParNew是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数,收集算法,STW,对象分配规则,回收策略等都与Serial收集器完全一样。

  虽然它相对Serial收集器来说,只是增加了垃圾多线程收集的方法,并无其他创新,但由于目前除了Serial收集器外,只有它能跟CMS收集器配合使用,所以它目前也是许多运行在Server模式下的虚拟机中首选的新生代收集器

3.3 Parallel Scavenge收集器

  Parallel Scavenge收集器也是一款新生代收集器,它同样是基于复制算法实现的,能够并行收集的多线程收集器,与其他收集器的目标不同,其他收集器是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CUP处理器用于运行用户代码的时间与处理器总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),如虚拟机总运行了100分钟,其中垃圾收集器花费了1分钟,那吞吐量就是99%。

  停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

  该收集器提供了两个参数用于精准控制吞吐量,分别为最大垃圾收集停顿时间:-XX:MaxGCPauseMillis;吞吐量:-XX:GCTimeRatio;自适应策略:-XX:+UseAdaptiveSizePolicy可以自动给Eden区和survivor区分配内存空间的大小。注意最大垃圾收集停顿时间并非越短越好,因为收集停顿时间短了,收集的频率就会增加,这样就会降低吞吐量。

3.4 Serial Old收集器

  Serial收集器的老年代版本,它同样是单线程的,使用的标记-整理算法。

  这个收集器的主要目的是在与给Client模式下使用的,在server模式下只有两种用途:一是配合Parallel Scavenge收集器使用(在刚开始没有Parallel Old时),二是作为CMS的备选方案。

3.5 Parallel Old收集器

  Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程内存回收,是基于标记整理算法。  一般与Parallel Scavenge(新生代的垃圾收集器)结合使用。在这出现之前Parallel Scavenge一直处于尴尬期,因为在这之前Parallel Scavenge因为兼容问题只能与Serial Old配合使用,而又因为Serial Old效率低,使得Serial Old+Parallel Scavenge结合的效率,还不如 ParNew+CMS组合的效率高,这就显得Parallel Scavenge 有点无用武之地的感觉,直到Parallel Old出现。。。 。

  之后在注重吞吐量和CPU利用率的情况下,都会使用Parallel Old+Parallel Scavenge的结合。

 

4.6 CMS收集器

  CMS(Concurrent Mark Sweep)收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,一种以获取最短回收停顿时间为目标的收集器。

CMS收集器是基于标记-清除算法实现的,整个过程分为四个步骤,包括:
1)初始标记(CMS initial mark):需要STW(stop the world),仅仅是标记一下GC Roots能直接关联到的对象,速度是非常快的。
2)并发标记(CMS concurrent mark),就是进行GC Roots Tracing的过程。
3)重新标记(CMS remark):需要STW,是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
4)并发清除(CMS concurrent sweep)

 

  由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,自己单独运行的时间个非常短,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS的三大缺点:

1.CMS收集器的并发标记阶段需要占用CPU,所以就会减少用户线程的CPU的占用,就会降低用户线程的执行效率。

2.无法处理浮动垃圾,而浮动垃圾就是在并发清理过程中产生的垃圾,因为此次并发处理的只是前面被标记了的垃圾,而清理过程中仍然是会产生垃圾的,所以在并发清理阶段还需要有一个预留区域来存放这些浮动垃圾,但会当出现预留区域不够存放这些浮动垃圾的情况,就需要使用Serial Old收集器。

3.因为基于标记清除算法,所以会产生大量垃圾碎片。

 4.7 Garbage First收集器(G1)

  Garbage First(简称G1)已经是JDK9及以上版本的默认垃圾收集器,是目前最新的稳定的垃圾收集器(JDK11之前),主要面向服务端应用。

  G1回收器使用分代和分区算法。分代算法就是依然区分年轻代和老年代,依然有eden区和survivor区。分区算法就是在此算法中是将Java堆划分为许多大小相等的独立区(Region),数值是在 1M 到 32M 字节之间的一个 2 的幂值数,JVM 会尽量划分 2048 个左右、同等大小的 region。当然这个数字既可以手动调整,G1 也会根据堆大小自动进行调整。这些Region独立区一部分 是作为 Eden,一部分作为 Survivor,除了意料之中的 Old region,G1 会将超过 region 50% 大小的对象(在应用中,通常是 byte 或 char 数组)归类为 Humongous 对象,并放置在相应的 region 中。逻辑上,Humongous region 算是老年代的一部分。也就是说在此算法中不要求整个eden区,年轻代或者老年代都连续。

 

  与前面介绍的各种收集器不同的是,在G1算法中不是在整个Java堆中进行全区域的垃圾收集,而是先检测各个Region里面的垃圾堆积的大小,然后在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region区(回收后获得的内存多,回收时所需的时间少的价值大)。这种使用Region划分内存空间以及优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

从 GC 算法的角度,G1 选择的是复合算法,可以简化理解为:

  1)在新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。

  2)在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。习惯上人们喜欢把新生代 GC(Young GC)叫作 Minor GC,老年代 GC 叫作 Major GC,区别于整体性的 Full GC,但是现代 GC 中,这种概念已经不再准确,对于 G1 来说:Minor GC 仍然存在,虽然具体过程会有区别,会涉及 Remembered Set 等相关处理。老年代回收,则是依靠 Mixed GC。并发标记结束后,JVM 就有足够的信息进行垃圾收集,Mixed GC 不仅同时会清理 Eden、Survivor 区域,而且还会清理部分 Old 区域。有一个重点就是 Remembered Set,用于记录和维护 region 之间对象的引用关系。G1中提供了两种模式垃圾回收模式,Minor GC(YoungGC)和Mixed GC,两种都是Stop The World(STW)的。

如果不计算维护Remembered Set的操作,G1收集器的运作大致可以分为以下几个步骤:

1)初始标记

2)并发标记

3)最终标记

4)筛选回收

 

 说在后:此篇博客摘自周志明老师《深入理解Java虚拟机》,想深入理解的同学可查看此书。

原文地址:https://www.cnblogs.com/ljl150/p/12799176.html