上一篇通过案例说明了老年代的常见优化和处理方式,这一节来看下目前最为热门的G1收集器,G1收集器也是JDK9服务端默认的垃圾收集器,虽然JDK9在现在看来还不是十分的普及,但是学习这个垃圾收集器是十分重要也是十分必要的。
上一节我们通过一个电商的模拟实战,了解了老年代常见的优化方式。同时在最后总结了老年代优化的一些常见的套路,下面直接用上一节的总结回忆整个内容:
- 首先业务的对象都是生命周期十分短暂的对象,新生代的压力比老年代要大,所以适当缩小老年代空间是十分划算的
- 预测在高并发的场景下对象进入老年代的时机,如果对象经常“跨区”说明有一部分内容空间是浪费了,那就是Survior区域
- 对象在各分区需要大致多少的内存空间,比如每个线程需要占用多少的内存空间
- 对象的年龄判断是否需要改动,提前让对象进入老年代是好处还是坏处
- 关注收集器对于对象垃圾回收的影响,同时在启动的时候要强制使用某一垃圾收集器,因为不同的JDK版本默认的垃圾收集器是不一样的。
在之前的文章我们提到过,传统的黄金组合的痛点「Stop world」 (世界停顿):在「并发标记和并发整理」阶段如果出现分配对象超过老年代而导致Full Gc,会立刻停下手头的所有工作全力进行垃圾收集的动作,并且使用「Serial old」收集器处理,这对于用户来说会发现明显的卡顿,同时会误认为系统卡死,体验十分差。
感兴趣可以看下关于G1论文的白皮书:http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.63.6386&rep=rep1&type=pdf。(看评论说其实讲的十分抽象和笼统,建议深入研究G1的大致看看)
为什么会诞生G1呢?最大原因是「JVM目前不能让收集器的垃圾回收停顿在某个特定时间范围」,但是也不需要像实时那样要求十分的严格。基于这样的考虑,早在06年就出现了G1的论文,但是最终实现花了好多年的时间!可以想象这个收集器内部注定是非常复杂的,当然我们没有必要去深入研究底层,只需要他的基础原理即可。
补充《深入理解JVM虚拟机》介绍的特点:
G1取消了固定分代的概念,取而代之的是使用分区的概念,把内存切分成一个个小块,同时各个小块又分为新生代(eden、Survior区)、老年代、大对象(humongous区)等等。
G1规定大于Region的一半的对象成为大对象,同时「不参与分代」。
Region并不是固定为新生代或者老年代,通常情况为自由状态,只有在需要的时候会被划分为指定的分代并且存放特定对象。这里也许会有疑问,这样分配Region不会产生很多垃圾碎片么?答案当然是并不会,首先新生代使用复制算法,会把存活对象拷贝到一块region进行存放,同时存活对象大于一定的region占比不会进行复制(下文会提到),把整个region清理并且进行回收,而老年代则使用标记-整理算法,同样的道理也会直接把垃圾对象的region给清理掉,也是不会产生内存碎片的,最后,大对象是横跨多个region并由专门的region存放,一旦回收直接干掉大对象把对应的region清理即可。
G1收集器
计算方式: 堆大小 / 2048,**G1收集器最多可以有2048个region,并且大小必须是2的倍数。**也就是说4G内存给每一个region的大小是2M的大小。
下面是G1常见的配置参数:
-XX:Heap Region Size:手动制定region的大小。单个region的大小指定,默认为2M,4g内存当中
-XX:G1NewSizePercent: 手动指定新生代初始占比,默认是5%
-XX:G1MaxNewSizePercent:新生代的最大占比默认是60%
-XX:G1MixedGCCountTarget
:表示一次垃圾回收之后,最后一次混合回收执行几次。这个参数意味着在垃圾回收的最后一个阶段回收和系统运行「交替运行」,并且默认回收8次之后结束这个操作。-XX:G1HeapWastePercent
:默认为5%。在混合回收的时候如果一次混合回收的**空闲region超过5%**就立刻停止垃圾回收的操作,这个参数也是为了尽量减少停顿的时间设计的。-XX:G1MixedGCLiveThreasholdPercent
:默认值为85%,表示存活对象要低于85%才进行回收的操作。因为如果一个region的存活对象大于85%,复制拷贝的代价是十分大的,并且这一类region大概率进入老年代-XX:G1MaxNewSizePercent
:新生代最大占用堆内存空间,默认值为60%。这个参数意味着在初始5%的新生代情况下,系统运行最多可以从系统总空间分配60%给新生代使用,超过这个值就肯定会触发新生代回收。-XX:NewRatio=n
:配置新生代与老年代的比例,默认是2:1为了更好理解G1的工作模式停顿时间模型,这里个人想了一个还算生动的例子解释下G1的工作模式:
我们平常去服务比较周到的餐馆,服务员通常会看我们桌子上垃圾的量有多少或者吃剩的盘子有多少,当垃圾超过一定量之后,服务员就会过来收盘子或者收垃圾,然后方便后面上菜,同时也可以让洗盘子的服务员忙忙停停的工作。
这之后就会考虑,如果每多出一个盘子就去收一个,你会不会很累,会不会等到盘子有一定量了再去收,比如桌子摆不下新的菜了,或者盘子叠了很多个。
最后,如果每次收盘子的速度能赶上上菜的速度,那么基本的正常运转是没有大问题的。
G1适合的系统包括「超大内存」的系统,此时如果按照传统的分代,比如16G的内存新生代8G,老年代8G这种划分,虽然拉长垃圾停顿的时间,但是一旦新生代被占满,回收的效率是非常低的,因为对象和GC ROOT非常多,最终导致垃圾收集长时间卡顿。而G1不一样,他只需要按照算法判断根据停顿模型的值在新生代接近停顿模式时间的时候就马上开启回收,不用等新生代满了才回收。
G1也适合「需要低延迟」的系统,因为低延迟对于系统的响应要求是非常高的,更加看重响应的时间,同时对于系统的资源要求也比较高,而分代的模型和理论在大内存机器会造成长时间的垃圾收集停顿,这对于实时响应的服务要求是非常高的。
G1最大的进步也是他的特点就是 「时间停顿模型」, 可以控制stop world的时间。同时可以让垃圾收集的时间控制在预期设置的范围之内。
其他进步:
没缺点是不可能的,肯定是有缺点,这个收集器最大的问题恰恰在于停顿模型,里面的算法细节十分的复杂,所以我们调试「不能仅仅通过业务推算」,而是要根据日志以及工具辅助来完成调优,G1的调优是非常麻烦的一件事。
其次就是Region的设计了,region的设计本身要消耗大量的系统资源进行维护的,这意味着G1收集器本身至少需要占用10%-20%的内存来维持自己的正常工作,比如计算停顿模型,维持跨代引用和GC ROOT等信息,这中间蕴藏的细节十分复杂,这里的内容可以参考【其他资料-干货文章】这部分了解,所以G1收集器本身就需要较好的机器性能才建议使用。
下面是根据《深入理解JVM虚拟机》书中总结G1的缺点:
和cms垃圾收集器类似,在初始的的情况需要stop the world的操作。「仅仅标记GC ROOT 可以引用的对象」,整个过程非常快。
这里可以看到对空间的结构已经和传统固定的分代模型已经不一样了,堆内存被划分为小块的region,初始标记会根据栈中的局部变量引用或者传递引用等标记初始的GC ROOT对象,这个过程可以比较快的完成,因为仅仅是简单标记而已。
这个阶段也和cms比较类似,同样可以和用户线程一起「并发运行」,系统可以正常分配对象,而垃圾回收线程根据GC ROOT 进行对象的引用,标记存活对象。同时将对象的改动进行标记记录,这个阶段会比较耗费系统资源,但是和系统线程一起并发影响也不是很大。
最终标记阶段:这个阶段和CMS类似,也是需要stop world,此时系统进程需要暂停,停止对象的分配,同时垃圾收集器负责对于对象进行最后的标记和分类动作,决定哪些对象需要被垃圾回收。
筛选回收阶段:需要重点记忆的一个阶段。这个阶段会计算老年代有多少个region存活,存活对象的占比,以及回收的效率计算。
这个阶段也是需要stop world,会让垃圾收集线程马力全开,在指定的停顿时间范围内完成更多的垃圾回收动作。同时这个阶段会重复多次,并且在回收的时候和系统线程是交替运行的,也就是说回收的时候需要暂停。
另外回收阶段不仅回收老年代,还回收新生代以及大对象,算是真正意义上的full GC。比如:老年代有1000个region,而通过计算发现需要回收的region为800个,那么就会回收800个REGION
这里再结合步骤说明一下上面的部分参数的作用,从上图可以看到,系统线程和垃圾回收是交替运行的,在最后一步默认会让系统和垃圾收集线程交互运行8次,假设停顿时间设置为200ms,那么就是每次在200ms内尽可能回收垃圾,回收完成立马开启系统线程,然后运行一段时间又进行回收,如此反复,如果在垃圾回收中4次就回收掉超过5%,那么意味着这一个阶段提前完成了,就会立马进入下一次的垃圾回收循环。
对应回收参数:
-XX:G1MixedGCCountTarget
:表示一次垃圾回收之后,最后一次混合回收执行几次。这个参数意味着在垃圾回收的最后一个阶段回收和系统运行交替运行,并且回收8次之后结束这个操作。-XX:G1HeapWastePercent
:默认为5%。在混合回收的时候如果一次混合回收的空闲region超过5%就立刻停止垃圾回收的操作,这个参数也是为了尽量减少停顿的时间设计的。 参数:-XX:InterfaceTestControllernitiatingHeapOccupancyPercent
,他的默认值是45%「。」
意思就是说如果老年代占用超过了45%**,「就会触发一个叫做混合回收的操作」,**混合回收意味着新生代和老年代一起回收,这时候毫无疑问整个系统线程都会停止。
回收失败了,就会停止线程,然后通过单线程的serrial方式对于所有region存活对象标记,整理,然后清理垃圾对象,整个过程是非常缓慢的。
这个过程其实和CMS的「Corrurnet mode fail」类似,但是由于G1的内存模型完全另辟蹊径,所以他需要回收新生代,老年代和大对象,算法的细节要更为复杂,整理恢复的时间也比较长,也可以说G1的回收是真正意义上的Full GC。
答案是虽然不需要指定新生代和老年代的大小由G1控制,但是region本身在运行时还是会划分新生代或者老年代,只是不再是固定的了,所以新生代还是有对应的Eden和Survior区域。
还是采用「复制算法」,当新生代超过默认的60%的最大占比限制的时候,会触发「Minor gc」,然后进行「stop world」的操作。
这里可能会想这不还是和之前没区别么?之前说过G1的特点是指定垃圾回收的最大停顿时间。G1会根据Region的大小和回收预测时间在我们指定的最大回收时间内尽可能的回收内存,回收之后的内存可以给新生代使用也可以给老年代使用。
这里需要注意,G1本身已经没有老年代回收这个概念了,取而代之的是「Mixed Gc」也就是混合回收,当老年代的region占比超过45%就会触发。
首先需要弄清一个概念,就是region虽然是分代存储的但是并不代表一直是分代的region,比如新生代如果有600个region,回收了200个region,这200个region等于是“自由”的,可以分给新生代也可以给老年代使用。
G1的停顿时间模型会根据用户设置的比如「200MS」内进行回收操作,可以通过:「-XX:MaxGCPauseMills」 这个参数设置最大的停顿等待时间,g1会根据此参数追踪最有回收价值的region进行处理,但是这个参数其实是一个软目标,并不是说收集器完全保证这个时间段内完成回收,而是意味着在这个时间段内做「最有价值的回收」,就像前文提到的收盘子的概念一样,他会尽可能的处理让垃圾收集速度更上分配的速度。
大对象不属于新生代也不属于老年代,所以在新生代或者老年代回收的时候会顺带回收大对象的region内存。也就是说大对象的回收依靠Mixed回收。
自己看书的一些笔记,不适合作为正文,所以放到最后(反正也没人看,哈哈)
Region使用两个ams指针把region的一部分空间划分对象分配使用,所有新分配的对象存在此区间,同样如果回收赶不上内存分配,也要冻结用户线程,进行stop world。
可预测模型依靠:-XX:MaxGcRauseMillion 期望值,G1会在期望值内做出最有效率的回收
算法:
G1使用「衰减均值算法」,垃圾回收计算region回收耗时和脏卡数据
知乎:G1 收集器原理理解与分析
请教G1算法的原理
美团:Java Hotspot G1 GC的一些关键技术
最后吐槽一句,JVM真的很难,光看《深入理解JVM虚拟机》这本书也只能大概了解目前主流收集器实现的大致原理,如果要深究需要长时间的积累,当然我们不需要学的那么痛苦,这篇文章讲到的内容基本能应付80、90%的场景了。
下一篇文章根据一个案例讲一下G1的大致优化思路,注意只是大致思路,和之前的分代收集器不同,G1要真刀真枪的去跑工具,看日志,做数据分析才能调优好,因为能用上G1收集器的系统多数不会小,小系统用用分代并且并发访问量不大的也不需要怎么调优。