13.G1垃圾收集器
G1收集器是一款面向服务器的垃圾收集器,也是HotSpot在JVM上力推的垃圾收集器,并赋予取代CMS的使命。为什么对G1收集器给予如此高的期望呢?既然对G1收集器寄予了如此高的期望,那么他一定是有其特别之处。他和其他的垃圾收集器有何不同呢?下面我们将从以下几个方面研究G1收集器。
一、 为什么会诞生G1收集器?
我们知道一个新事物的诞生并且能够取代旧事物,那他一定具备了旧事物所不具备的优点。在G1之前,我们使用的是Serial、Parller、ParNew、CMS垃圾收集器,那么这些收集器有什么特点呢?
- 分代收集:整个堆空间分为新生代和老年代,新生代又分为Eden区,Survivor区。
- 垃圾收集触发机制:新生代垃圾收集触发机制是在新生代快要满的时候,触发垃圾回收;老年代也是如此,在老年代空间快要满的时候触发垃圾回收。
- 垃圾回收面临的问题:Stop The World,这是所有垃圾回收面临的严峻问题,因为Stop The World,很可能会影响用户体验。所以,CMS在Stop The World上面大做文章,让耗时短的初始标记和重新标记Stop The World,而耗时长的并发标记,并发清除和用户线程并发执行,以减少用户感知。
- 分代收集:放开思路,垃圾收集器一定都要分代么,分为年轻代和老年代?分代是为了方便收集,缺点是如果分配不合理,可能会浪费内存,频繁触发垃圾回收。那么能不能不分代呢?
- 垃圾收集触发机制:一定要等到内存快满的时候才收集么?之前这么设置的原因是为了减少垃圾收集的次数,降低对用户体验的影响。原因是垃圾回收的时候回Stop The World。如果能像CMS一样,不STW,是不是就会减少对用户的影响,或者STW的时间非常短,短到用户根本无法感知,如果这样的话,是不是就可以频繁触发垃圾回收了?不用等到达到极限的时候才触发了?
- 用户体验:这也是终极问题,如何才能对用户的影响最小,运行效率最高呢?
二、 GC收集器的三个考量指标 GC收集器的三个考量指标:
- 占用的内存(Capacity)
- 延迟(Latency)
- 吞吐量(Throughput)
我们都知道随着计算机的发展,硬件的成本是越来越低了,计算机的内存越来越大,原来最怕的就是GC的过程中占用过多的内存资源,现在在大内存的时代也都能容忍了。
吞吐量如何解决呢?我们现在使用的都是分布式系统,可以通过扩容的方式来解决吞吐量的问题。
随着JVM中内存的增大,垃圾回收的间隔变得更长,然后回收一次垃圾耗时也越来越多,现在STW的时间问题就是JVM需要迫切解决的问题,如果还是按照传统的分代模型,使用传统的垃圾收集器,那么STW的时间将会越来越长。在传统的垃圾收集器中,SWT的时间是无法预估的,那么有没有办法能够控制垃圾收集的时间呢?这样我们可以将垃圾收集的时间设置的足够短,让用户无感知,然后增加垃圾回收的次数。
G1就做了这样一件事,它不要求每次都把垃圾清理的干干净净,它只是每次根据设置的垃圾收集的时间来收集有限的垃圾,其他的垃圾留到下一次收集。
我们对G1的要求是:在任意1秒的时间内,停顿不得超过10ms,这就是在给它制定KPI。G1会尽量达成这个目标,它能够反向推算出本次要收集的大体区域,以增量的方式完成收集。
【13.G1垃圾收集器】因此,G1垃圾回收器(
-XX:+UseG1GC
)不得不设置的一个参数是:-XX:MaxGCPauseMillis=10三、 G1垃圾收集器设计原理
- G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
- G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
- G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
- G1的收集器对年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。一个Region之前可能是新生代,在垃圾回收以后,这块空间可能就变成老年代了。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
文章图片
Region有五种状态:
- 存放Eden区对象,
- 存放Survivor对象
- 存放Old对象
- 还有一类特殊的Humongous对象,专门用来存储大对象。什么事大对象呢?G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的进行回收大多数情况下都把Humongous Region作为老年代的一部分来进行回收。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配 大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中,这样可以节约老年代的空间,避免因为老年代空间不够的GC开销。
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个 Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多 的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100 个,s1对应100个。
五、 G1收集器的运行原理 我们之前详细研究过CMS垃圾收集器,G1的收集过程一部分和CMS差不多,下面来看看G1收集器的运行步骤:
文章图片
1.初始标记(initial mark,STW)
这一部分和CMS的一样,会Stop The World。
初始标记只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程(STW)。
这里有一个词“直接关联”很重要,也就是我们只标记根节点GC Roots。以下面的代码为例说明:
public class Math {
public static int initData = https://www.it610.com/article/666;
public static User user = new User();
public User user1;
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}public static void main(String[] args) {
Math math = new Math();
math.compute();
Class extends Math> mathClass = math.getClass();
new Thread().start();
}
}
看main方法,在堆中创建了一块空间new Math(), 栈中有一个变量指向了堆空间的地址,这里math是一个根节点。初始标记的时候只会标记math这样的根节点。new Math()里面有一个成员变量User user1,这个变量不会被标记,因为他不是根节点GC Root。也就是说,在初始标记的时候只会标记GC Root,对象里面的非GC Root不会被标记。
所以,这个过程是很快的。一个应用程序的根节点是有限的,没有多少,所以标记的速度也很快。
为什么初始标记要STW呢?因为如果不STW,那么用户线程会不停的创建新的对象,这样就标记不完了。
2.并发标记(Concurrent Marking)
和CMS的并发标记类似。
并发标记进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
还是用Math类来说,在初始标记的时候,只标记GC Roots,接下来并发标记标记的是GC Root下面的其他对象,比如user1对象。相比于根节点来说,非根节点会更多,因此这个过程也会很慢。
这是可能会有各种情况发生,比如初始标记的时候被标记为垃圾的对象,通过并发标记不是垃圾了;也可能最开始的时候不是垃圾,但是经过并发标记后变成垃圾了。
当对象扫描完成以后,并发时引用变动的对象可能会产生多标和漏标的问题,多标不用处理,下次GC会重新标记。漏标的问题,G1中会使用SATB(snapshot-at-the-beginning)算法来解决。
3.最终标记(Remark,STW)
和CMS的重新标记类似。有所不同的是,CMS在标记的时候使用的是增量标记,G1使用的是原始快照。
并发把所有的对象都标记完了。但是有些情况对象的垃圾状态发生了变化,原来的垃圾对象现在不是垃圾了,或者原来的非垃圾对象后来变成垃圾了,这时就需要重新标记。重新标记就是为了修复在并发标记中,状态已经改变的对象。比如:处理并发标记阶段仍遗留下来的最后那少量的SATB记录(漏标对象)。仍然需要暂停所有的工作线程。
4.筛选回收(Cleanup,STW)
? 这个过程是和CMS不同的,CMS并发清理是和用户线程并发执行的。而G1的筛选回收是Stop The World的。
? 为什么会选择Stop The World呢?这时因为在筛选回收阶段首先会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
? 比如说老年代此时有1000个 Region都满了,但是我们设置了预期停顿时间-XX:MaxGCPauseMillis=200ms,本次垃圾回收可能只能停顿200毫秒。如果要对1000个Region全部进行垃圾回收,需要超过200ms,那么这时通过之前回收成本计算得出,回收其中800个Region刚好需要200ms,那么这次就只会回收800个Region(Collection Set,要回收的集合),尽量保证GC导致的停顿时间控制在我们指定的范围内,保证不影响用户的体验。多出来的200个Regin怎么办呢?下次GC的时候再回收。
? 这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
六、 G1回收的过程 其实G1回收的过程中使用的是“复制”算法。比如老年代有一块空间要回收了,他是怎么做的呢?之前在标记阶段,我们在老年代标记了很多非垃圾对象,把这些非垃圾对象复制到相邻的还没有被占用的空里去。然后把原来那块空间直接个清理掉。这是G1底层挪动对象的算法。那么G1和CMS有什么区别呢?CMS底层使用的是“标记-清除”,而G1底层使用的是“标记-复制”,他们的区别是---碎片。复制最好的一个地方就是复制过去以后会产生很少的内存碎片。虽然G1底层使用的是复制算法,但最终达到的效果和“标记-整理”是一样的。
问题:以下面这个图为例,老年代有6块Region需要被回收,但是根据设置的收集时间,只能回收3块。那么应该回收哪3块呢?
文章图片
G1收集器会按照回收收益比去选择。有一定的算法,按照算法计算选择的。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内尽可能多的回收垃圾。
整个过程哪一块最耗时呢?复制的过程最耗时,Region块中的对象越多,越耗时,垃圾回收的效益比就越低。清理原来的空间是很快的。还是上面的案例, 有一块Region空间只有一个对象需要复制,另一块Region空间,有50个对象需要复制,在选择的时候,垃圾收集器会优先选择复制只有一个对象的空间。这样耗时少,腾出的空间却很大。
不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样 回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶 段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并 发收集,Shenandoah可以看成是G1的升级版本)
七、 G1垃圾收集器的分类 G1垃圾收集分为三种:一种是YoungGC,一种是MixedGC,另一种是Full GC
1.YoungGC
? YoungGC就是MinorGC,原来的垃圾收集器都是Eden区放满了就出MinorGC,但是G1有所不同。之前说过,新生代占整个内存的5%,Eden区和Survivor的比例是8:1:1,不到5%。假如这些空间全部都放满了,会怎么样呢?是不是就立刻触发MinorGC了呢?不是的。那么何时触发minorGC呢?触发MinorGC的时间和-XX:MaxGCPauseMillis参数的值有关系。假如-XX:MaxGCPauseMillis=200ms,G1会计算回收这5%的空间耗时是不是接近200ms,如果是,那么就会触发MinorGC。如果不是,假如只有50ms,远远低于200ms,那么就不会触发MinorGC,他会把新的对象放到新的没有被占用的Region区中。直到Eden园区回收的时间接近200ms了,这时才会触发MinorGC。这就是刚开始新生代设置的空间是5%,但是在实际运行的过程中,很可能会超过5%,最大不能超过60%,60%是默认值,这个值可以通过“-XX:G1MaxNewSizePercent”参数调整。
文章图片
? 也就是说,YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC 。
? G1非常的重视最大停顿时间,所以会非常重视回收效益比,所以G1的性能是比较高的, 性能高带来的后果就是,G1底层的算法会比CMS复杂很多。算法细节也会比CMS多很多。算法复杂,对于大内存的机器来说会比较有效果,但是对于内存不太大的机器来说,运行效果可能还不如CMS,这就是为什么很长一段时间,jdk8这个版本还是用的CMS+ParNew。在jdk8版本的时候,已经有G1垃圾收集器了,但是对G1底层算法还没有优化的很好,直到jdk9,把G1算法再优化以后,且内存增大以后,效率才越来越高。
2.MixedGC
MixedGC和之前的FullGC优点相似,但他不是Full GC。G1有专门的Full GC。当老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值时则触发Mixed GC。回收时会回收所有的 Young区和部分Old区以及大对象区。为什么是一部分的Old区呢?它会根据GC的最大停顿时间来计算最高效益比,来确定old区垃圾收集的先后顺序。
正常情况G1的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够 的空region能够承载拷贝对象就会触发一次Full GC.
3.Full GC
Full GC会停止应用线程,然后采用单线程进行收集,就类似于Serial Old垃圾收集器。采用单线程进行标记、清理和压缩整理,目的是腾出一批Region来供下一次MixedGC使用,这个过程是非常耗时的,单线程的效率是很低的。(Shenandoah优化成多线程收集了)
什么时候会触发Full GC呢?
在老年代,需要把region中存活的对象拷贝到别的region中去的时候,拷贝过程中发现没有足够的空region能够承载的拷贝对象了,就会触发Full GC。举个例子:假如MixedGC触发的条件-XX:InitiatingHeapOccupancyPercent=45%,而剩余50%的空间被新生代占了。那么还剩5%的空间。当-XX:InitiatingHeapOccupancyPercent的值达到了45%,触发MixedGC的时候,这个时候需要复制老年代对象到新的未被占用的Region区,很显然这时没有足够的Region区,这时会触发Full GC。
八、 总结G1收集器的特点
- **并行与并发: **G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式 让java程序继续执行。
- 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
- 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;
从局部 上来看是基于“复制”算法实现的。
- **可预测的停顿: **这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了 追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"- XX:MaxGCPauseMillis"指定)内完成垃圾收集。
-XX:+UseG1GC:使用G1收集器-XX:ParallelGCThreads:指定GC工作的线程数量 -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区. -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
也就是垃圾回收的时候允许停顿的时间-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)-XX:G1MaxNewSizePercent:新生代内存最大空间,默认是60%。-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个
年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代 -XX:MaxTenuringThreshold:最大年龄阈值(默认15) ,当在年轻代经历了15次GC还没有被回收掉,那么进入老年代。-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能 就要触发MixedGC了。
也就是MixedGC触发的条件-XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这 个值,存活对象过多,回收的的意义不大。
在我们回收老年代的时候,我们需要知道老年代每个region中有多少存活对象。比如一个region中有100个对象,其中有85个是存活对象,垃圾对象是15个。那么这样的region回收的意义不大。反过来,如果有80格式垃圾对象,存活对象有只有20个,那么这时候就应该被回收。-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一 会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都 是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清 理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立 即停止混合回收,意味着本次混合回收就结束了。
十、 G1收集器优化建议 假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才
触发年轻代gc。 那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。 或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor
区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑 每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.
十一、G1的使用场景
- 50%以上的堆被存活对象占用
- 对象分配和晋升的速度变化非常大
- 垃圾回收时间特别长,超过1秒
- 8GB以上的堆内存(建议值)
- 停顿时间是500ms以内
分析:通常我们的服务器是4核8G,承载每秒上千上万的并发量应该都还可以。但是几十万上百万的并发量,4核8G的配置肯定是承受不住的。 为什么受不住呢?我们设想每个线程请求产生的对象是1kb,有100万并发进来,1秒钟将产生多少垃圾呢?1k*100万/1024=976M的垃圾,将近1G的垃圾,这样的话,过不了几秒就要触发一次Full GC。这样GC将会很频繁,这是不可以的,GC过于频繁,而且垃圾堆积肯定会影响用户的体验。所以,4核8G不满足我们的需求。假如使用了4核8G会产生什么样的后果呢?来分析一下:
文章图片
上图是根据参数配置的内存空间。
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
根据分析,我们知道,当第10s进行垃圾回收的时候,首先会STW,这时候前9s的对象都已经变成了垃圾,但是最后1s的对象不是垃圾,会被放到survivor区。976M远大于Survivor区的200M,直接进入老年代。老年代也放不下,就会触发Full GC,然后Full GC还没有处理完,新的垃圾又来了,最后就会触发OOM。
那么,这种高并发量的问题如何解决呢?
我们知道kafka的并发量非常大,每秒可以处理几十万甚至上百万的消息,可以借鉴kafka的处理思想。一般来说部署kafka需要用大内存机器,比如64G。如果我们要处理几十甚至上百万的并发消息,也用64G内存的服务器,堆内存该如何分配呢?
按照之前的经验,其实大部分对象在1s内就已经死亡了,而这些对象都是放在Eden区,Eden区对象有朝生夕死的特点。对象很大,我们就给新生代分配更大的内存空间,比如三十或者四十G。但是,如果给新生代分配三四十G也会有问题。以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗? 通常Eden区执行是很快的,但这里有三四十个G,就是遍历对象也会耗用很长时间。假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三 四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消 息,显然是不行的。
对于这种情况如何优化呢?我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,一共有三四十G,先回收三四G,剩下的下次在回收啊。50ms的卡顿用户也是完全能够接受的,几乎无感知,那么整个系统就可以在卡顿几 乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
通常4~6G使用CMS;8G以上使用G1
推荐阅读
- 垃圾睡眠点
- 垃圾回收机制(第十二天)
- 震撼!洛阳开始垃圾分类啦!
- 其他|清理C盘内存(电脑C盘飘红了,那么如何清理垃圾文件,总结几种亲测方案)
- 小垃圾漂流记(二)
- 党建引领|党建引领 夏果滩社区开展清理河道垃圾活动
- 垃圾回收机制与内存管理
- JVM实用参数(七)CMS收集器
- JVM实用参数(六)|JVM实用参数(六) 吞吐量收集器
- jvm垃圾回收卡表