Java的垃圾回收在JVM中有着举足轻重的地位,这篇文章将简要聊一聊Java的垃圾收集器,从远古时代的Serial到最新的ZGC,简要分析各个垃圾收集器的特点,重点关注G1和ZGC。
Serial/Serial Old
Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。
进行垃圾收集时,必须暂停其他所有工作线程,直到收集结束,GC线程均为单线程。针对新生代,serial采用复制算法;针对老年代,serial采用标记-整理算法。
ParNew
新生代收集器,需要与其他老年代收集器搭配使用。在JDK7之前,只有他与serial能与CMS收集器搭配使用,因此常常成为新生代收集器的首选。
ParNew收集器实质上是Serial收集器的多线程并行版本,在控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
Parallel Scavenge/Old
Parallel Scavenge收集器是一款新生代收集器,基于标记-复制算法,多线程收集,该收集器无法于CMS搭配使用。
Parallel Old收集器工作于老年代,基于标记-整理,多线程收集,于JDK6推出。
与关注停顿时间的收集器不同,Parallel收集器关注的目标是吞吐量,也就是处理器运行用户代码的时间和处理器消耗的总时间的比。
CMS
CMS收集器工作于老年代,基于标记-清除算法,多线程收集,于JDK5推出,这是第一款支持并发的收集器,垃圾收集线程能和用户线程同时工作。
工作过程
CMS工作过程主要分为四个步骤:
1)初始标记(CMS initial mark)
2)并发标记(CMS concurrent mark)
3)重新标记(CMS remark)
4)并发清除(CMS concurrent sweep)
其中,初始标记和重新标记这两个步骤仍然需要“Stop The World”,其他两个步骤能与用户线程同时工作。
初始标记仅仅只是标记一下GC Roots能直接关联到的对象
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。
最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
缺点
CMS的两个特点会导致其出发FULL GC
浮动垃圾的产生
在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。如果产生的浮动垃圾较多,可能会触法完全完全“Stop The World”的Full GC。
大量的空间碎片
CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。为了优化这两种情况,JVM提供两个参数用于控制老年代空间碎片的整理:
- 一个是-XX:+UseCMS-CompactAtFullCollection,该参数开启时,在不得不进行FULL GC前会开启内存碎片的整理过程;
- 另一个是-XX:CMSFullGCsBeforeCompaction,,这个参数的作用是要求CMS收集器在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。 由于内存碎片的整理需要移动存活对象,因此无法与用户线程并发执行,这会导致短暂的停顿。
G1
Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,基于Region的内存布局,面向整个Java堆进行垃圾收集,且用户可以指定自己预期的停顿时间。
基于Region的内存布局
G1之前的垃圾收集器,堆内存一般被分为新生代和老年代,年轻代又分为Eden区和两个Survivor区,不同分代和分区之间的空间大小比例通过参数指定。
G1垃圾收集器将连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
Region中有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
面向整个堆内存的垃圾收集器
在G1之前的垃圾收集器,一般只针对某个特定的分代(FULL GC时除外),要么面向新生代,要么面向老年代。虽然G1的设计也是基于分代收集的思想,但是由于使用了基于Region的内存布局,弱化了分代的概念,垃圾回收的衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。进行垃圾回收时,G1会把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间,对于新生代的region,这更像是标记-复制算法,对于存储大对象的region,这更像是标记-整理算法。
G1收集器主要有 Young GC 和 Mixed GC两种模式:
Young GC
选定所有新生代里的Region。通过控制新生代的region个数,即新生代内存大小,来控制young GC的时间开销。(复制回收算法) G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。
Mixed GC
选定所有新生代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。 Mixed GC不是full GC,它只能回收部分老年代的Region。如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。所以我们可以知道,G1是不提供full GC的。
可预测停顿时间模型
停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
可预测停顿时间模型的实现思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。
G1工作过程
G1收集器的运作过程大致可划分为以下四个步骤:
初始标记(Initial Marking)
仅仅只是标记一下GC Roots能直接关联到的对象,。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记(Concurrent Marking)
从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
最终标记(Final Marking)
对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的未标记的对象。(增量更新:CMS收集器采用增量更新算法实现,而G1 收集器则是通过原始快照(SATB)算法来实现的)
筛选回收(Live Data Counting and Evacuation)
负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的
从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的
ZGC
ZGC收集器是一款基于Region内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC内存模型
在x64硬件平台下,ZGC的Region被分为大、中、小三类容量:
-
小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
-
中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
-
大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段) 的,因为复制一个大对象的代价非常高昂。
低延迟的实现
相比于之前的垃圾回收器,ZGC有着更短的停顿时间。
G1停顿时间长的原因,在于涉及到对象的整理和移动时必须"Stop The World",因为他们未能解决对象转移过程中准确定位对象地址的问题。GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。
转移过程中准确访问对象需要解决两个问题,一个是如何判断对象是否移动的问题,另一个是及时更新对象地址的问题。
ZGC通过着色指针判断对象是否移动,通过读屏障技术实现对象地址的及时更新,解决了转移过程中准确访问对象的问题,实现了并发转移。
屏障
这里说的屏障是JVM向应用代码插入一小段代码的技术,JVM中有两种屏障,一种是读屏障(Load Barrier),另一种是写屏障(Store Barrier)。
屏障的使用与现代CPU的结构息息相关,现代CPU往往带有高速缓存,因此缓存中的值可能与主存中的值不一致。
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
ZGC使用读屏障,可以确保读取到对象的最新地址。
着色指针
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0-41位,而第42-45位存储元数据,第47-63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
两个 Mark 位表明该对象指针是否已经被标记,采用两个 Mark bit 可以在前后不同的 GC 时使用不同的 Mark bit;Remapped 位表示当前对象指针是否已经调整为搬移之后的对象指针;Finalizable 位主要是为 Finalizable 对象服务,用来表示该对象指针是否仅经 Finalize 对象标记,主要供 Mark 阶段和弱引用处理阶段使用。通过 Colored 指针,不同的 GC 阶段,当前 Runtime 的正确的指针颜色仅为一种颜色(Marked 或者 Remapped)。
ZGC工作过程
ZGC的工作过程可以抽象为4个部分:并发标记,并发预备重分配,并发重分配与并发重映射
并发标记(Concurrent Mark)
并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿。与G1、Shenandoah不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
并发预备重分配(Concurrent Prepare for Relocate)
将需要清理的region组成重分配集(Relocation Set),重分配集中存活的对象将会被复制到其他的Region中,重分配集中的Region会被释放。
并发重分配(Concurrent Relocate)
重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(SelfHealing)能力。
并发重映射(Concurrent Remap)
重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
参数
ZGC的参数大致分为三类:
- 堆大小:Xmx。ZGC 能够通过极致的低延迟满足业务高标准 SLA 的服务准入条件,但是与所有编程语言的 concurrent GC 类似,延迟是以内存空间作为 trade-off 的。当分配速率过高,超过回收速率,造成堆内存不够时,会触发 Allocation Stall,这类 Stall 会减缓当前的用户线程。因此,当我们在 GC 日志中看到 Allocation Stall,通常可以认为堆空间偏小或者 concurrent gc threads 数偏小。
- GC 触发时机:ZAllocationSpikeTolerance, ZCollectionInterval。ZAllocationSpikeTolerance 用来估算当前的堆内存分配速率,在当前剩余的堆内存下,ZAllocationSpikeTolerance 越大,估算的达到 OOM 的时间越快,ZGC 就会更早地进行触发 GC。ZCollectionInterval 用来指定 GC 发生的间隔,以秒为单位触发 GC。
- GC 线程:ParallelGCThreads, ConcGCThreads。ParallelGCThreads 是设置 STW 任务的 GC 线程数目,默认为 CPU 个数的 60%;ConcGCThreads 是并发阶段 GC 线程的数目,默认为 CPU 个数的 12.5%。增加 GC 线程数目,可以加快 GC 完成任务,减少各个阶段的时间,但也会增加 CPU 的抢占开销,可根据生产情况调整。
总结
垃圾收集器的性能优化经历了几个不同的阶段,最开始的垃圾收集器GC线程是单线程,到ParNew收集器,GC线程终于可以多线程并发执行,CMS则是第一款能与工作线程并行执行的垃圾回收器,随后的G1和ZGC,越来越多的垃圾回收过程能与工作线程并行执行,且随着互联网的兴起,垃圾回收越来越关注垃圾回收的停顿时间,低延迟成为衡量一框垃圾回收器是否优秀的重要标准。