• JVM系列3:JVM垃圾回收


    1.JVM内存分配和回收

    1.1 对象分配原则

      在JVM系列1:内存区域中我们谈到,JVM堆中的内存划分如下:

      

    从中可以看出堆内存分为新生代和老年代以及永久代(在JDK1.8中已经被MetaSpace元空间替代),其中新生代又分为Eden区和Survior1区和Survior2区;

    堆中分配内存常见的策略:

    • 对象优先分配在Eden区
    • 大对象直接进入老年代
    • 长期存活的对象进入老年代

    1.2 对象回收

      因为主流的垃圾回收器使用的分代回收策略,因此堆内存需要分为新生代和老年代。方便在不同的区域采用合适的内存回收策略。

    回收方式有Minor Gc和Full GC(Major Gc)两种,两者区别如下:

    • 大多数情况下,对象在Eden区分配地址,当Eden区内存不足时,会发生一次Minor Gc,也就是新生代GC
    • 老年代发生的GC,通常一次Full Gc至少伴随着一次Minor Gc,且Full Gc的速度较Minor Gc慢10倍以上。也就是老年代GC

    以下是测试Minor Gc的实例:

    在Run Configurations中的Arguments中添加打印GC信息的参数:

      -XX:+PrintGCDetails

     

    由上面的GC打印结果可知,新创建的对象part1优先分配了Eden区

    此时Eden区使用率已经接近100%,此时我们继续创建一个对象part2

    由上面的GC打印结果可知,由于Eden区内存空间已经不足以容纳对象part2,刚才Minor Gc中讲到:Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,在GC过程中,虚拟机发现part1对象无法存入Survior区,此时只好通过 分配担保机制<文章底部有介绍> 把新生代的对象提前转移到老年代中去,因为老年代上的空间足够存放part1,所以不会出现Full GC。

    2.判断对象死亡方法

      堆中几乎放着所有的对象实例,因此在对堆垃圾回收的的第一步:就是要判断哪些对象已经死亡,即哪些对象是此次GC可以进行回收的。

    判断对象是否死亡有两种方法:引用计数法和可达性分析算法。

    2.1 引用计数法

      给对象添加一个引用计算器,每当一个地方引用它时,则该引用计数器加1,当引用失效时,计数器减1,任意时刻如果对象的引用计数器为0,那么虚拟机就认为没有任何地方需要引用该对象,即对象"死了",可以对其进行内存回收。

      优点:实现简单、高效。

      缺点:无法应对对象的循环引用。基于此目前主流的虚拟机中没有采用该方法。

        

    public class ReferenceCountingGc {
        Object instance = null;
        public static void main(String[] args) {
            ReferenceCountingGc objA = new ReferenceCountingGc();
            ReferenceCountingGc objB = new ReferenceCountingGc();
            objA.instance = objB;
            objB.instance = objA;
            objA = null;
            objB = null;
        }
    }
    /*在上述实例中,objA和objB相互引用,即便后面对objA和objB都设置为null
     *时,但是它们的引用计数器都不为0,导致采用引用计数法的GC也无法对    
     *objA和objB进行垃圾回收*/

    2.2 可达性分析算法

      该算法的思想是:通过一系列被称为"GC Roots"的对象作为起点,从这些起点开始出发,到达每个对象所走过的路径称为"引用链",当一个对象到GC Roots之前没有引用链连接的话,说明该对象是不可及的状态,也就是不可用,可以进行GC。

      

      目前主流虚拟机中大多采用该算法判断对象是否存活。

    2.3 强引用、软引用、弱引用和虚引用

    ⑴强引用(StrongReference)
    强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。  ps:强引用其实也就是我们平时A a = new A()这个意思。如果你不需要使用某个对象了,可以将相应的引用设置为null,消除强引用来帮助垃圾回收器进行回收。因为过多的强引用也是导致OOM的罪魁祸首。

      总结下来,强引用有以下特点:

    • 强引用就是最普通的引用
    • 可以使用强引用直接访问目标对象
    • 强引用指向的对象在任何时候都不会被系统回收
    • 强引用可能会导致内存泄漏
    • 过多的强引用会导致OOM

      因为持有强引用的对象不会被垃圾回收,所以可能导致内存泄漏:

    ObjectA a = new ObjectA();
    ObjectB b = new ObjectB(a);
    a = null;
    
    /*在上述代码中,对象a和b都持有一个对象的强引用。当执行a =null后,本来对象ObjectA的强引用a释放了。但是此时因为ObjectB持有ObjectA的强引
    用,导致无法对ObjectA进行垃圾回收,此时就会造成内存泄漏*/

    (2)软引用(SoftReference)

      软引用是使用SoftReference创建的引用,强度弱于强引用,被其引用的对象在内存不足的时候会被回收,不会产生内存溢出。

    在垃圾回收器没有回收时,软可达对象<文章底部有介绍> 就像强可达对象一样,可以被程序正常访问和使用,但是需要通过软引用对象间接访问,需要的话也能重新使用强引用将其关联。所以软引用适合用来做内存敏感的高速缓存。

    String s = new String("Frank");    // 创建强引用与String对象关联,现在该String对象为强可达状态
    SoftReference<String> softRef = new SoftReference<String>(s);     // 再创建一个软引用关联该对象
    s = null;        // 消除强引用,现在只剩下软引用与其关联,该String对象为软可达状态
    s = softRef.get();  // 重新关联上强引用

    软引用的总结如下:

    • 软引用弱于强引用
    • 软引用指向的对象会在内存不足时被垃圾回收清理掉
    • JVM会优先回收长时间闲置不用的软引用对象,对那些刚刚构建的或刚刚使用过的软引用对象会尽可能保留
    • 软引用可以有效的解决OOM问题
    • 软引用适合用作非必须大对象的缓存

    (3)弱引用(WeakReference)

    弱引用是使用WeakReference创建的引用,弱引用也是用来描述非必需对象的,它是比软引用更弱的引用类型。在发生GC时,只要发现弱引用,不管系统堆空间是否足够,都会将对象进行回收。(如果弱引用对象较大,直接进到了老年代,那么就可以苟且偷生到Full GC触发前)

    String s = new String("Frank");    
    WeakReference<String> weakRef = new WeakReference<String>(s);
    s = null;  //把s设置为null后,字符串对象便只有弱引用指向它。

     弱引用的特点:

    • 只具有弱引用的对象拥有更短暂的生命周期。
    • 被垃圾回收器回收的时机不一样,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。而被软引用关联的对象只有在内存不足时才会被回收。
    • 弱引用不会影响GC,而软引用会一定程度上对GC造成影响

    (4)虚引用(PhantomReference)

      虚引用是使用PhantomReference创建的引用,虚引用也称为幽灵引用或者幻影引用,是所有引用类型中最弱的一个。一个对象是否有虚引用的存在,完全不会对其生命周期构成影响,也无法通过虚引用获得一个对象实例。

    • 虚引用是最弱的引用
    • 虚引用对对象而言是无感知的,对象有虚引用跟没有是完全一样的
    • 虚引用不会影响对象的生命周期
    • 虚引用可以用来做为对象是否存活的监控(可用来做堆外内存的回收)

    虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

    特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

    3.几种垃圾回收算法

    3.1 标记-清除算法

      标记-清除算法分为:标记阶段和清除阶段,标记阶段:从根节点开始,标记所有从根节点开始的对象,未被标记的对象就是未被引用的垃圾对象,然后时清除阶段:清除所有未被标记的对象。

      

      标记-清除算法存在问题:

    • 造成可用内存不连续,存在大量内存碎片,大对象进行内存分配时可能会提前出发Full Gc;
    • 效率较低

    3.2 复制算法

      将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

      

     

    复制算法存在的问题是可用内存空间只有原来的一半,为了解决这个问题:

        IBM研究表明新生代中的对象98%是朝夕生死的,所以并不需要按照1:1的比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1(可以通过-SurvivorRattio来配置),也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。

    复制算法的应用场景:存活对象少,垃圾对象多的内存区域,这种情形在新生代较为常见,因此现在的商业虚拟机都采用这种收集算法来回收新生代,而老年代的存活对象较多,如果依然采用复制算法,复制开销过大显然不合适,此时我们就采用下面一种垃圾回收算法。

    3.3 标记-整理算法

      标记-整理算法和标记-清除算法类似,也分为两个阶段:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一段移动,然后直接清理掉端边界以外的内存。

      

       *标记-压缩算法是一种老年代的回收算法,它在标记-清除算法的基础上做了一些优化。首先也需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。

    3.4 增量算法

      如果一次GC过程将所有垃圾都进行回收,会导致耗时较长,使程序进入一个长时间停顿的状态,为了解决这种问题,我们想到了让垃圾回收的线程和程序的主线程交替运行,每次垃圾回收线程只回收一小部分内存区域,然后马上切换到主程序运行,然后主程序运行一段时间后由切换回垃圾回收线程,如此循环直至内存区域全部回收完毕,使用这种方式时:由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

    4.几种垃圾收集器

      第3节中讲到的垃圾回收算法是内存回收的方法或是思想,而本节中的垃圾收集器就是内存回收的具体实现。

    此节我们将对各个收集器进行比较,但并非了挑选出一个最好的收集器。因为知道到目前为止,还没有最好的垃圾收集器来满足我们在不同内存区域的垃圾回收需求,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器

    4.1 Serial收集器

      Serial(串行)收集器是最基本也是最悠久的垃圾收集器,从其命名来看,"串行",即在同一时间内只能做一个任务,所以它是一个"单线程"的垃圾收集器,这个单线程不仅仅是指只有一条垃圾回收进程来进行回收工作,而是更具重量级的单线程,当它在工作时,所有其他工作线程都必须停止下来(Stop the world),直至回收结束。

        

      在Serial收集器中:新生代采用复制算法,老年代采用标记-整理算法。

      优点:由于没有线程切换和上下文切换的开销,因此它简单而高效(与其他收集器的单线程相比),通常可应用在Client端的垃圾回收中。

      缺点:由于"Stop The World",导致用户体验差。

    4.2 ParNew收集器

      ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样。

      

      在ParNew收集器中:新生代采用复制算法,老年代采用标记-整理算法。

      优点: 1.能够并发,因此是大多数Server环境下的垃圾回收的选择  2.除Serial之外是唯一可以和CMS收集器(真正意义上的并发收集器)配合工作。

    4.3 Parallel Scavenge收集器

      Parallel是采用复制算法的多线程新生代垃圾回收器,它和ParNew收集器有很多的相似的地方,但Parallel更加注重吞吐量(文章下方有注释)

    Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果不熟悉,可以把内存管理优化的工作交由虚拟机来完成。

    Parallel Scavenge收集器:新生代采用复制算法,老年代采用标记-整理算法。

    4.4 Serial Old收集器

      Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。

    4.5 Parallel Old收集器

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

    4.6 CMS收集器

      CMS(Concurrent Mark Swep)收集器是一个比较重要的回收器,现在应用非常广泛,CMS一种获取最短回收停顿时间为目标的收集器,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。这使得它很适合用于和用户交互的业务。从名字(Mark Swep)就可以看出,CMS收集器是基于标记清除算法实现的。它的收集过程分为四个步骤:

    1. 初始标记(initial mark)  :暂停所有的其他线程,并记录下直接与root相连的对象,速度很快 ; 
    2. 并发标记(concurrent mark):同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
    3. 重新标记(remark):重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
    • 并发清除(concurrent sweep):开启用户线程,同时GC线程开始对未标记的区域做清扫。

    优点:

    • 真正实现并发
    • 低停顿

    缺点:

    • 对CPU资源敏感
    • 无法处理浮动垃圾
    • 使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

    4.7 G1收集器

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

    G1收集器的工作过程:

    • 初始标记
    • 并发标记
    • 最终标记
    • 筛选回收

    它具有如下特点:

    • 并发与并行:充分利用硬件资源,减少"Stop The World"时间,减少用户线程停顿时间。
    • 分代收集:G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
    • 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的,因此不会出现大量空间碎片。
    • 可预测的停顿:G1和CMS一样, 都追求低停顿,但G1还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。

     G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了GF收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

    参考:


    分配担保机制:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
    下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
    取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁

    软可达:如果一个对象与GC Roots之间不存在强引用,但是存在软引用,则称这个对象为软可达(soft reachable)对象。

    内存泄漏:是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费。

    吞吐量:所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

  • 相关阅读:
    成功引爆
    pecompact2脱壳手记
    象棋
    今天小雨
    出错了,怎么办?
    设计模式——Adapter模式
    表设计中应注意的2点
    设计模式——Singleton模式
    设计模式——Facade模式
    设计模式——Strategy模式
  • 原文地址:https://www.cnblogs.com/LearnAndGet/p/9778004.html
Copyright © 2020-2023  润新知