JVM垃圾回收篇(垃圾回收算法)

Posted ProChick

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM垃圾回收篇(垃圾回收算法)相关的知识,希望对你有一定的参考价值。

1.垃圾回收的两个阶段

  • 垃圾标记阶段

    • 在堆空间里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
    • 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,那么这个对象就应该被标记为死亡对象,等待被回收
  • 垃圾清除阶段

    • 当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存
    • 目前在JVM中比较常见的三种垃圾收集算法是:标记一清除算法( Mark Sweep )、复制算法( Copying)、标记一压缩算法( Mark Compact )

2.垃圾标记算法之引用计数算法

  • 概念

    引用计数算法( Reference Counting ),对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况

  • 原理

    对于一个对象A而言,只要有任何一个对象引用了A,则A的引用计数器就加1。当该引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,那就表示对象A不可能再被使用,就可以进行回收了

  • 优点

    • 算法实现简单,垃圾对象易于辨识
    • 判定的效率高,且回收没有延迟性
  • 缺点

    • 它需要单独的字段存储计数器,这就增加了存储空间的开销

    • 每次赋值都需要更新计数器,即伴随着加法和减法操作,这就增加了时间的开销

    • 它无法处理循环引用的情况,也正是因为如此,在Java的垃圾回收器中才没有使用这类算法

      循环引用说明

      证明Java中的垃圾回收机制没有使用这种算法

      public class RefCountGC {
          private byte[] bigSize = new byte[5 * 1024 * 1024];
      
          Object reference = null;
      
          public static void main(String[] args) {
              RefCountGC obj1 = new RefCountGC();
              RefCountGC obj2 = new RefCountGC();
      
              obj1.reference = obj2;
              obj2.reference = obj1;
      
              obj1 = null;
              obj2 = null;
              
              // 显式的调用垃圾回收,观察对象是否被回收
              System.gc();
      
              try {
                  Thread.sleep(1000000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
      }
      

      从上图可以看出,没有进行垃圾回收之前,内存占用16798K。进行垃圾回收之后,内存占用655K。说明对象确实被回收释放了。但如果按照引用计数算法,两个对象之间其实还存在着互相引用,即引用计数器的值为1,也就是说本来不应该被回收,所以这里使用的显然就不是引用计数算法。

  • 总结

    • 引用计数算法, 是很多语言在进行资源回收时所利用的算法。例如:Python语言,它更是同时支持引用计数和垃圾收集机制
    • Java并没有选择引用计数算法,是因为其存在一个基本的难题,也就是很难处理循环引用关系
    • Python是如何解决循环引用的呢?
      • 手动解除,就是在合适的时机,解除引用关系。
      • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用
    • 具体看这种算法是不是好,还是要看场景的,业界有大规模的实践中保留了引用计数机制,以提高吞吐量的尝试

3.垃圾标记算法之可达性分析法

  • 概述

    • 可达性分析算法也叫做根搜索算法
    • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生
    • 相较于引用计数算法,这里的可达性分析算法就是Java语言、C#语言所选择的,这种类型的垃圾收集通常也叫作追踪性垃圾收集
  • 思路

    • 可达性分析算法是以根对象集合( GCRoots )作为起始点,所谓"GC Roots"根对象集合就是一组必须活跃的引用,然后按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达

    • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索过程中所走过的路径称为引用链( Reference Chain )

    • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。如果目标对象没有任何引用链相连,则认为是不可达的,也就意味着该对象己经死亡,可以标记为垃圾对象

  • GC Roots都包含哪些对象

    • 虚拟机栈中引用的对象,比如:各个线程被调用的方法中使用到的参数、局部变量等
    • 本地方法栈引用的对象
    • 方法区中类静态属性引用的对象,比如:Java类的引用类型静态变量
    • 方法区中常量引用的对象,比如:字符串常量池 里的引用
    • 所有被同步锁 synchronized 持有的对象
    • 基本数据类型对应的 Class 对象、一些常驻的异常对象( NullPointerException、OutOfMemoryError )、系统类加载器对象、反映JVM内部情况的JMXBean对象、JVMTI中注册的回调对象、本地代码缓存等
    • 除了上面这些固定的情况以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收。如果只针对Java堆中的某一块区域进行垃圾回收(比如:只针对新生代进行回收),那就必须考虑到这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要将关联的区域对象也加入GC Roots集合中去,这样才能保证可达性分析的准确性
  • 注意细节

    • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,这点不满足的话,分析结果的准确性就无法保证

    • 这也正是导致垃圾回收进行时,必须进行Stop The World的一个重要原因,即不能够被用户线程所干扰。 即使是在号称几乎不会发生停顿的 CMS 垃圾收集器中,枚举根节点时也是必须要停顿的

4.垃圾清除算法之标记清除算法

  • 基本概述

    • 标记一清除算法是一种非常基础和常见的垃圾收集算法
    • 该算法被 J·McCarthy 等人在1960年提出并并应用于Lisp语言
  • 执行过程

    • 当堆中的有效内存空间( available memory )被耗尽的时候,就会停止整个程序( stop the world ),然后进行两项工作:标记、清除

    • 标记: 垃圾收集器从引用根节点GC Roots开始遍历,然后标记所有正在被引用的对象,然后在对象的Header头信息中记录为可达对象

    • 清除: 垃圾收集器对整个堆内存空间从头到尾进行线性的遍历,如果发现某个对象在其Header头信息中没有标记为可达对象,则将其回收

  • 优点缺点

    • 优点
      • 算法简单
      • 易于实现
      • 易于理解
    • 缺点
      • 执行效率不算高
      • 执行时需要停止整个应用程序,用户体验差
      • 这种方式清理出来的空闲内存是不连续的,会产生内存碎片,需要专门维护一个空闲列表
  • 注意细节

    这里所谓的清除并不是真的置空,而是把需要清除对象的地址回收到空闲的地址列表里,下次有新对象需要加载时,直接使用这些地址即可,也就相当于一个覆盖的过程

5.垃圾清除算法之复制算法

  • 基本概述

    • 为了解决标记一清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文
    • 在该论文中所描述的算法被人们称为复制算法,它也被M. L.Minsky本人成功地引入到了Lisp语言的一个实现版本中
  • 执行过程

    • 将内存空间分为大小相同的两块,每次只使用其中一块进行对象的存储

    • 在垃圾回收时,将正在使用内存空间中的存活对象复制到未被使用空的内存块中,然后清除正在使用的内存块中的其余所有的垃圾对象

    • 之后不断交换两个内存的角色,不断进行复制的这种操作,最后完成垃圾回收

  • 优点缺点

    • 优点
      • 没有标记和清除过程,实现简单,运行高效
      • 复制过去以后保证空间的连续性,不会出现“碎片”问题
    • 缺点
      • 需要两倍的内存空间
      • 这种复制而不是移动的方式,意味着GC需要维护内存之间对象引用关系,也就是需要不断更改栈中存储变量指向对象引用的地址指针
  • 注意细节

    如果系统中的可用对象很多,复制算法就不是很理想。一种极端情况下,如果经过一遍垃圾回收发现没有可被回收的对象,那就意味着要白白做一次复制的操作,而且复制的量极大

  • 应用场景

    在新生代中,通常一次可以回收70%-90%的内存空间,回收性价比很高,所以现在的商业虚拟机都是用这种收集算法回收新生代的。比如:新生代的幸存者区

6.垃圾清除算法之标记压缩算法

  • 基本概述

    • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。但是在老年代,更常见的情况是大部分对象都是存活对象,如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法
    • 标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进,于是发明了标记一压缩算法
    • 1970年前后,G. L. Steele 、C. J. Chene、D.S. Wise 等研究者发布了标记一压缩算法,在许多现代的垃圾收集器中,人们也都使用了这种算法或其改进版本
  • 执行过程

    • 第一阶段:和标记一清除算法一样,也是从根节点GC Roots开始标记所有正在被引用对象

    • 第二阶段:将所有的存活对象进行移动整理,将它们压缩到内存的一端,然后按顺序排放

    • 第三阶段:清理边界外所有的垃圾对象

  • 优点缺点

    • 优点
      • 不会发生内存碎片化问题,也就不需要维护空闲列表
      • 针对于复制算法而言,不需要进行内存减半处理
    • 缺点
      • 进行碎片整理的过程中,也就是移动对象的过程中,如果对象被其他对象引用,则仍然需要调整引用的地址指针
      • 移动对象的过程中,需要全程暂停用户相关应用程序,保证数据一致性
  • 注意细节

    • 标记一压缩算法的最终效果等同于标记一清除算法执行完成后,再进行一次内存碎片整理。二者的本质差异在于标记一清除算法是一种非移动式的回收算法,而标记一压缩是移动式的
    • 标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉,这样当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可

7.垃圾清除算法总结

标记清除算法复制算法标记压缩算法
执行效率中等最快最慢
空间开销开销小,但有碎片化问题开销大,但没有碎片化问题开销小,且没有碎片化问题
移动对象不需要需要需要

8.其它收集算法

1.分代收集算法

  • 基本概述

    • 我们知道不同对象的生命周期是不一样的,因此不同生命周期的对象可以采取不同的收集方式,以便提高回收效率,这就是分代收集算法的初衷
    • 在Java程序运行的过程中,会产生大量的对象,根据不同对象的生命周期,选择合适的算法
      • HTTP请求中的Session对象、线程对象、Socket连接对象等等, 这类对象的生命周期比较长,我们可以选用复制算法
      • 程序运行过程中生成的临时变量、字符串对象等等,这类对象的生命周期比较短,我们可以选用标记清除或者标记压缩算法
    • 目前几乎所有的GC都是采用分代收集算法进行垃圾回收的
  • 分代案例

    在HotSpot VM中,一般是把Java堆空间分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率

    • 年轻代
      • 区域相对老年代较小,对象生命周期短、存活率低,回收频繁
      • 复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过HotSpot VM中的两个survivor的设计得到缓解
    • 老年代
      • 区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
      • 这种情况存在大量存活率高的对象,复制算法明显变得不合适,一般是由标记清除或者标记压缩算法的混合实现
  • 应用场景

    • 在HotSpot VM中,垃圾收集器CMS是基于 Mark Sweep 算法实现的
    • 其中,对于内存碎片问题,CMS采用基于 Mark Compact 算法的 Serial Old 回收器作为后补措施,也就是当内存回收不佳导致Concurrent Mode Failure时,将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理

2.增量收集算法

  • 基本概述
    • 上述现有的算法,在垃圾回收过程中,应用程序都将处于一种 Stop The World 的状态
    • 在这种状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,然后等待垃圾回收的完成
    • 如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,增量收集算法的诞生
  • 实现思想
    • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。也就是说每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成
    • 总的来说,增量收集算法的基础仍是传统的标记清除和复制算法,只不过增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
  • 优点缺点
    • 优点:由于在垃圾回收过程中,间歇性地还执行了应用程序代码,所以能减少系统的停顿时间
    • 缺点:由于线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

3.分区收集算法

  • 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。
  • 为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿
  • 分代算法将按照对象的生命周期长短划分成两个部分,而分区算法将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用,独立回收
  • 这种算法的好处是可以控制一次回收多少个小区间

以上是关于JVM垃圾回收篇(垃圾回收算法)的主要内容,如果未能解决你的问题,请参考以下文章

<JVM上篇:内存与垃圾回收篇;11-垃圾回收概述及算法

JVM垃圾回收2(垃圾收集算法)

JVM系列 - JVM垃圾回收器

JVM学习--垃圾回收器

JVM GC-----垃圾回收算法

JVM调优:基本垃圾回收算法