Java 程序员Java

没学会?华为大佬梳理的这份万字JVM笔记,带你掌握7种垃圾回收器

2021-10-17  本文已影响0人  程序花生

垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。从不同角度分析垃圾收集器,可以将GC分为不同的类型。

垃圾收集器分类

按线程数分: 串行垃圾回收器和并行垃圾回收器。

按工作模式分: 并发式垃圾回收器和独占式垃圾回收器。

按碎片处理方式分: 压缩式垃圾回收器和非压缩式垃圾回收器。

按工作的内存区间分:年轻代垃圾回收器和老年代垃圾回收器。

评估GC的性能指标

吞吐量、暂停时间、内存占用这三者共同构成一个“不可能三角”。一款优秀的收集器通常最多同时满足其中的两项。这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。

简单来说,主要抓住两点:吞吐量、暂停时间。

吞吐量:

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间)

比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。

吞吐量优先,意味着在单位时间内,STW的时间最短:0.2+0.2=0.4

暂停时间:

“暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态。

例如,GC期间1ee毫秒的暂停时间意味着在这1e0毫秒期间内没有应用程序线程是活动的。暂停时间优先,意味着尽可能让单次STW的时间最短:0.1+0.1 + 0.1+ 0.1+ 0.1=0.5

吞吐量vs暂停时间

高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。

低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。

不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。

在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能专注于两个目标之一,或尝试找到一个二者的折衷。

现在标准:在最大吞吐量优先的情况下,降低停顿时间。

常见的垃圾回收器

GC垃圾收集器是和JVM一脉相承的,它是和JVM进行搭配使用,在不同的使用场景对应的收集器也是有区别。

7种经典的垃圾收集器:

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。

如何查看默认垃圾收集器:

Serial回收器:串行回收

Serial收集器是最基本、历史最悠久的垃圾收集器了,JDK1.3之前回收新生代唯一的选择。它是HotSpot中Client模式下的默认新生代垃圾收集器,采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。

除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial old收集器。Serial old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。

Serial收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。

优势: 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

限制条件:串行,单核CPU,不支持交互较强的应用。

参数设置:

-XX:+UseSerialGC :指定年轻代和老年代都使用串行收集器(等价于新生代用Serial GC,且老年代用Serial old GC)

ParNew回收器:并行回收

ParNew收集器可以看作是Serial收集器的多线程版本。Par是Parallel的缩写,New:只能处理的是新生代。ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制。

ParNew 是很多JVM运行在Server模式下新生代的默认垃圾收集器。

由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?

ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快地完成垃圾收集,提升程序的吞吐量。但是在单个CPU环境下,PerNew收集器不比Serial收集器更高效。 虽然Serial收集器时基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程中产生的一些额外开销。

参数设置:

Parallel回收器:吞吐量优先

Parallel Scavenge收集器中采用了复制算法、并行回收和"Stop the World"机制。 和ParNew收集器不同,ParallelScavenge收集器的目标是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。

自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。

高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel old收集器,用来代替老年代的Serialold收集器。

Parallel old收集器采用了标记-压缩算法,但同样也是基于并行回收和"stop-the-World"机制。

在程序吞吐量优先的应用场景中,IParallel收集器和Parallel old收集器的组合,在server模式下的内存回收性能很不错。在Java8中,默认是此垃圾收集器。

参数配置:

CMS回收器:低延迟

在JDK1.5时期,Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。(涉及STW的阶段主要是:初始标记和重新标记)

尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。 因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收, 以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。

要是CMS运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用Serial old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

CMS为什么不使用标记整理算法?

当并发清除的时候,用Compact整理内存的话,会占用原来的用户线程使用的内存(要保证用户线程能继续执行,前提的它运行的资源不受影响)。Mark Compact更适合“stop the world”这种场景下使用。

优点:并发收集、低延迟

缺点:

参数设置:

如果你想要最小化地使用内存和并行开销,请选Serial GC;

如果你想要最大化应用程序的吞吐量,请选Parallel GC;

如果你想要最小化GC的中断或停顿时间,请选CMS GC。

G1回收器:区域化分代式

随着应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

为什么名字叫 Garbage First(G1)呢?

G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

在JDK1.7版本正式启用,移除了Experimental的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel old组合。被oracle官方称为 “全功能的垃圾收集器”。

G1垃圾收集器的优点:

并行与并发:

分代收集:

空间整合:

可预测的停顿时间模型(即:软实时soft real-time):

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1垃圾收集器的缺点:

G1参数设置:

G1收集器的常见操作步骤:

G1中提供了三种垃圾回收模式:YoungGC、Mixed GC和FullGC,在不同的条件下被触发。

G1收集器的适用场景:

分区Region:化整为零

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过XX:G1HeapRegionsize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

一个region有可能属于Eden,Survivor或者old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于survivor内存区域,o表示属于0ld内存区域。图中空白地表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。

设置H的原因:

对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Fu11Gc。G1的大多数行为都把H区作为老年代的一部分来看待。

每个Region都是通过指针碰撞来分配空间。

G1垃圾回收器的回收过程

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子: 一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

Remembered Set(记忆集):

解决方法:

G1回收过程1 - 年轻代GC

然后开始如下回收过程:

第一阶段,扫描根

根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。

第二阶段,更新RSet

处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确地反映老年代对所在的内存分段中对象的引用。

第三阶段,处理RSet

识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

第四阶段,复制对象

此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到o1d区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。

第五阶段,处理引用

处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

G1回收过程2 - 并发标记过程:

初始标记阶段: 标记从根节点直接可达的对象。这个阶段是sTw的,并且会触发一次年轻代GC。

根区域扫描(Root Region Scanning): G1 Gc扫描survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在youngGC之前完成。

并发标记(Concurrent Marking): 在整个堆中进行并发标记(和应用程序并发执行),此过程可能被youngGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。 同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

再次标记(Remark): 由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。

独占清理(cleanup,STW): 计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是sTw的。这个阶段并不会实际上去做垃圾的收集

并发清理阶段: 识别并清理完全空闲的区域。

G1回收过程3 - 混合回收:

当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个old GC,除了回收整个Young Region,还会回收一部分的old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些o1d Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。

G1回收可选的过程4 - Full GC:

G1回收的优化建议:

从oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

年轻代大小:

暂停时间目标暂停时间目标不要太过严苛:

垃圾回收器总结

截止JDK1.8,一共有7款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

怎么选择垃圾回收器?

Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。怎么选择垃圾收集器?

GC日志

通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。

内存分配与垃圾回收的参数列表:

YoungGC:

FullGC:

垃圾回收器的新发展

GC仍然处于飞速发展之中,目前的默认选项G1GC在不断地进行改进,很多我们原来认为的缺点,例如串行的FullGC、Card Table扫描的低效等,都已经被大幅改进,例如,JDK10以后,FullGC已经是并行运行,在很多场景下,其表现还略优于ParallelGC的并行Ful1GC实现。

即使是SerialGC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在serverless等新的应用场景下,Serial Gc找到了新的舞台。

比较不幸的是CMSGC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除

现在G1回收器已成为默认回收器好几年了。我们还看到了引入了两个新的收集器:ZGC(JDK11出现)和Shenandoah(Open JDK12)

ShenandoahGC: (主打特点:低停顿时间)

shenandoah GC的弱项:高运行负担下的吞吐量下降。

shenandoah GC的强项:低延迟时间。

革命性的ZGC:

ZGC与shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。

ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC的工作过程可以分为4个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。

ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

虽然ZGC还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。

未来将在服务端、大内存、低延迟应用的首选垃圾收集器。

上一篇 下一篇

猜你喜欢

热点阅读