前言:我们在jvm调优时,主要目的就是让程序能够稳定的运行,减少卡顿,提升用户体验。而我们程序要都知道,在进行Full GC时,会造成STW(Stop The World)停止所有用户线程,造成卡顿。严重时会造成大量用户线程超时、失败等。调优就是根据不同场景,选择不同的垃圾收集器,并对其参数调整,来达到我们的目的,那么垃圾收集器有哪些,他的机制如何,我们又该如何选择和调优呢?
先来看看目前主流的垃圾收集器:
在说这些收集器的机制时,我们先了解一下分代理论和垃圾回收算法
现代大部分的虚拟机的垃圾回收器都遵循“分代收集”的理论基础来进行设计实现:
■ 绝大部分的对象的声明周期很短,都是朝生夕死。
■ 历经多次垃圾回收后仍然存活的对象就可能更加越难被回收。
根据这个理论基础,JVM的大部分虚拟机将堆空间逻辑划分为新生代/年轻代、老年代,垃圾收集器可以根据不同的区域选择不同的回收算法进行GC。
目前的算法有:标记-复制算法、标记-清除算法、标记-整理算法以及分代算法
如图所示:将内存分隔为两块相同大小的区域,每次只使用其中的一块,每当GC完成后,将存活的对象复制到另一块区域,然后将前一块清空。
如图所示:该算法分为标记和清除两个阶段,标记存活的对象,然后GC清除掉没有被标记的对象
这种算法虽然简单,但是有两个问题:
● 内存越大,要标记的对象越多,耗时越久,性能越低。
● 内存碎片化问题,导致大对象(数组)分配困难。
它的大体流程和标记清除法一致,但是它具有整理内存空间的功能。这种算法避免了内存碎片化的问题,但是效率上相对于标记-清除法略低。
根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用标记-复制算法,老年代采用标记整理算法。
接下来再说说不同的垃圾收集器:
Serial针对年轻代、Serial Old针对老年代,但是他俩有个特点就是单线程。流程很简单,但是内存越大,他俩的性能就越差,适合几十到几百兆的堆内存使用,这点内存玩蛇皮啊,所以现在基本不用。他的流程大概是这个样子:
这两个收集器跟上面的区别就在于GC过程采用多线程模式。
同时线程数可配置,默认是CPU核心数,可以通过-XX:ParallelGCThreads配置;
也可以控制GC暂停的最大时间,通过-XX:MaxGCPauseMillis调节,但吞吐量会降低,因为GC更频繁,不宜设置太低。他们的流程如图:
ParNew和Parallel Scavenge很相似,都是并发执行GC工作,但是ParNew只能负责新生代的GC任务
了解了上面几款垃圾收集器,我们通过流程图也可以看到,无论是那种算法,在进行垃圾回收时,都会有较长时间的STW,这种给用户带来的体验是非常差的。当然我们也可以尽可能让对象都在新生代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收,这样来减少Full GC。但是总会有不可避免的时候,所以垃圾收集器也在升级,针对STW进行优化!
Concurrent Mark Sweep简称CMS,这是一款追求低延时的收集器,也是第一款真正意义上的并发垃圾收集器,它实现了GC线程和用户线程同时工作的能力,极大提高了JVM垃圾收集的亲和力;但是CMS也是唯一一款采用标记-清除算法的老年代收集器。
我们先来看看他的流程:
1.初始标记:这个阶段将GC Roots作为根节点扫码与之直连的对象,速度较快耗时很短,但是会暂停用户线程STW。
2.并发标记:该阶段从GC Roots直连的对象作为开始,通过可达性分析算法扫码整个堆,这个过程很长,但是可以和用户线程并发执行,所以没有STW。但是并发标记阶段会存在多标、漏标的问题。
3.重新标记:就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对
象的标记记录,该过程会STW,暂停时间比初始标记略长,但远远比并发标记时间段短。这个阶段主要会用到三色标记里的增量更新算法做重新标记,
4.并发清理:这个阶段将未被标记的对象进行垃圾清理,如果这个阶段还有新对象产生,则会标记为黑色,不会处理。
5.并发重置:该阶段重置本次GC过程中标记的数据。
这个流程中有几个点,可能有部分同学不太理解,我们先解释一下;
首先说说什么是三色标记:
三色标记法是可达性分析算法中,垃圾收集器在并发标记阶段为了提高效率和解决一些漏标问题提出的一种扫描标记的实现手段,三色标记法将对象分为3类:黑色、灰色、白色。
黑色:标识对象已经被收集器访问过,并且该对象引用的对象都已经被扫描,黑色对象是安全存活的。
灰色:标识对象已经被收集器访问过,但是该对象引用的对象还未完全扫描完成。
白色:标识对象还未被扫描过,GC刚开始阶段所有对象都是白色,在可达分析之后如果对象仍然是白色,则会被当做垃圾回收。
接下来我们思考这个流程中,我们是有用户线程的,那方法结束了,或者方法执行过程中,对象的引用发生了变化了会不会有问题呢?确实是有的;
1.多标问题
如果对象原来经过扫描变成了黑色不会被回收,但是在并发标记和并发清理阶段将对象的引用断开,此时C对象已经是垃圾,这些垃圾我们就称之为浮动垃圾,但是它是黑色的无法被回收;但是这种问题JVM能够容忍,因为对程序并不会造成问题,只是占用了一小部分的内存空间,等下一次GC就会回收这些浮动垃圾。
2 漏标问题
如果原本是正常的对象结果由于引用关系的断开没有被标记成功,后来又有引用关联,那么这个白色对象D就会被误删除,那这个不就是BUG了么?当然为了解决这个BUG,JVM提供了2套方案:增量更新、SATB(原始快照)技术来解决漏标问题,CMS采用了写屏障+增量更新
啥?什么增量更新、SATB?写屏障又是咋回事?解释一下:
增量更新:是指当有黑色对象指向新的白色对象时,将新的引用记录存下来,等扫描完成后将这些记录引用的黑色节点为根重新扫描一遍。
原始快照 SATB:当某个灰色对象指向白色对象的引用断开时,将这个引用记录存下来,等扫描完成后将记录中的灰色对象再扫描一遍
写屏障:简单理解就是JVM层面对引用类型字段复制时后的AOP切面,就是说在引用类型赋值之前和之后都会触发特定的代码。在复制之前执行指令叫写前屏障,复制之后的叫写后屏障。
● 和用户线程共同执行,真正的并发、低延迟
● 浮动垃圾问题无法处理,只有等下一次GC回收,针对浮动垃圾CMS老年代也预留了一小部分空间,大概8%左右(这个阈值可以调),所以CMS的老年代只有92%的可用率。
● 内存碎片化问题,这是标记-清除算法的问题,CMS可以通过-XX:+UseCMSCompactAtFullCollection命令来执行空间整理。
● CMS适合几个G~一二十G的堆回收场景。
附上CMS的相关核心参数:
了解了这几款垃圾收集器,目前我们日常项目已经可以轻松玩转了,但是要追求更大的内存、更短的STW,垃圾收集器也一直在升级,比如接下来的G1收集器,以及还有比他更加牛B的ZGC!
G1收集器是JVM一款革命性的垃圾收集器,它打破了传统的物理内存分代的概念,将堆内存化整为零,分为多个相同大小的区域Region,每一个区域扮演者不同的分代角色,并且区域的角色会跟随GC而改变,每一种角色区域都有不同的策略去回收。目的是进一步减少大内存GC的STW时间,JVM可以通过-XX:UseG1GC来开启G1。
G1规定,堆内存最多可分为2048个Region,可以通过-XX:G1HeapRegionSize调整region大小。
新生代默认占有总内存的5%,可以随着程序运行动态变化,但最多不会超过总内存的60%,伊甸园区和幸存区的比例仍然遵守8:1:1的默认原则。
Humongous表示大对象的存放区域,一个对象的大小超过了Region的50%就认为是大对象,如果对象过于庞大,则使用连续的Humongous区域存放。
初始标记阶段短暂暂停用户线程,记录GC Roots直连的对象,这个阶段STW短暂。
并发标记阶段和用户线程并发执行,和CMS一样,将GC Roots直连的对象最为根节点通过可达性算法和三界标记手段来进行并发标记,此过程较长,但是没有STW,和CMS一样存在多标和漏标问题。
最终标记阶段和CMS一样,处理并发标记阶段导致引用链变化的情况,此阶段会短暂STW,但是漏标的问题和CMS处理手段不一样,CMS使用的是增量更新,G1使用的是原始快照。
筛选回收阶段是对各个Region区域做回收的成本分析,根据用户设置的期望GC停顿时间-XX:MaxGCPauseMillis(默认200ms)来制定回收计划。
举个例子:比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)比如一个Region,存活的对象较多,花200ms才能回收10M垃圾,另外一个Region存活对象较少,花50ms就能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率
不管是年轻代或是老年代,G1的回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。
YoungGC
G1的YoungGC并不是Eden区满了就回收,而是会评估现在回收Eden的所有Region成本和用户设定的停顿时间的比值,如果时间充裕便不会进行GC,而是开辟新的空闲区域作为Eden。
MixedGC
MixedGC并不是FullGC,当老年代所有的Region总和超过了设定的堆内存的阈值,则回收所有的Young区Region和一部分Ola区Region,采用复制算法,将存活的对象拷贝到临近的Region去,如果过程中没有足够的空闲Region则触发一次G1的FullGC
FullGC
类似于CMS的并发回收失败,G1会暂停用户线程,采用单线程的Serial模式来进行标记清理。
● -XX:+UseG1GC:使用G1收集器
● -XX:G1HeapRegionSize 指定Region的大小,1MB~32MB,必须是2的幂次方。
● -XX:MaxGCPauseMillis 指定最大的GC暂停时间。
● -XX:G1NewSizePercnet 指定新生代占比,默认是5%。
● -XX:G1MaxNewSizePercent:新生代内存最大空间
● -XX:InitiatingHeapOccupancyPercent 指定老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合
收集(MixedGC)
● G1适合>8G堆内存的JVM,<8G有时候反而不如CMS+ParNew组合,因为复杂的底层。
● G1适合对象分配和晋升速度快的场景。
● G1适合50%以上的堆被存活对象占用的场景
● G1适合对STW要求高的场景。