26、JVM 调优实战 - 案例:百万级用户的在线教育平台,如何基于G1垃圾回收器优化性能?

1. 案例背景

案例的背景,是一个百万级注册用户的在线教育平台,主要目标用户群体是几岁到十几岁的孩子,注册用户大概是几百万的规模,日活用户规模大概在几十万。

其业务包括选课、排课、浏览课程以及付费购买之类的低频的行为。此外最核心也最为主要的高频行为是 “上课” 。

从该课程面向的群体来说,主要是幼儿园的孩子到中小学的孩子。所以这些群体最活跃使用该平台的时候主要集中在晚上放学之后到八九点之间。以及周末也是最活跃使用该平台的时候。

由此,可以知道,每天晚上那两三小时的高峰期,有将近几十万日活用户集中在该时间段在平台上上在线课程。而白天几乎没有什么流量。夜晚两三小时的流量占比将近99%。

2. 系统核心业务流程分析

在线教育APP主要偏向于互动方式的授课模式。通过游戏互动让孩子们感兴趣,愿意学,而且通过游戏强互动让他们保持注意了,促使他们对学习到的东西进行输出,提升学习的效果。

所以,这个游戏互动功能,会承载用户高频率、大量的互动点击。

比如在完成什么任务的时候要点击很多的按钮,频繁的进行互动,然后系统后台需要接收大量的互动请求,并且记录下来用户的互动过程和互动结果。

同时,系统得记录下来用户完成了多少个任务,做对了几个,做错了几个,诸如此类的。

3. 系统的运行压力

假设晚上高峰期内几十万用户同时在线使用平台,平均每个用户大概会使用1小时左右来上课,那么每小时大概会有20万活跃用户同时在线学习。

这20万活跃用户进行的互动操作,大致为每分钟进行1次互动操作,一小时内就会进行60次互动操作。

那么20万用户在1小时内会进行1200万次互动操作,平均到每秒钟大概是 3000 次左右的互动操作。

以每秒钟要承载3000并发请求来看,核心服务需要部署5台4核8G的机器来抗。每台机器每秒钟抗个600请求,这个压力可以接受,也不会导致宕机的风险。

每次互动完成会累加一些对应的“XX币”之类的东西。

大致估算一下,一次互动请求大致会连带创建几个对象,占据几KB的内存,比如我们就认为是5KB吧!那么一秒600请求会占用3MB左右的内存。

4. G1垃圾回收器的默认内存布局

基于4核8G的机器来部署系统,然后每台机器每秒会有600个请求会占用3MB左右的内存空间。

假设对机器上的JVM,分配4G给堆内存,其中新生代默认初始占比为5%,最大占比为60%,每个Java线程的栈内存为1MB,元数据区域(永久代)的内存为256M,此时JVM参数如下:

“-Xms4096M -Xmx4096M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:+UseG1GC”

“-XX:G1NewSizePercent” 参数是用来设置新生代初始占比的,不用设置,维持默认值为5%即可。

"-XX:G1MaxNewSizePercent" 参数是用来设置新生代最大占比的,也不用设置,维持默认值为60%即可。

此时堆内存共4G,那么此时会除以2048,计算出每个 Region 的大小,也就是每个 Region 为2MB。刚开始新生代就占5%的Region,也就是新生代只有100个Region,有200MB的内存空间。

 

5. GC停顿时间设置

G1垃圾回收器中的参数 “-XX:MaxGCPauseMills” ,默认值为200毫秒。意思是每次触发一次GC的时候导致的系统停顿时间(STW)不要超过200毫秒,避免系统因为GC长时间卡死。

该参数一般保持默认值。

6.到底多长时间会触发新生代GC?

当系统运行起来之后,会不停的在新生代的 Eden 区域内分配对象,按照推算是每秒分配3MB的对象。

 

虽然G1回收器有 “-XX:G1MaxNewSizePercent” 参数限定了新生代最多占用堆内存 60%的空间。

