关于JVM的GC算法与GC收集器

GC算法

现在的大部分虚拟机都遵循了“分代收集”的理论进行设计,主要是建立在两个假说之上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就难以消亡。

这两个假说奠定了我们垃圾收集器的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储。也正是因为有了如此划分之后,我们才有针对某个区域进行回收的回收类型和回收算法的设计。

分代收集划分

我们的在将分代收集理论具体运用到虚拟机之后,设计者一般考虑将Java堆划分为了新生代和老年代两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

我们在划分区域之后就可以针对不同的区域使用不同的收集类型,但是我们假设如果进行一次新生代区域的收集,但新生代的对象却是完全有可能被老年代所引用!所以需要为了可达性分析结果的正确性,来遍历一次老年代。但是老年代划分的区域都是比较庞大的,所以这显然会给我们的内存回收带来很大的性能负担。

所以,针对这个问题,对分代收集理论提出了第三条的经验法则:

跨代引用假说:跨代引用相对于同代引用来说仅占极少数。所以存在互相引用的两个对象,应该是同时消亡或者同时生存的。比如我们老年代的对象存活周期长,于是我们在新生代中的引用也难以消亡,在熬过多次收集之后,这个新生代的对象就晋升到了老年代,这个时候跨代引用也就消除了。

针对这个假说,我们设计一个全局的数据结构——记忆集。这个结构把老年代划分成若干个小块,标识出老年代的哪一块内存会存在跨代引用。此时如果发生了新生代的收集的时候,我们就不必要对整个老年代进行扫描,而是针对只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描,可达性分析。这比起之前直接扫描整个老年代来查看谁引用了显然划算的多!

针对分代收集,我们有以下这些专属名词:

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,又分为了:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

常见GC算法

标记-清除算法

最早出现的垃圾收集算法,算法顾名思义,分为了“标记”和“清除”两个阶段。首先首先从每个 GC Roots 出发依次标记有引用关系的对象,最后清除没有标记的对象。也可以反过来,标记没有引用关系的对象,最后清除有标记的对象。

缺点:

  • 执行效率不稳定,如果堆包含大量对象且大部分需要回收,必须进行大量标记清除,导致效率随对象数量增长而降低。
  • 存在内存空间碎片化问题,会产生大量不连续的内存碎片,导致以后需要分配大对象时因为内存不足而容易触发 Full GC。

标记-复制算法

为了解决内存碎片化的问题,我们于是提出了标记-复制算法。我们将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当使用的这块空间用完了,就将存活对象复制到另一块,再把已使用过的内存空间一次清理掉。主要用于进行新生代。虽然增加了复制的开销,但是因为大部分对象都是可回收的,所以还是可以接受的。

缺点:因为只使用一半空间,所以空间利用率不高。

不过我们的HotSpot虚拟机通过这种策略设计了新生代的内存布局,将新生代的内存分成了一个Eden区和两个Survivor区来比配这种标记-复制算法。每次分配内存只使用 Eden 和其中一块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上,然后直接清理掉 Eden 和已用过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的大小比例是 8:1,即每次新生代中可用空间为整个新生代的 90%。更具体的解释可以参考这一篇博文,点击跳转

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。如果不想浪费我们不使用的那段空间,就需要有额外空间分配担保,应对被使用内存中所有对象都存活的极端情况,所以老年代一般不使用此算法。

所以针对老年代的特性,我们提出了标记-整理算法。我们首先会让所有存活的对象都向内存空间一段移动,然后然后清理掉边界以外的内存。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。但是如果移动对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行 。但是不移动的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。

所以说,无论是使用标记-清除还是标记-整理对老年代来说都是有弊端的。但是综合起来,从整个程序的吞吐量来看,移动对象会更划算。(HotSpot虚拟机里面关注吞吐量的ParallelScavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,也是基于这两种弊端设计的。)

不过,我们还有一种混合使用方式,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

GC收集器

如果说垃圾收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。经典垃圾收集器虽然不是最先进的技术,但仍然值得我们去学习,也才能更好的与最新的收集器去对比!

Serial(串行)收集器

Serial收集器是最基础、历史最悠久的收集器。这个收集器是一个单线程收集器,只使用一个处理器或者一条收集线程去完成垃圾收集工作,而且在进行垃圾收集的时候必须暂停其他所有工作线程,直到收集结束,这个暂停其他所有线程我们称为“Stop The World”(后面都简称STW来介绍)。

收集器运行示意图如下:

STW是由虚拟机在后台自动发起和自动完成的,但是在由于停掉了其他所有的线程,对很多应用是不能接受的,也会影响用户的体验,所以我们后来开发的收集器很多都是为了降低这个停顿的时间。

Serial收集器虽然是最早出现的,但是并不代表它已经被我们淘汰了。它也有着自己独特的优点:

与其他收集器的单线程相比,就是简单而高效。

  • 对于内存受限的环境它是所有收集器中额外内存消耗最小的;
  • 对于处理器核心较少的环境,Serial 由于没有线程交互开销,可获得最高的单线程收集效率。

因为这些优点,Serial收集器对于运行在客户端模式下的虚拟机是一个很好的选择,而且它也是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

收集器运行示意图如下:

ParNew 是虚拟机在激活CMS(下面会介绍的收集器)后默认的新生代收集器,一个重要原因是除了 Serial 外只有它能与 CMS 配合。所以它也是JDK7之前的遗留系统中首选的新生代收集器!

可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。ParNew淡出了视线...

Parallel Scavenge收集器

Parallel(并行)Scavenge也是一款新生代收集器,基于标记-复制算法实现的收集器,也是能够并行的多线程收集器。

