美文网首页程序员Java学习笔记
深入理解Java虚拟机(二)GC算法与垃圾收集器

深入理解Java虚拟机(二)GC算法与垃圾收集器

作者: 2eebd72c5658 | 来源:发表于2017-08-23 14:27 被阅读205次

    概述

    说起垃圾收集(Grabage Collection,GC),我们需要考虑GC需要完成的三件事情:

    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?

    为什么我们要求了解GC呢和内存分配呢?答案很简单:当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统达到更大并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

    前面到我们已经介绍了Java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出有条不紊地执行者出栈和入栈操作。每一个栈帧中分配多少内存基本是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分的内存,后续讨论的“内存”分配和回收也仅指这一部分的内存。

    如何判断对象已死?

    在堆中存放着Java世界中几乎所有对象的实例,垃圾收集器在对堆回收之前,第一件事情就是要确定这些对象中,哪些还“存活”着,哪些已经“死去”。

    1、引用计数法

    引用计数法判断简要说明是这样的:给对象中添加一个引用计数器,每当有一个地方引用时,引用计数器值+1;当引用失效时,计数器值-1;任何时刻引用计数器的值为0的对象就是不可能再被使用的。

    客观的说,引用计数法的实现简单,判定效率也很高,在大部分情况下都是一个不错的算法,也有一些著名的案例。但是,至少主流的Java虚拟机里面没有选择引用计数法来管理内存,其中最主要的原因是它未能解决对象之间相互循环引用的问题。

    举个简单的例子,请看下面的代码:对象objA和objB都有字段instance,赋值令objA.instance = objB及objB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已不可能再被访问,但是他们因为互相引用着对方,导致他们的应用计数器值都不为0,于是引用计数器算法无法通知GC收集器回收它们。


    public class ReferenceCountingGC {
    
        public Object instance = null;
    
            private static final int _1MB = 1024 * 1024;
    
            private byte[] bigSize = new byte[2 * _1MB];
    
            public static void main(String[] args) {
                ReferenceCountingGC objA = new ReferenceCountingGC();
                ReferenceCountingGC objB = new ReferenceCountingGC();
                objA.instance = objB;
                objB.instance = objA;
        
                objA = null;
                objB = null;
        
                //假设在这行发生GC,objA和objB是否能被回收呢?
                System.gc();
            }
    }
    

    运行结果:

    [GC (System.gc()) [PSYoungGen: 6769K->496K(38400K)] 6769K->504K(125952K), 0.0011286 secs] [Times: user=0.00 sys=0.01, real=0.00 secs] 
    [Full GC (System.gc()) [PSYoungGen: 496K->0K(38400K)] [ParOldGen: 8K->426K(87552K)] 504K->426K(125952K), [Metaspace: 3304K->3304K(1056768K)], 0.0059597 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
    Heap
     PSYoungGen      total 38400K, used 333K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
    eden space 33280K, 1% used [0x0000000795580000,0x00000007955d34a8,0x0000000797600000)
    from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
    to   space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
    ParOldGen       total 87552K, used 426K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
    object space 87552K, 0% used [0x0000000740000000,0x000000074006ab90,0x0000000745580000)
    Metaspace       used 3311K, capacity 4496K, committed 4864K, reserved 1056768K
    class space    used 369K, capacity 388K, committed 512K, reserved 1048576K
    

    从运行结果可以清楚得看到,GC日志中包含“6769K->496K”,意味着虚拟机并没有因为这两个对象相互引用就不回收他们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

    2、可达性分析算法

    可达性分析算法图例

    在主流的商用应用程序语言中,都是称通过可达性分析来判断对象是否存活的。这个算法的思路就是通过一系列的称为“GC Roots”的对象作为起点,这这些节点往下搜索,所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的。

    在Java语言中,可作为GC Roots的对象包括下面几种:

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

    垃圾回收算法

    1、标记-清除算法(Mark-Sweep)

    最基础的收集算法是“标记-清除”算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有要回收的对象,在标记完成后统一回收所有被标记的对象。它的主要不足有两个:一个是效率问题,标记和清除两个过程效率都不高;另一个是空间问题,标记清除之后会有大量不连续的空间碎块,后面程序中需要分配较大对象的时候,由于无法找到足够的内存不得不提前出发另一次垃圾收集动作。

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

    2、复制算法

    将可用内存按容量大小划分为大小相等的两块,每次只使用其中的一块。当一块内存使用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。只是这种算法的带将是将内存缩小为原来的一半,未免太高了一点。

    复制算法

    现代的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中对象98%对象是“朝生夕死”的,所以不需要按照1:1的比例来划分内存空间,而是将内存分为较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。HotSpot虚拟机中默认Eden和Survivor的大小比例是8:1。

    3、标记-整理算法

    复制收集算法在对象存活率较高时,就要进行较多的复制操作,效率就会变低。
    根据老年代的特点,提出了”标记-整理“算法。

    标记过程仍然与”标记-清除“算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

    标记-整理算法

    4、分代收集算法

    一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
    在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法。
    在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记-清除”或“标记-整理”算法来进行回收。

    垃圾收集器

    图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域表示它是属于新生代收集器还是老年代收集器。


    1.Serial收集器

    是最基本、发展历史最悠久的收集器。这是一个单线程收集器。但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。


    Serial收集器是虚拟机运行在Client模式下的默认新生代收集器。
    优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程效率。

    2.ParNew收集器

    ParNew收集器其实就是Serial收集器的多线程版本。
    是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
    ParNew收集器默认开启的收集线程数与CPU的数量相同。下图是ParNew/Serial Old收集器运行示意图


    3.Parallel Scavenge收集器

    Parallel Scavenge收集器是一个新生代收集器,使用复制算法,又是并行的多线程收集器。
    最大的特点是:Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。
    所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。
    高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

    4.Serial Old收集器

    Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下虚拟机使用。
    如果在Server模式下,它主要还有两大用途
    1.与Parallel Scavenge收集器搭配使用
    2.作为CMS收集器的后备预案,在并发收集发生Conurrent Mode Failure使用。

    5.Parallel Old收集器

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

    6.CMS(Concurrent Mark Sweep)收集器

    CMS收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
    关注点:尽可能地缩短垃圾收集时用户线程的停顿时间。
    CMS收集器是基于“标记-清除”算法实现的,整个过程分为4个步骤

    1. 初始标记
    2. 并发标记
    3. 重新标记
    4. 并发清除

    其中,初始标记,重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只标记一下GC Roots能直接关联到的对象,速度很快。并发标记阶段就是 进行GC Roots Tracing的过程。重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记几率,这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记时间短。整个过程耗时最长的阶段是并发标记,并发清除过程,但这两个过程可以和用户线程一起工作。


    缺点:

    • CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
    • CMS收集器无法处理浮动垃圾,可能出现“Conurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会产生新的垃圾,这一部分垃圾出现在标记过程之后,CMS无法在档次收集中处理掉它们,只好留待下一次GC时再清理掉。这部分垃圾就称为“浮动垃圾”。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时程序运作使用。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活。如果预留空间无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案Serial Old。
    • CMS是一款基于“标记-清除”算法实现的收集器,所以会有大量空间碎片问题。

    7.G1收集器

    G1收集器是当今收集器技术发展的最前沿成果之一。是一款面向服务端应用的垃圾收集器。
    特点

    1. 并行与并发
      能充分利用多CPU,多核环境下的硬件优势,缩短Stop-The-World停顿的时间,同时可以通过并发的方式让Java程序继续执行
    2. 分代收集
      可以不需要其他收集器的配合管理整个堆,但是仍采用不同的方式去处理分代的对象。
    3. 空间整合
      G1从整体上来看,采用基于“标记-整理”算法实现收集器
      G1从局部上来看,采用基于“复制”算法实现。
    4. 可预测停顿

    使用G1收集器时,Java堆内存布局与其他收集器有很大差别,它将整个Java堆划分成为多个大小相等的独立区域。
    G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

    垃圾收集器参数总结

    内存分配与回收策略

    • 对象优先在Eden分区:
      大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机发起一次Minor GC。GC后对象尝试放入Survivor空间,如果Survivor空间无法放入对象时,只能通过空间分配担保机制提前转移到老年代。

    • 大对象直接进入老年代:
      大对象指需要大量连续内存空间的Java对象。虚拟机提供-XX:PretenureSizeThreshold参数,如果大于这个设置值对象则直接分配在老年代。这样可以避免新生代中的Eden区及两个Survivor区发生大量内存复制。

    • 长期存活的对象进入老年代:
      虚拟机会给每个对象定义一个对象年龄计数器。如果对象在Eden出生并且经过一次Minor GC后任然存活,且能够被Survivor容纳,将被移动到Survivor空间中,并且对象年龄设为1.每次Minor GC后对象任然存活在Survivor区中,年龄就加一,当年龄到达-XX:MaxTenuringThreshold参数设定的值时,将会移动到老年代。

    • 动态年龄判断:
      虚拟机不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold设定的值才会将对象移动到老年代去。如果Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

    • 空间分配担保:
      在Minor GC前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC是成立的。如果不成立,虚拟机查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次移动到老年代对象的平均大小,如果大于,将尝试一次Minor GC。如果小于,或者HandlePromotionFailure设置值不允许冒险,那将进行一次Full GC。

    新生代GC(Minor GC):发生在新生代的垃圾收集动作,因为Java对象大多朝生夕死,所以Minor GC非常频繁,回收速度也较快。

    老年代GC(Major GC/Full GC):发生在老年代的垃圾收集动作。出现Major GC,经常会伴随至少一次Minor GC。Major GC的速度一般比Minor GC慢10倍以上。


    今天的分享希望对你有用,每天分享一点点

    相关文章

      网友评论

        本文标题:深入理解Java虚拟机(二)GC算法与垃圾收集器

        本文链接:https://www.haomeiwen.com/subject/varddxtx.html