原创

你必须了解的java内存管理机制(四)-垃圾回收

前言

  在前面三篇文章中,对JVM的内存布局、内存分配、垃圾标记做了较多的介绍,垃圾都已经标记出来了,那剩下的就是如何高效的去回收啦!这篇文章将重点介绍如何回收旧手机、电脑、彩电、冰箱~啊呸(⊙o⊙)…将重点介绍几种垃圾回收算法、HotSpot中常用的垃圾收集器的主要特点和应用场景。同时,这篇文章也是这个系列中的最后一篇文章啦!

相关链接(注:文章讲解JVM以Hotspot虚拟机为例,jdk版本为1.8)
1、 你必须了解的java内存管理机制-运行时数据区
2、 你必须了解的java内存管理机制-内存分配
3、 你必须了解的java内存管理机制-垃圾标记
4、 你必须了解的java内存管理机制-垃圾回收

正文

  上一篇文章中,我们详细介绍了两种标记算法,并且对可达性分析算法做了较多的介绍。我们也知道了HotSpot在具体实现中怎么利用OopMap+RememberedSet的技术做到“准确式GC”。不管使用什么优化的技术,目标都是准确高效的标记回收对象!那么,为了高效的回收垃圾,虚拟机又经历了哪些技术及算法的演变和优化呢?(注:G1收集器及回收算法本文不涉及,因为我觉得后面可以单独写一篇文章来谈!)

回收算法

  在这里,我们会先介绍几种常用的回收算法,然后了解在JVM中式如何对这几种算法进行选择和优化的。

标记-清除

  "标记-清除"算法分为两个阶段,“标记”和“清除”。标记还是那个标记,在上一篇文章中已经做了较多的介绍了,JVM在执行完标记动作后,还在"即将回收"集合的对象将被统一回收。执行过程如下图:

  

  优点:
    1、基于最基础的可达性分析算法,它是最基础的收集算法。
    2、后续的收集算法都是基于这种思路并对其不足进行改进而得到的。
  缺点:
    1、 执行效率不高。
    2、 由上图能看到这种回收算法会产生大量不连续内存碎片,如果这时候需要创建一个大对象,则无法进行分配。

复制算法

  “复制”算法将内存按容量划分为大小相等的两块,每次使用其中的一块。当一块的内存用完了,就将还存活的对象复制到另一块上面,然后将已经使用过的存储空间一次性清理掉,这样每次都是针对整个半区的内存进行回收,不用考虑碎片问题。执行过程如下图:

  

  优点:
    1、每次针对半个区域进行回收,实现简单,运行高效。
    2、不会产生内存碎片问题。
  缺点:
    1、 内存会缩小为原来的一般,代价高。
    2、 当对象存活率较高时,需要进行较多复制操作,效率将会变低。

复制算法改良版

  “复制算法改良版”替代原来将内存一分为二的方案,将内存分为一块较大的内存(称为Eden空间)和两块较小的内存(称为Survivor空间),每次使用Eden空间和其中一块Survivor空间。当回收时,将Eden和其中一块Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。执行过程如下图:

  

  优点:
    1、改善了普通复制算法的缺点,提高了空间利用率

标记-整理算法

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

  

  优点:
    1、改善了“标记-清除”算法会产生内存碎片的缺点。
    2、不会像“复制”算法那样效率随对象存活率升高而变低。
  缺点:
    1、 依然没有解决 “标记-清除”算法存在的缺点,那就是回收效率问题。还多了需要整理的过程,效率更低。

分代收集算法

  我们都知道,在主流的虚拟机中都是采用分代收集算法来进行堆内存的回收,在第一篇文章中我们也用了一张图展示了JVM堆内存的划分。如下:

  

  分代回收根据对象存活周期的不同将内存划分为几块,这样就可以根据各个年代的特点采用最适当的收集算法。一般把Java堆分为新生代老年代

  新生代

  在Hotspot虚拟机中,新生代的收集器都是采用的改良版的复制算法进行垃圾回收。将新生代一分为三,一块Eden区和两块Survivor区。Eden区与两块Survivor区的比例为8:1:1。这样划分的依据是什么呢?基于弱代理论,IBM研究表明新生代中98%的对象都是"朝生夕死",大多数分配了内存的对象并不会存活太长时间,在处于年轻代时就会死掉。

  在原始的复制算法中,空间一分为二,空间利用率为50%,也就是说有新生代中50%的空间会被浪费,无法分配内存。Hotspot虚拟机使用改良的复制算法,并且设置合理的空间比例,新生代中可用的内存空间为整个新生代容量的90%,只有10%的空间会被浪费,大大的提高的新生代的空间利用率。如果存活对象占用的内存大于新生代容量的10%怎么办?这就需要依赖其他内存(老年代)进行分配担保了。新生代回收动图如下:

  

  老年代

  由于老年代的对象存活周期一般相对较长,不会像新生代对象那样“朝生夕死”,所以对象存活率高是老年代的特点,并且老年代也没有额外的空间可以分配担保,所以不适合采用复制算法进行回收。根据老年代的特点,一般会使用"标记-清理"或"标记-整理"算法来进行垃圾回收。