Parallel Scavenge收集器与其他多线程收集器关注点不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。而Parallel Scavenge的目标则是要达到 一个可控制的吞吐量。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,不过在老年代采用的是标记-整理算法。这个收集器主要意义也是供客户端模式下的HotSpot虚拟机使用。

收集器运行示意图:

它的用途主要是有两个:

  • 在JDK5以及之前的版本与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器发生失败时的后备预案

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。有了这个老年代收集器之后,我们的Parallel Scavenge也就有了除Serial Old以外的其他老年代收集器的搭配了。

这二者搭配之后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合。

二者搭配收集器示意图如下:

我们的JDK7,JDK8默认使用的垃圾收集器就是这个搭配组合!也是我们注重吞吐量的优先选择。

CMS收集器

上面介绍了到了关注吞吐量的垃圾收集器,下面又回到了介绍我们以获取最短回收停顿时间为目标的收集器——CMS收集器。CMS收集器是基于标记-清除算法实现的,过程相对复杂,可以分为下面四个流程:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

其中初始标记、重新标记这个两个步骤仍然需要STW。初始标记仅是标记 GC Roots 能直接关联的对象,速度很快。并发标记从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长但不需要停顿用户线程。重新标记则是为了修正并发标记期间因用户程序运作而导致标记产生变动的那部分记录。并发清除清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与用户线程并发。

收集器运行示意图:

整个过程中耗时比较长的并发标记和并发清除都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

缺点

虽然CMS是一款优秀的收集器,也是HotSpot虚拟机追求低停顿的第一次成功尝试,但是还是有三个比较明显的缺点

  • 对处理器资源敏感,并发阶段虽然不会导致用户线程暂停,但会降低吞吐量。
  • 无法处理浮动垃圾,有可能出现并发失败而导致 Full GC。(在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集
    时再清理掉。这一部分垃圾就称为“浮动垃圾”。)
  • 基于标记-清除算法,产生空间碎片。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

Garbage First收集器

Garbage First收集器(简称G1)开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。其主要目的是用来替换CMS收集器!它是一款面向服务端应用的垃圾收集器,在JDK9,宣告取代了之前默认使用的Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

我们的G1收集器将堆划分成多个大小相等的独立Region区域,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。然后跟踪各 Region 里垃圾的价值,价值即回收所获空间大小以及回收所需时间的经验值,在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值最大的 Region。这种方式保证了 G1 在有限时间内获取尽可能高的收集效率。

G1在不考虑用户线程运行过程中的动作,运作过程大致可划分为以下四个步骤:

  • 初始标记:标记 GC Roots 能直接关联到的对象,让下一阶段用户线程并发运行时能正确地在可用 Region 中分配新对象。需要 STW 但耗时很短,在 Minor GC 时同步完成。

  • 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆的对象图。耗时长但可与用户线程并发,扫描完成后要重新处理 SATB 记录的在并发时有变动的对象。

  • 最终标记:对用户线程做短暂暂停,处理并发阶段结束后仍遗留下来的少量 SATB 记录。

  • 筛选回收:对各 Region 的回收价值排序,根据用户期望停顿时间制定回收计划。必须暂停用户线程,由多条收集线程并行完成。

运行示意图如下:

可以看到除了并发标记,其他阶段也是要求STW的。它并非一味追求低延迟,而是在延迟可控的情况下获得尽可能高的吞吐量!而且可由用户指定期望停顿时间虽然是 G1 的一个强大功能,但该值不能设得太低,一般设置为100~300 ms。不然为了达到我们设置的这个低延迟的收集效果,我们每次的收集区域很小,渐渐的就赶不上了分配内存的速度了,最后因为垃圾变多而导致Full GC得不偿失!

内存分配与回收策略

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存和自动回收分配给对象的内存。下面就会介绍一下关于分配内存的一些原则等等。(后续会补充相关参数的使用和堆的情况)

对象优先在Eden区分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,然后再进行分配。

大对象直接进入

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组!

之所以大对象要在老年代中分配是因为大对象如果在新生代分配空间时,它容易导致内存明明还有不少空间时提前触发垃圾收集,以获取足够的空间来安置他们,新生代使用复制算法,当复制时候大对象也意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

长期存活的对象将进入老年代

我们之前在介绍GC算法标记-复制的时候,在新生代长期存活的对象,就会进入老年代。为做到一点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头。在新生代第一次被收集的时候,对象会从Eden区复制到Survivor区,后面会继续在两个Survivor区里面来回复制。每在Survivor区里面熬过一次收集,Age就会加1。当Age达到默认的15的时候,就会晋升到老年代!

对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判断

为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 的一半,年龄不小于该年龄的对象就可以直接进入老年代。

空间分配担保

我们的空间分配担保是指在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么这一次Minor GC可以确保是安全的!

如果条件不成立,虚拟机会查看 -XX:HandlePromotionFailure 参数设置的是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将冒险尝试一次 Minor GC,否则改成一次 FullGC。

为什么需要空间分配担保呢?

这是因为要考虑到我们的新生代是使用一个Survivor空间来作为轮换备份,在经历多次Minor GC后,存在极端情况就是内存回收后新生代中所有对象都存活。我们是需要保证老年代有足够大的空间来容纳这些Survivor区无法容纳的存活的对象,如果无法容纳就会触发Full GC!而设置允许冒险其实就是一种赌概率的方式,赌老年代可以容纳!

总结

关于后面垃圾收集器两款垃圾收集器介绍的并不是特别完整,只是大概了解。具体的细节实现特点并未深入,需要深入的可以去看下面的参考资料。

参考资料

深入理解Java虚拟机(第三版)

posted @ 2020-08-19 16:04  CryFace  阅读(293)  评论(0编辑  收藏  举报