高亮的杂货铺

JVM从0到1-经典垃圾回收器

JVM从0到1-经典垃圾回收器
2020-03-15 · 9 min read
JVM Java

封面图为七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用

Serial收集器【新生代】


图为ParNew/Serial Old收集器运行示意图
最老的单线程收集器,使用标记-复制算法,会Stop The World,但他目前仍然是HotSpot在客户端模式下的默认新生代收集器。在客户端模式下是一个不错的选择。

Serial Old收集器 【老年代】

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

ParNew收集器【新生代】


图为ParNew/Serial Old收集器运行示意图
除了支持多线程并行收集之外,并没有太多创新之处。 这个收集器可以和CMS收集器一起工作,后来ParNew合并入了CMS

Parallel Scavenge收集器【新生代】

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是
能够并行收集的多线程收集器。 相对于ParNew收集器,特点是他的目标不是尽可能的缩短垃圾收集时用户线程的停顿时间,而是达到一个可控制的吞吐比,所谓吞吐比就是处理器用于运行用户代码与处理器总消耗时间的比值。 主要适合用于在后台运算而不需要太多交互的分析任务。

Parallel Old收集器【老年代】

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

CMS收集器(Concurrent Mark Sweep)【老年代】


CMS收集器的目标是获取最短回收停顿时间。 从名字就可以看出采用的是标记清除算法。
他的过程包括四部

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

其中,并发标记重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

CMS收集器无法处理"浮动垃圾“,即在并发标记和并发清理阶段,产生的新的垃圾对象。

同样,也正是因为没法处理”浮动垃圾“,CMS就不能等待到老年代完全被填满了再进行垃圾回收,必须要预留一部分空间给用户线程使用。在JDK5中,当老年代使用了68%的空间后,到了JDK6,被调整到了92%,如果在运行时发生了无法分配内存给用户线程的情况,则不得不启动后背预案,冻结用户线程的执行,临时启用Serial Old处理器来进行老年代的垃圾收集,但是这样的话,停顿的时间就很常了。

此外,由于采用的是标记-清除的算法,还会导致大量空间碎片的产生。空间碎片过多时,就有可能导致大对象无法进行分配,从而导致不能不提前进行一次 Full GC。 为了解决这个问题,CMS提供了一个参数,可以在被迫Full GC的时候,开启内存碎片的合并整理。

Garbage First收集器


G1收集器基于”停顿时间模型“的收集器,可以支持指定一个长度为M毫秒的时间片对内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的支持。

G1收集器属于Mixed GC模式,它可以面向堆中的任意一个部分来组成回收集(Collection Set)。 衡量标准不再是它属于哪个分代,而是在哪块内存中存放的垃圾数量最多,回收收益组大。

G1也仍然遵循分代收集理论,但其不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。 收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象,还是已经存活了一段时间,熬过多次收集的旧对象,都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过了一个Region容量一般的对象,就应该被视为大对象。 每个Region的大小可以通过参数进行设定。 如果对象大小超过了一个Region,则会被存放在N个连续的Humongous Region中。 G1把Humangous Regions视作老年代的一部分来看带。

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

G1收集器如何解决跨Region引用对象?

通过卡表来实现。每个Region都维护自己的记忆集。Region的记忆集相对较为复杂,因为他要存储的并不只有老年代,而是其他所有的Region,所以他是一个hash结构,key为其他Region的起始地址,value是一个结合,存储的是卡表的索引号。即需要同时记录我指向了谁,又要记录谁指向了我。 同样,这种卡表也到只了G1收集器有着更高的内存负担。根据经验,G1至少要消耗大约Java堆10%到20%的额外内存来维持收集器工作。

在并发标记阶段,如何保证收集器线程与用户线程互不干扰的运行?

一是要保证不能打破原来的对象图结构,这在上一篇中说过,其中CMS采用的是增量更新算法实现,而G1收集器使用的是原始快照(SATB)算法来实现的。 此外,对于用户线程新创建对象的内存分配,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针范围内。 G1默认在这两个地址以上的对象是被默认标记过的,即默认他们是存活的,不纳入回收范围。

和CMS类似,如果创建的新对象没有空间分配,也会导致G1收集器强迫冻结用户线程执行,导致Full GC而产生长时间的”Stop The World“

G1收集器的运作大致可以分为四个步骤

  1. 初始标记: 标记GC Roots能够直接关联的对象,并修改TAMS指针的值,让下一个阶段并发运行时,能够在可用的Region内分配新对象。
  2. 并发标记:从GC Roots出发,对堆中的对象进行可达性分析,递归扫描整个堆里的对下个图,并找出要回收的对象,这个阶段耗时较长,但是可以与用户程序并发执行。 当对象图扫描完成后,还需要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记:对用户线程做一个短暂的暂停,用户处理并发标记结束后仍然遗留的那一部分SATB记录。
  4. 筛选回收:负责更新Region的统计数据,堆各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后决定回收的那一部分Region的存活对象复制到空的Region里,再清理掉整个旧的Region的全部空间,这里涉及到存活对象的移动,是必须暂停用户线程的。

G1从整体上来看,是基于标记整理算法的,但是从局部上来看,是基于标记复制算法来实现的。