收集器

  上面我们介绍了在JVM中常用的垃圾回收算法及每一种算法的优缺点。接下里会介绍在HotSpot虚拟机中常用的几种垃圾收集器,垃圾收集器是垃圾回收算法的具体实现,不同的商家、不同版本的JVM所提供的垃圾收集器可能会存在差异。这几种收集器分别是Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1。在了解垃圾收集器之前,我们先来区分几个概念:

  并发收集器VS并行收集器
  并行:指多条收集线程同时进行收集工作,但此时用户线程处于等待状态。如ParNew、Parallel Scavenge、Parallel Old。
  并发:指用户线程与垃圾收集线程同时执行(并不一定是并行,可能会交替执行)。如CMS、G1。

  YoungGC VS OldGC VS MinorGC VS MajorGC VS FullGC
  Minor GC、YoungGC:Minor GC又称为新生代GC,所以等价于Young GC,在新生代的Eden区分配满的时候触发。在Young GC后新生代中有部分存活对象会晋升到老年代,有可能是年龄达到阈值(默认为15岁,在JVM里面15岁就步入老年生活了,O(∩_∩)O哈哈~)了,也可能是Survivor区域满了,如果是Survivor区域被填满,会将所有新生代中存活的对象移动到老年代中!

  Major GC、Old GC、Full GC:Old GC从字面能理解是老年代的GC,但是对Major GC和Full GC存在多种说法,有的认为Major GC等价于Old GC只是针对老年代的GC,有的认为Major GC和Full GC是等价的。但是我个人认为Major是指老年代GC,而Full GC针对新生代、老年代、永久代整个的回收。由于老年代的GC都会伴随一次新生代的GC,所以习惯性的把Major GC和Full GC划上了等号。前面Young GC时候说到“在Young GC后新生代中有部分存活对象会晋升到老年代”,万一老年代的空间不够存放新生代晋升的对象怎么办呢?所以当准备要触发一次Young GC时,如果发现统计数据之前Young GC的平均晋升大小比目前老年代剩余的空间大,则不会单独触发Young GC,而是转为触发Full GC,也就是整堆的收集!

串行收集器

  串行垃圾收集器是最基本、发展历史最悠久的收集器。主要包含Serial和Serrial Old两种收集器,分别用来收集新生代和老年代。串行收集器由于是单线程收集,在进行垃圾收集时,必须暂停(Stop The World)所有的工作线程,直到GC线程工作完成。运行示意图如下:

  

  Serial 收集器:主要针对新生代回收,采用复制算法,单线程收集。
  Serial Old收集器:主要针对老年代回收,采用“标记-整理”算法,单线程收集。

  串行收集器在单CPU的环境下,没有线程切换的开销,可以获得最高的单线程收集效率,但是由于现在普遍都是多CPU(或者多核)环境,所以除了在桌面应用中仍然将串行收集器作为默认的收集器,其他场景已经很少(很少不代表没有,后面CMS会讲到)使用。

  在上面我们谈到一个词,需要暂停(Stop The World)所有的工作线程,这个概念在后面也会多次提到,为什么需要暂停呢?一是为了方便GC动作,不然在GC过程中又会额外产生新的垃圾,或者分配新的对象。二是因为GC过程中对象的地址会发生变化,如果不暂停线程,可能会导致引用出现问题。

并行收集器

  并行收集器是串行收集器的多线程版本,除了多线程外,其余的行为、特点和串行收集器一样。主要包含ParNew收集器、Parallel Scavenge收集器、Parallel Old收集器。运行示意图如下:

  

  ParNew收集器:主要针对新生代回收,采用复制算法,多线程收集。一般老年代如果使用CMS收集器,则默认会使用ParNew作为新生代收集器
  Parallel Scavenge收集器:该收集器与ParNew收集器类似,也是新生代收集器,采用复制算法,多线程收集。其他收集器关注点是尽可能地缩短垃圾收集时用户线程停顿的时间,但是Parallel Scavenge收集器的目标则是达到一个可控的吞吐量(吞吐量=CPU运行用户代码时间/(CPU运行用户代码时间+CPU垃圾收集时间)),所以该收集器也成为吞吐量收集器。由于该收集器没有使用传统的GC收集器代码框架,是另外独立实现的,所以无法和CMS收集器配合工作。
  Parallel Old收集器:主要针对老年代回收,采用“标记-整理”算法,多线程收集。该收集器是Parallel Scavenge收集器的老年代版本。在JDK1.6之后用来替代老年的Serial Old收集器。在注重吞吐量以及CPU资源敏感的场景,一般会选择Parallel Scavenge+Parallel Old的组合进行垃圾收集。