但不代表就是随着系统运行一直给新生代分配更多的 Region,知道新生代占据了 60%的Region之后,无法再分配更多的 Region了,才出发新生代 GC。这种思路是错误的

案例分析

假设G1回收掉300个Region(600MB内存),大致需要200ms。

那么随着系统运行,每秒创建3MB的对象,大概1分钟左右就会塞满100个Region(200MB内存)。

此时的G1如果有计划出发一次新生代GC,就会去看回收那200MB只需要大概几十ms,最多让系统停顿几十ms,这个时间和设定的 “-XX:MaxGCPauseMills” 参数限制的200ms停顿时间相差甚远。

要是这时触发新生代GC,那么会导致回收完后接着1分钟再次让新生代100个Region塞满,继续触发Minor GC。如此反复,会导致频繁的Minor GC。

如此频繁的Minor GC是很不合理的,所以,G1会考虑给新生代新增一些Region,然后让系统继续运行着,在新生代Region分配新对象,避免频繁的触发新生代GC。

 

然后系统继续运行,知道可能300个Region都占满了,此时计算后发现回收这300个Region大概需要200ms,那么就可能会触发一次Minor GC。

总结一下,就是G1里是很动态灵活的,它会根据你设定的GC停顿时间给你的新生代不停分配更多 Region。

然后到一定程度,感觉差不多了,就会触发新生代GC,保证新生代GC的时候导致的系统停顿时间在你预设范围内。

以上只是一个示例,实际生产中,G1本身就是这样的一个运行原理,再结合你预设的gc停顿时间,给新生代分配一些Region,然后到一定程度就触发GC,并且把GC时间控制在预设范围内,尽量避免一次性回收过多的 Region 导致GC停顿时间超出预期。

6. 新生代GC如何优化?

对于G1而言,首先要给整个JVM的堆区域足够的内存,比如这里就给了JVM超过5G的内存,其中堆内存有4G。

接着合理设置 “-XX:MaxGCPauseMills” 参数。

如果这个参数设置的小了,说明每次GC停顿时间可能特别短,此时G1一旦发现你对象几十个Region 占满了就立即出发新生代GC,然后GC频率特别频繁,虽然每次GC时间很短。

比如说30秒触发一次新生代GC,每次就停顿30毫秒。

如果这个参数设置大了,那么可能G1会允许你不停的在新生代里分配新的对象,然后积累很多对象了,再一次性回收几百个 Region。

此时可能一次GC停顿时间就就会达到几百毫秒,但是GC的频率很低。比如30分钟触发一次新生代GC,但是每次停顿500毫秒。

7. mixed gc如何优化?

老年代在堆内存里占比超过45%就会触发 Mixed GC。

年轻代的对象进入老年代的几个条件,一个是新生代GC过后存活对象太多没法放入Survivor区域,一个是对象年龄太多,另一个就是动态年龄判定规则。

这里最关键的,就是新生代GC过后存活对象过多无法放入Survivor区域,以及动态年龄判定规则。

这两个条件可能让很多对象快速进入老年代,一旦老年代频繁达到占用堆内存45%的阈值,那么就会频繁触发Mixed GC。

所以Mixed GC本身很复杂,很多参数可以优化,但是优化Mixed GC的核心不是优化它的参数,而是尽量避免对象过快进入老年代,尽量避免频繁触发Mixed GC,就可以做到根本上优化Mixed GC了。

G1优化的核心,是 “-XX:MaxGCPauseMills” 这个参数。如果该参数的值很大,导致系统运行很久,新生代可能都占用了堆内存的60%了,此时才触发新生代GC。

那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。

或者是新生代GCC过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。

总之,调节好 “-XX:MaxGCPauseMills” 这个参数的值,在保证它的新生代GC别太频繁的同时,还得考虑每次GC过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发Mixed GC。