java 五 G1 垃圾回收器
G1垃圾回收器的工作原理
G1 垃圾回收器特点
G1 垃圾回收器是可以同时回收新生代和老年代对象的,不需要两个垃圾回收器配合起来运作。它最大一个特点就是把 JAVA 堆内存分为多个大小相等 Region。 G1 也会有新生代和老年代的概念,但是只不过是逻辑上的概念。G1 可以让我们设置一个垃圾回收的预期停顿时间。也就是说我们可以指定:希望G1进行垃圾回收的时候,可以保证,在1小时内由G1垃圾回收导致的 “Stop the world” 时间,也就是系统停顿时间,不能超过1分钟。这样相当于我们可以直接控制垃圾回收对系统性能的影响了。
G1是如何做到对垃圾回收导致的系统停顿可控的?
G1 必须要追踪每个 Region 里的回收价值。他必须搞清楚每个 Region 里的对象有多少是垃圾,如果对这个 Region 进行垃圾回收,需要耗费多长时间,可以回收掉多少垃圾。
简单来说,G1 可以做到让你来设定按逻辑回收对系统的影响,他自己通过把内存拆分为大量小 Region,以及追踪每个 Region 中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同事在有限的世界内尽量回收尽可能多的垃圾对象。这就是G1的核心设计思路。
Region可能属于新生代也可能属于老年代
在 G1 Region 可能属于新生代,也有可能老年代。 刚开始 Region 可能谁都不属于,然后接着就分配给新生代,然后放了很多属于新生代的对象,接着就触发了垃圾回收这个Region,如下图
然后下一次同一个Region可能又被分配了老年代了,用来放老年代的长生存周期的对象,如下图所示
实际上新生代和老年代各自的内存区域是不停的变动的,由 G1 自动控制。
G1分代回收原理深度图解:为什么回收性能比传统GC更好
如何设定G1对应的内存大小
G1对应的是一大堆的 Region 内存区域,每个 Region 的大小都是一致的。
给整个堆内存设置一个大小,比如说用“-Xms”和“-Xmx”来设置堆内存的大小。接着 JVM 启动时可以使用 “-XX:+UseG1GC”来指定使用G1垃圾回收器,此时会自动用堆大小除以2048。因为 JVM 最多可以有 2048 个 Region , 然后 Region 的大小必须是2的倍数,比如 1MB, 2MB, 4MB之类的。
比如说堆大小是4G,那么就是4096MB,此时除以2048个Region,每个Region的大小就是2MB。大概就是这样子来决定Region的数量和大小的。
我们也可以通过手动的方式来指定,则是 "-XXHeapRegionSize" 。
刚开始的时候,默认新生代对堆内存的占比是 5%,也就是占据 200MB的内存,则对应大概是 100 个Region,这个是可以通过 "-XX:G1NewSizePercent" 来设置新生代初始占比的,其实维持这个默认值即可。
在系统运行中,JVM其实会不停地给新生代增加更多的 Region,但是最多新生代的占比不会超过60%,可以通过 "-XX:G1MaxNewSizePercent"。一旦 Region 进行了垃圾回收,此时新生代的 Region 数量还会减少,这些都是动态的。
新生代还有Eden和Survivor的概念吗?
在 G1 中虽然把内存划分为了很多的 Region,但是其实还是有新生代,老年代的区分。而且新生代里还有 Eden 和 Survivor 的划分。新生代的这个参数 “-XX:SurvivorRatio=8” 还是可以区分出来哪些是属于新生代的Region,哪些属于Eden,哪些属于Survivor。
比如新生代之前说刚开始初始的时候,有100个Region,那么可能80个Region就是Eden,两个Survivor各自占10个Region。如下图:
在这里其实还是有 Eden 和 Survivor 的概念,他们会各自占据不同的 Region。只不过随着对象不停的在新生代里分配,属于新生代的Region会不断增加,Eden和Survivor对应的Region也会不断增加。
G1的新生代垃圾回收
随着不停地在新生代的Eden对应的Region中存放对象,JVM就会不停的给新生代加入更多的 Region,直到新生代占据堆大小的最大比例60%。一旦新生代达到了设定的占据堆内存的最大大小60%,比如都有1200个Region了,里面的Eden可能占据了1000个Region,每个Survivor是100个Region,而且Eden区还占满了对象,此时如下图所示。
这个时候还是会触发新生代的GC,G1就会用之前说过的复制算法来进行垃圾回收,进入一个 “Stop the World” 状态。然后把 Eden 对应的 Region 中的存活对象放入 S1对应的 Region 中,接着回收掉 Eden 对应的 Region 中的垃圾对象,如下图:
但是这个过程跟之前是有区别的,因为G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,可以通过“-XX:MaxGCPauseMills”参数来设定,默认值是200ms。
对象什么时候进入老年代
按照默认新生代最多只能占据堆内存 60% 的 Region 来推算,老年代最多可以占据 40% 的Region,大概就是 800个左右的Region。对象进入老年代的条件跟之前几乎是一样的,
1.对象在新生代躲过了很多次的垃圾回收,达到了一定的年龄, "-XX:MaxTenuringThreshold" 参数可以设置这个年龄,他就会进入老年代
2. 动态年龄判定规则,如果一旦发现某次新生代GC过后,存活对象超过了 Survivor 的 50%。此时就会判断一下,比如年龄为1岁,2岁,3岁,4岁的对象的大小总和超过了Survivor的50%,此时4岁以上的对象全部会进入老年代,这就是动态年龄判定规则
3. Minor GC 过后可能存活的对象超过 Survivor 的容量,此时会有一些对象进入老年代中。
大对象Region
G1 提供了专门的 Region 来存放大对象,而不是让大对象进入老年代的 Region 中。
在 G1 中,大对象的判定规则就是一个大对象超过了一个 Region 大小的 50%,比如安装上面算的,每个 Region 是 2MB,只要一个对象超过 1MB,就会被放入大对象专门的Region 中。而且一个大对象如果太大,可能会横跨多个 Region 来存放,如下图:
堆内存哪些Region 用来存放大对象?60%给了新生代,40%给了老年代。因为 G1中,新生代和老年代的 Region 是不停的变化的。比如新生代现在占据了1200个Region,但是一次垃圾回收之后,就让里面1000个Region都空了,此时那1000个Region就可以不属于新生代了,里面很多Region可以用来存放大对象。新生代、老年代在回收的时候,会顺带带着大对象Region一起回收,所以这就是在G1内存模型下对大对象的分配和回收的策略。
线上系统部署如果采用G1垃圾回收器,应该如何设置参数
什么时候触发新生代+老年代的混合垃圾回收
G1 有一个参数,是 “-XX:InitiatingHeapOccupancyPercent”,他的默认值是45%。若老年代占据了堆内存的 45% 的Region的时候,此时就会尝试触发一个新生代 + 老年代一起回收的混合回收阶段。
比如按照我们之前说的,堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候,就会开始触发一个混合回收,如下图所示。
G1垃圾回收的过程
-
触发一个 “初始标记” 的操作,这个过程是需要进入 "Stop the World" 的,仅仅只是标记一下 GC Roots 直接引用能引用的对象,这个过程速度是很快的。具体操作如下:停止系统程序的运行,然后对各个线程栈内存中的局部变量代表的GC Roots,以及方法区中的类静态变量代表的GC Roots,进行扫描,标记出来他们直接引用的那些对象。
-
进入 "并发标记" 阶段,这个阶段会允许系统程序的运行,同时进行 GC Roots 追踪,从 GC Roots 开始追踪所有的存活对象。这个并发标记阶段还是很耗时的,因为要追踪全部的存活对象。但是这个阶段是可以跟系统程序并发运行的,所以对系统程序的影响不太大。而且JVM会对并发标记阶段对对象做出的一些修改记录起来,比如说哪个对象被新建了,哪个对象失去了引用。
-
最终标记阶段,这个阶段会进入“Stop the World”,系统程序是禁止运行的,但是会根据并发标记 阶段记录的那些对象修改,最终标记一下有哪些存活对象,有哪些是垃圾对象。如图:
-
混合回收阶段,这个阶段会计算老年代中每个Region中的存活对象数量,存活对象的占比,还有执行垃圾回收的预期性能和效率。接着会停止系统程序,然后全力以赴尽快进行垃圾回收,此时会选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在我们指定的范围内。比如说老年代此时有1000个Region都满了,但是因为根据预定目标,本次垃圾回收可能只能停顿200毫秒,那么通过之前的计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,把GC导致的停顿时间控制在我们指定的范围内。
在这里需要注意的是,老年代对堆内存占比达到 45% 的时候,触发的是 "混合回收",也就是说:此时垃圾回收不仅仅是回收老年代,还会回收新生代,还会回收大对象。
G1垃圾回收器的一些参数
一般在老年代的Region占据了堆内存的Region的45%之后,会触发一个混合回收的过程,也就是Mixed GC,分为了好几个阶段。在这里最后一个环节,其实就是执行混合回收,从新生代和老年代里都回收一些Region。但是最后一个阶段混合回收的时候,其实会停止所有程序运行,所以说G1是允许执行多次混合回收。
比如先停止工作,执行一次混合回收回收掉 一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region。有一些参数可以控制这个,比如“-XX:G1MixedGCCountTarget”参数,就是在一次混合回收的过程中,最后一个阶段执行几次混合回收,默认值是8次意味着最后一个阶段,先停止系统运行,混合回收一些Region,再恢复系统运行,接着再次禁止系统运行,混合回收一些Region,反复8次。假设一次混合回收预期要回收掉一共有160个Region,那么此时第一次混合回收,会回收掉一些Region,比如就是 20个Region。接着恢复系统运行一会儿,然后再执行一次“混合回收”,再次回收掉20个Region。如此反复执行8次混合回收阶段之后 ,就把预订的160个Region都回收掉了,而且还把系统停顿时间控制在指定范围内。
为什么要反复回收多次呢?
因为你停止系统一会儿,回收掉一些Region,再让系统运行一会儿,然后再次停止系统一会儿,再次回收掉一些Region,这样可以尽可能让系统不要停顿时间过长,可以在多次回收的间隙,也运行一下。
还有一个参数,就是“-XX:G1HeapWastePercent”,默认值是5% 。他的意思就是说,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉。这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会 立即停止混合回收,意味着本次混合回收就结束了。而且从这里也能看出来G1整体是基于复制算法进行Region垃圾回收的,不会出现内存碎片的问题,不需要像CMS那样标记-清理之后,再进行内存碎片的整理。
还有一个参数,“-XX:G1MixedGCLiveThresholdPercent”,他的默认值是85%,意思就是确定要回收的Region的时候,必须是存活对象低于85%的Region才可以进行回收。否则要是一个Region的存活对象多余85%,你还回收他干什么?这个时候要把85%的对象都拷贝到别的Region,这个成本是很高的。
回收失败时的Full GC
如果在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region里去。此时万一出现拷贝的过程中发现没有空闲Region可以承载自己的存活对象了,就会触发 一次失败。一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢极慢的。
百万级用户的在线教育平台,如何基于G1垃圾回收器优化性能(上)
这是一个百万级注册用户的在线教育平台,主要目标用户群体是几岁到十几岁的孩子,注册用户大概是几百万的规模,日活用户规模大概在几十万。这里尤为关键的需要注意的,就是每天晚上那两三小时的高峰时期,几乎你可以认为每天几十万日活用户(那些小孩儿)都会集中在这个时间段来平台上上在线课程,比如青少年英语课,或者数学课之类的。所以这个晚上两三小时的时间段里,将会是平台每天绝对的高峰期,而且白天几乎没什么流量,可能99%的流量都集中在晚上,如下图所示。
在那几十万用户晚上高峰时间段使用系统上课的时候,尤为核心的业务流程,就是大量的游戏互动环节。通过游戏互动让孩子们感兴趣,愿意学,而且通过游戏强互动让他们保持注意力,促使他们对学习到的东西进行输出,提升学习的效果,大家看下图。
也就是说,这个游戏互动功能,一定会承载用户高频率、大量的互动点击。
系统的运行压力
首先我们来分析一下晚上高峰期内几十万用户同时在线使用平台,每秒钟会产生多少请求?
我们可以大致来估算一下,比如说晚上3小时高峰期内有总共60万活跃用户,平均每个用户大概会使用1小时左右来上课,那么每小时大概会有20万活跃用户同时在线学习。
这20万活跃用户因为需要进行大量的互动操作,所以大致可以认为是每分钟进行1次互动操作,一小时内会进行60次互动操作
那么20万用户在1小时内会进行1200万次互动操作,平均到每秒钟大概是3000次左右的互动操作,这是一个很合理的数字。
那么每秒钟要承载3000并发请求,根据经验来看,一般系统的核心服务需要部署5台4核8G的机器来抗住是差不多的,每台机器每秒钟抗个600请求,这个压力可以接受,一般不会导致宕机的问题。
那么每个请求会产生多少个对象呢?
一次互动请求不会有太复杂的对象,他主要是记录一些用户互动过程的,可能会跟一些积分之类的东西有关联大家如果玩儿过在线教育APP都知道,每次你完成一个活动,一般会给你累加一些对应的“XX币“,“XX宝石”之类的东西。所有大致估算一下,一次互动请求大致会连带创建几个对象,占据几KB的内存,比如我们就认为是5KB吧那么一秒600请求会占用3MB左右的内存。
百万级用户的在线教育平台,如何基于G1垃圾回收器优化性能(下)
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的内存空间,如下图所示。
GC停顿时间如何设置?
在G1垃圾回收器中有一个至关重要的参数会影响到GC的表现,就是“-XX:MaxGCPauseMills”,他的默认值是200毫秒也就是说咱们希望每次触发一次GC的时候导致的系统停顿时间(也就是“Stop the World”)不要超过200毫秒,避免系统因为GC长时间卡死。
到底多长时间会触发新生代GC?
“Eden区域的空间不够了,就触发新生代gc” 之前说过“-XX:G1MaxNewSizePercent”参数限定了新生代最多就是占用堆内存60%的空间。
我们首先假设一个前提,这个纯碎就是我们人为设定的,就是假设在这个系统里,G1回收掉300个Region(600MB内存),大致需要200ms。
那么很有可能系统运行时,G1呈现出如下的运行效果。
首先,随着系统运行,每秒创建3MB的对象,大概1分钟左右就会塞满100个Region(200MB内存),如下图所示。
此时很可能G1会觉得,要是我现在就触发一次新生代gc,那么回收区区200MB只需要大概几十ms,最多就让系统停顿几十ms而已,跟我的主人设定的“-XX:MaxGCPauseMills”参数限制的200ms停顿时间相差甚远。
要是我现在就触发新生代gc,那岂不是会导致回收完过后接着1分钟再次让新生代这100个Region塞满,接着又触发新生代gc?
那这样算下来,岂不是每分钟都要执行一次新生代gc?是不是太频繁了?好像没这个必要吧!
所以还不如给新生代先增加一些Region,然后让系统继续运行着在新生代Region中分配对象好了,这样就不用过于频繁的触发新生代gc了,此时如下图。
然后系统继续运行,一直到可能300个Region都占满了,此时通过计算发现回收这300个Region大概需要200ms,那么可能这个时候就会触发一次新生代gc了。
所以大家通过这一小节的分析就明白了,其实G1里是很动态灵活的,他会根据你设定的gc停顿时间给你的新生代不停分配更多Region
然后到一定程度,感觉差不多了,就会触发新生代gc,保证新生代gc的时候导致的系统停顿时间在你预设范围内。
其实这个G1到底会分配多少个Region给新生代,多久触发一次新生代gc,每次耗费多长时间,这些都是不确定的,必须通过一些工具去查看系统实际情况才知道,这个提前是无法预知的。
但是大家需要知道的,就是G1它本身是这样的一个运行原理,他会根据你预设的gc停顿时间,给新生代分配一些Region,然后到一定程度就触发gc,并且把gc时间控制在预设范围内,尽量避免一次性回收过多的Region导致gc停顿时间超出预期。
新生代gc如何优化?
垃圾回收器是一代比一代先进,内部实现机制越来越复杂,但是对我们来说优化的时候越来越简单了。
比如对于G1而言,我们首先应该给整个JVM的堆区域足够的内存,比如我们在这里就给了JVM超过5G的内存,其中堆内存有4G的内存。
接着就应该合理设置“-XX:MaxGCPauseMills”参数
如果这个参数设置的小了,那么说明每次gc停顿时间可能特别短,此时G1一旦发现你对几十个Region占满了就立即触发新生代gc,然后gc频率特别频繁,虽然每次gc时间很短。
比如说30秒触发一次新生代gc,每次就停顿30毫秒。
如果这个参数设置大了呢?
那么可能G1会允许你不停的在新生代理分配新的对象,然后积累了很多对象了,再一次性回收几百个Region
此时可能一次GC停顿时间就会达到几百毫秒,但是GC的频率很低。比如说30分钟才触发一次新生代GC,但是每次停顿500毫秒。
所以这个参数到底如何设置,需要结合后续给大家讲解的系统压测工具、gc日志、内存分析工具结合起来进行考虑,尽量让系统的gc频率别太高,同时每次gc停顿时间也别太长,达到一个理想的合理值。
mixed gc如何优化?
老年代在堆内存里占比超过45%就会触发mixed gc。
大家之前都很清楚了年轻代的对象进入老年代的几个条件了,要不然是新生代gc过后存活对象太多没法放入Survivor区域,要不然是对象年龄太大,要不然是动态年龄判定规则。
其中尤其关键的,就是新生代gc过后存活对象过多无法放入Survivor区域,以及动态年龄判定规则
这两个条件尤其可能让很多对象快速进入老年代,一旦老年代频繁达到占用堆内存45%的阈值,那么就会频繁触发mixed gc。
所以mixed gc本身很复杂,很多参数可以优化,但是优化mixed gc的核心不是优化他的参数,而是跟我们之前分析的思路一样,尽量避免对象过快进入老年代,尽量避免频繁触发mixed gc,就可以做到根本上优化mixed gc了。
那么G1里面跟之前的ParNew+CMS的组合是不同的,我们到底应该如何来优化参数呢?
其实核心的点,还是“-XX:MaxGCPauseMills”这个参数。
大家可以想一下,假设你“-XX:MaxGCPauseMills”参数设置的值很大,导致系统运行很久,新生代可能都占用了堆内存的60%了,此时才触发新生代gc。
那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。
或者是你新生代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节“-XX:MaxGCPauseMills”这个参数的值,在保证他的新生代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc。
至于到底如何优化这个参数,一切都要结合后续大量工具的讲解和实操演练了,到这里为止,至少大家对原理性的东西都很了解了。