java 垃圾回收之垃圾回收器篇
一,垃圾回收器的分类与性能指标
GC分类
按线程数分(垃圾回收线程数 :可以分为串行垃圾回收器和并行垃圾回收器
串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
按并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制(用户线程暂停)。
按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
独占式垃圾回收器(Stop the World)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束
按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片,分配对象空间使用指针碰撞
非压缩式垃圾回收器垃圾回收完成后,会产生内存碎片,此时需要维护一个空闲列表来记录这么内存碎片
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。
年轻代垃圾回收器对年轻代进行垃圾回收
老年代垃圾回收器对老年代进行回收
GC性能指标
1.吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间);虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%;
因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。
2.暂停时间:是指一个时间段内应用程序线程暂停,让GC线程执行的状态
暂停时间优先,意味着尽可能让单次STW的时间最短;
低暂停时间(低延迟)较好,因为从最终用户的角度来看,不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有较低的暂停时间是非常重要的,特别是对于一个交互式应用程序。
如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
3.内存占用:Java堆区所占的内存大小。
4.垃圾收集开销:垃圾收集所用时间与总运行时间的比例。
5.收集频率:相对于应用程序的执行,收集操作发生的频率。
一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。现在标准:在最大吞吐量优先的情况下,降低停顿时间
二,垃圾回收器
说一下 JVM 有哪些垃圾回收器?
串行回收器:Serial、Serial old
并行回收器:ParNew、Parallel Scavenge、Parallel old
并发回收器:CMS、G1
如何选择垃圾回收器:针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
如何查看默认垃圾收集器: -XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
串行垃圾回收器(Serial、Serial old)
Serial GC(年轻代 Client模式):复制算法、串行回收、"Stop-the-World"机制
Serial Old GC(老年代 Client模式):标记-压缩算法、串行回收和"Stop the World"机制;作为老年代CMS收集器的后备垃圾收集方案;与新生代的Parallel Scavenge配合使用
执行过程如图:
它只会使用一个CPU或一条收集线程去完成垃圾收集工作,限定单个CPU环境:省去切换线程的开销;
在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(STW)
-XX:+UseSerialGC:指定年轻代和老年代都使用串行收集器
串行垃圾回收器现在开发中基本很少使用了,都是多核 cpu
并行垃圾回收器(ParNew 回收器)
ParNew :年轻代(Parallel New)复制、并行、STW
区别Serial GC:是否并行
新生代:回收次数频繁,使用并行方式高效
适用范围: ParNew收集器运行在多CPU的环境下,可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。老年代可以搭配 Serial oldGC 或者 CMS GC
执行过程如图:
设置 ParNew 垃圾回收器:
-XX:+UseParNewGC:指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代
-XX:ParallelGCThreads:限制线程数量,默认开启和CPU相同线程数
并行垃圾回收器(Parallel Scavenge GC 和Parallel Old GC)
Parallel Scavenge 回收器:吞吐量优先、复制算法,并行执行,STW、Java8默认的回收器
Parallel Old 回收器:吞吐量优先、标记-压缩算法,并行执行,STW、Java8默认的回收器
区别ParNew: Parallel Scavenge收集器的目标:达到一个可控制的吞吐量; 自适应调节策略
适用范围: 吞吐量可以高效率利用CPU时间,尽快完成程序的运算任务,主要适用于后台运算而不需要太多交互任务。因此,常见在服务器环境中使用。例如,执行批量处理、订单处理、工资支付、科学计算的应用程序。
执行过程:
Parallel Scavenge 回收器参数设置:
-
-XX:+UseParallelGC :指定年轻代使用Parallel并行收集器执行内存回收任务
-
-XX:+UseParallelOldGC:指定老年代使用并行回收收集器
-
-XX:ParallelGCThreads:设置年轻代并行收集器的线程数
默认情况下,当CPU数量小于8个,ParallelGCThreads的值等于CPU数量
当CPU数量大于8个,ParallelGCThreads的值等于3+[5*CPU_Count]/8]
-
-XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间(即STW的时间)单位:毫秒
为了尽可能地把停顿时间控制在XX:MaxGCPauseMillis 以内,收集器在工作时会调整Java堆大小或者其他一些参数
-
-XX:GCTimeRatio:垃圾收集时间占总时间的比例,用于衡量吞吐量的大小
-
-XX:+UseAdaptiveSizePolicy :设置Parallel Scavenge收集器具有自适应调节策略
在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,来达到在堆大小、吞吐量和停顿时间之间的平衡点。
在手动调优比较困难的场合,可以直接使用这种自适应方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMillis),让虚拟机自己完成调优工作
并发垃圾回收器(CMS 回收器(Concurrent-Mark-Sweep)
CMS :使用标记-清除算法、并发、低延迟(STW)、老年代
第一次实现了让垃圾收集线程与用户线程同时工作
匹配:ParNew、Serial GC 无法匹配:Parallel Scavenge(底层所用框架 不同)
执行过程如图:
初始标记(Initial-Mark)阶段:会STW ,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
并发标记(Concurrent-Mark)阶段: 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
重新标记(Remark)阶段: 由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,并且也会导致“Stop-the-World”的发生,但也远比并发标记阶段的时间短
并发清除(Concurrent-Sweep)阶段: 此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
CMS 优点分析:
- 最耗时:并发标记、并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的
- 并发收集的
CMS 弊端分析:
-
内存碎片–>分配大对象–>提前触发 Full GC
内存碎片需要额外维护空闲列表
-
占用一部分线程–>导致应用程序变慢,总吞吐量降低
-
无法处理浮动垃圾–>Concurrent Mode Failure–>Full GC
在并发标记(并发清理)阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
-
CMS运行期间预留的内存无法满足程序需求:出现“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial old收集器来重新进行老年代的垃圾收集,这样停顿时间更长
CMS 参数配置:
-
-XX:+UseConcMarkSweepGC:手动指定使用CMS收集器执行内存回收任务。
开启该参数后会自动将-XX:+UseParNewGC打开。即:ParNew(Young区)+CMS(Old区)+Serial Old(Old区备选方案)的组合。
-
-XX:CMSInitiatingOccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。
反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数。
-
-XX:+UseCMSCompactAtFullCollection:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
-
-XX:CMSFullGCsBeforeCompaction:设置执行多少次Full GC后对内存空间进行压缩整理
-
-XX:ParallelCMSThreads:设置CMS的线程数
并发垃圾回收器(G1 GC)
G1 回收器:区域化分代式(Garbage-First)复制算法、并行并发兼具、面向服务端.回收整个堆区
G1目标:在延迟可控的情况下获得尽可能高的吞吐量
G1的分代收集
仍然有新生代,老年 代,Eden S0,S1等的 概念
使用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表示属于Old内存区域。图中空白的表示未使用的内存空间。
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过0.5个Region,就放到H。
设置 H 的原因:
对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。
为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。
如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
G1执行过程:
G1 GC的垃圾回收过程主要包括如下三个环节:年轻代 GC ,老年代并发标记过程,混合回收
在之前的先了解Remember set
Remember set
存在问题:一个对象被不同区域引用的问题,
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题(而G1更突出,因为G1主要针对大堆)
回收新生代也不得不同时扫描老年代?这样的话会降低Minor GC的效率
解决方法:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
每个Region都有一个对应的Remembered Set
每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏
Remembered Set 记录了当前 Region 中的对象被哪个对象引用了
这样在进行 Region 复制时,就不要扫描整个堆,只需要去 Remembered Set 里面找到引用了当前 Region 的对象
Region 复制完毕后,修改 Remembered Set 中对象的引用即可
年轻代GC:
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
YGC时,首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
-
第一阶段,扫描根
根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等
根引用连同RSet(Remember Set)记录的外部引用作为扫描存活对象的入口
-
第二阶段,更新RSet
处理dirty card queue 中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
- 第三阶段,处理RSet
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
- 第四阶段,复制对象
此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象
如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。
如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
- 第五阶段,处理引用
处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
对于应用程序的引用赋值语句 oldObject.field=new Object(),JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。
在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
老年代并发标记过程(Concurrent Marking):
-
初始标记阶段
标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC
正是由于该阶段时STW的,所以我们只扫描根节点可达的对象,以节省时间
-
根区域扫描(Root Region Scanning)
G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象
这一过程必须在Young GC之前完成,因Young GC会使用复制算法对Survivor区进行GC
- 并发标记(Concurrent Marking)
在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断
在并发标记阶段,若发现区域对象(Region)中的所有对象都是垃圾,那这个区域会被立即回收
同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)
- 重新标记(Remark)
由于应用程序持续进行,需要修正上一次的标记结果。STW
G1中采用了比CMS更快的初始快照算法:Snapshot-At-The-Beginning(SATB)
- 独占清理(cleanup,STW)
计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域
为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集
- 并发清理阶段
识别并清理完全空闲的区域
混合回收:
当越来越多的对象晋升到老年代Old Region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region
这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
混合回收细节:
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算出来
默认情况下,这些老年代的内存分段会分8份(可以通过-XX:G1MixedGCCountTarget设置)被回收
混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收
XX:G1MixedGCLiveThresholdPercent,默认65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但回收到的内存却很少
G1大致的回收流程:
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。
在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。标记完成马上开始混合回收过程。
对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。
和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
G1 参数配置:
-
-XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务
-
-XX:G1HeapRegionSize:设置每个Region的大小
值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000
-
-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标,默认值:200ms
-
-XX:+ParallelGCThread:设置STW工作线程数。最多设置为8
-
-XX:ConcGCThreads:设置并发标记的线程数
将n设置为并行垃圾回收线程数(ParallelGcThreads)的1/4左右
-
-XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值
超过此值,触发GC。默认值:45
G1 收集器的常见操作步骤:
第一步:开启G1垃圾收集器
第二步:设置堆的最大内存
第三步:设置最大的停顿时间;
其他参数设置基本不怎么变化
G1的优点:
1.并发与并行兼具
并行:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
并发:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
2.分代收集
3.可预测的停顿时间模型
设定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率
4.空间整合
Region 与Region 之间采用复制算法
但整体内存上是 标记-压缩算法,俩种算法都可以避免内存碎片
G1收集器的适用场景
面向服务端应用,针对具有大内存、多处理器的机器
要低GC延迟,并具有大堆的应用程序提供解决方案;在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器均使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程