目录
一、垃圾收集器们
二、CMS(Concurrent-Mark-Sweep):低延迟
2.1 什么是CMS
2.2 CMS工作流程
2.3 详细描述
2.4 CMS的优缺点
2.4.1 优点
2.4.2 弊端
2.5 CMS常用参数
三、G1(Garbage First)收集器:区域化分代式
3.1 什么是G1
3.2 G1的工作原理
3.3 G1的特点
3.4 G1的优缺点
3.4.1 优点
3.4.2 总结
3.5 G1回收器的使用场景
3.6 分区region——化整为零
3.6.1 单个Region
3.6.2 指针碰撞
3.6.3 TLAB
3.7 G1的垃圾回收过程概述
3.7.1 主要环节
3.7.2 Young GC
3.7.3 老年代并发标记
3.7.4 混合回收
3.7.5 Remember Set
3.8 年轻代GC
3.8.1 dirty card queue(脏卡表队列)
3.9 并发标记
3.10 混合回收
3.11 可选的垃圾回收过程:Full GC
3.12 G1常用的参数
四、革命性的ZGC
本文重点介绍CMS和G1,至于前面出现的其他GC就不多介绍了。只是简单地提一下:
收集的区域:
配合关系:
总结:
在JDK 1.5时期,Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS (Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-world"
CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发
标记阶段、重新标记阶段和并发清除阶段。
尽管CNS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-world”,只是尽可能地缩短暂停时间。
因为最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。
既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?答案其实很简答,因为当并发清除的时候,用compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。Mark Compact更适合“stop the world”这种场景下使用。
1)会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
2)CMS收集器对CPU资源非常敏感。在开发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
3) CMS收集器无法处理浮动垃圾。可能出现 “Concurrent Mode Failure” 失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS经无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First (G1)GC?原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。它在JDK1.7版本正式被启用,移除了Experimental的标识,是JDK 9以后的默认垃圾回收器,取代了CMS回收器以及Parallel + Parallel old组合。被oracle官方称为“全功能的垃圾收集器”。与此同时,CMS已经在JDK 9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。
G1 (Garbage-First)垃圾回收器是当今收集器技术发展的最前沿成果之一。官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。
G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。他跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给他起一个名字:垃圾优先(Garbage First) 。
G1中提供了三种垃圾回收模式: YoungGC、Mixed GC和Full GC,在不同的条件下被触发。
与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
CMS:“标记-清除”算法、内存碎片、若干次GC后进行一次碎片整理
G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
这是G1 相对于CMS 的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。G1 跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率。
G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比CMS要高。从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。 如果设置了Region数量,那么Region大小就不是固定的,但是大小肯定是2的幂次方,并且在1~32M之间;如果设置了Region大小,那么Region数量就不是固定的,但是肯定是2048附近。region块的大小可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
虽然G1还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
Region只能是Eden、Survivor、Humongous中的一种,但是它的身份不是固定的,谁来占用那么这个Region就是谁的。一个region 有可能属于Eden,Survivor或者 Old/Tenured 内存区域。但是一个region只可能属于一个角色。3.3图中的E表示该region属于Eden内存区域,s表示属于survivor内存区域,o表示属于old内存区域。图中空白的表示未使用的内存空间。G1垃圾收集器还增加了一种新的内存区域,叫做 Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。
设置H的原因:对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
单个Region使用指针碰撞的方式来放数据。allocated是已经使用的内存空间, top就是指针的位置, unallocate是没有使用的内存空间。
虽然存在分区Region,但是依然有线程独有的TLAB空间,,这样可以保证多个线程对对象修改可以并行操作。
想了解TLAB可以看看我这篇博客的5.3:JVM学习(九):堆(万字剖析)_玉面大蛟龙
G1 GC的垃圾回收过程主要包括如下三个环节:
按照 young gc -> young gc + concurrent mark-> Mixed GC顺序,进行垃圾回收。
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到survivor区间或者老年区间,也有可能是两个区间都会涉及。
当堆内存使用达到-XX:InitiatingHeapOccupancyPercent(默认45%)时,开始老年代并发标记过程。
标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了(在规定时间内挑选价值高的回收)。同时,这个老年代Region是和年轻代一起被回收的。
举个例子:一个web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
主要是要解决一个对象被不同区域引用的问题:一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代,这样明显会降低Minor GC的效率;
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描。每个Region都有一个对应的Remembered Set;每次Reference类型数据写操作时,都会产生一个写屏障(Write Barrier)暂时中断操作,然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)。如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。
上页提到的Remebered Set就是上述Reset,上页提到的Reference类型就是引用类型,其中Reset的作用是记录当前Region中哪些对象被外部引用指向,比如Old区中的对象会指向Eden区的对象,然后当我们要回收某个Region的时候,直接遍历当前Region中的所有对象就可以了,然后针对性的去找到那些指向当前对象的其他对象,最终确定当前对象是否是根可达的。如果不是,那就应该被删除,其实之前的垃圾回收器都涉及到这个问题,当进行Minor GC的时候,通过GC Roots查找的时候还需要遍历OId区的对象,毕竟Old区对象也可能会指向Eden区对象。但是G1通过Rset避免了全堆的扫描,当引用类型数据写操作时,先暂时中断,然后判断当前引用类型数据是否被其他对象所指向,如果不被指向,那就直接放在Region中就可以了;如果被其他对象指向,那么还要判断这个对象是在当前要插入的Region中,还是在其他Region中。如果在其他Region中,那就需要使用CardTable把当前引用类型数据的指向信息放在Rset中,也就是形成上面的虚线连线;如果在当前Region中,那就不需要指向了,毕竟到时候我们会进行遍历查找根可达对象,那肯定会找到的,所以这种情况也是直接放在Region中就可以了。
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收Eden区和Survivor区。
首先G1停止应用程序的执行(Stop-The-world) ,创建回收集(collection set,是指需要被回收的内存分段的集合),年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程:
1、第一阶段,扫描根。可以体现Rset作用:避免全堆扫描。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
2、第二阶段,更新RSet。作用:保证Rset中的数据准确性。处理dirty card queue(见3.8.1)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
3、第三阶段,处理RSet。作用:根可达性遍历的一部分。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
4、第四阶段,复制对象。说明:新生代使用复制算法。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被会被复制到old区中空的内存分段。如果survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
5、第五阶段,处理引用。处理Soft,weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空(见备注),GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
备注: 空Eden: Eden变成空的,那它就变成了无主Region,因此会被记录到空链表中,等待下一次被分配。
对于应用程序的引用赋值语句obiect.field=object(其中object.field=object中的第一个object代表老年代中的对象,而第二个object代表Eden区中的对象),JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet, 保证RSet实时准确的反映引用关系。
那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
Reset更新需要线程同步,所以开销会很大,因此不能实时更新,因此我们需要把引用对象被其他对象引用的关系放在一个脏卡表队列中,当年轻代回收的时候会进行STW,所以我们也正好把脏卡表队列中的值更新到Rset中,这样不仅没有涉及到开销问题,还可以保证Rset中的数据是准确的。
1.初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
2.根区域扫描(Root Region Scanning) : G1 GC扫描survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成。主要扫描哪些老年代对象是可达:毕竟我们进行young GC的时候会移动Survivor区,移动之后就找不到哪些老年代对象是可达的了。
3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
4.再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。原因:并发标记不准确
5.独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。其实是一个统计计算过程,不会涉及垃圾清理。
6. 并发清理阶段:识别并清理完全空闲的区域。
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC, 该算法并不是一个Old
GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收:-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间,则该region不会被回收。
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收的内存却很少。
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-world),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。
导致G1 Full GC的原因可能有:
前三个最常用。
ZGC的目标是在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内的低延迟。《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的工作过程可以分为4个阶段:并发标记-并发预备重分配-并发重分配-并发重映射等。
ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。
ZGC未来将成为服务端、大内存、低延迟应用的首选垃圾收集器。