CMS收集器

  前面介绍的几种收集器都相对比较简单,也很好理解,所以也没做过多的介绍。接下来介绍的收集器相对前面几种收集器就要复杂一些,并且使用较广,所以介绍会较详细!并发标记清理(Concurrent Mark Sweep)收集器也称为并发低停顿收集器或低延迟收集器。CMS收集器采用的是“标记-清理”算法,所以不会进行压缩操作。我们先来了解一下CMS收集器的运作过程:

  

  CMS收集器运作过程

  1、初始标记(CMS initial mark)
  仅标记GC Roots能直接关联的对象,这个阶段为速度较快,但是仍然需要“Stop The World”,但是停顿时间较短!

  2、并发标记(CMS Concurrent mark)
  进行GC Roots Tracing的过程,也就是查找GC Roots能直接关联的对象所引用的内存。在这个阶段,GC线程与用户线程是同时运行的,所以并不能保证能标记出所有存活的对象。

  3、重新标记(CMS remark)
  由于并发标记阶段,用户线程在并发运行,所以可能在并发标记阶段产生新的对象,所以在重新标记阶段也会需要“Stop The World”来标记新产生的对象,且停顿时间比初始标记时间稍长,但远比并发标记短。

  4、并发清除(CMS Concurrent sweep)
  在并发清除阶段用户线程与清理线程也是同时工作,清理线程回收所有的垃圾对象!

  CMS收集器缺点

  上面了解了CMS收集器的运作过程,不知道在了解过程中你有没有发现一些问题,比如CMS收集器采用的是“标记-清除”算法,那会不会产生很多的内存碎片?比如在并发清理阶段,用户线程还在运行,会不会在清理的过程中又产生了垃圾?总结CMS收集器的几个明显的缺点如下:

  1、 对CPU资源非常敏感
  并发收集虽然不会暂停用户线程,但是因为会占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量下降。CMS的默认收集线程的数量=(CPU数量+3)/4。所以,当CPU数量大于4个时,会有超过25%的资源用于垃圾收集。当CPU数量小于或等于4个时,默认一个收集线程。

  2、 产生大量内存碎片
  CMS收集器采用“标记-清除”算法,在清除后不会进行压缩操作,这样会导致产生大量不连续的内存碎片,在分配大对象时,无法找到足够的连续内存,从而需要提前触发一次FullGC的动作。针对该问题,提供了两个参数来设置是否开启碎片整理。
  1)、“-XX:+UseCMSCompactAtFullCollection”参数
  从名字能看出来,在收集的时候是否开启压缩。这个参数默认是开启的,但是是否开启压缩还需要结合下面的参数!
  2)、“-XX:+CMSFullGCsBeforeCompaction”参数
  该参数设置执行多少次不压缩的Full GC后,来一次压缩整理。这个参数默认为0,也就是说每次都执行Full GC,不会进行压缩整理。
  如果开启了压缩,则在清理阶段需要“Stop the world”,不能进行并发!

  3、 产生浮动垃圾
  上面说到过在并发清理阶段,用户线程还在运行,这时候可能就会又有新的垃圾产生,而无法在此次GC过程中被回收,这成为浮动垃圾。

  4、 “Concurrent Mode Failure”失败
  不知道大家在开发过程中有没有遇到过“Concurrent Mode Failure”失败的信息,不管你有没有遇到过,反正我是遇到过!这个异常是什么原因导致的呢。在并发标记和并发清除阶段,用户线程与GC线程并发工作,这会导致在清理的时候又会有用户的线程在拼命的创建对象,本身垃圾回收时候肯定是可用内存不够了,可万一这时候用户线程创建了大量的对象怎么办呢?所以一般CMS收集器的垃圾回收的动作不会在完全无法分配内存的时候进行,可以通过“-XX:CMSInitiatingOccupancyFraction”参数来设置CMS预留的内存空间!如果预留的空间无法满足程序的需要,就会出现 “Concurrent Mode Failure”失败。这时候JVM会启用后备方案,也就是前面介绍过的Serial Old收集器,这样会导致另一次的Full GC的产生,这样的代价是很大的,所以CMSInitiatingOccupancyFraction这个参数设置需要根据程序合理设置

  CMS收集器应用场景

  上面介绍了CMS收集器的缺点,那它当然也有它的优点啦,比如并发收集、低停顿等等……所以CMS收集器适合与用户交互较多的场景,注重服务的响应速度,能给用户带来较好的体验!所以我们在做WEB开发的时候,经常会使用CMS收集器作为老年代的收集器!

写在结尾

  “你必须了解的java内存管理机制”这个系列文章终于完成啦!而我也要回去过年啦!写技术文章真的挺花时间的,因为很多时候自己要去查阅很多资料,还要去做验证。所以,有时候电脑面前不知不觉就是一个周末过去了,而一篇文章还没写完……新的一年希望自己还能坚持勤记录、齐分享,督促自己去学习、去进步!

正文到此结束
Loading...