likes
comments
collection
share

真丶干货!记一次实实在在的线上JVM调优,过程较为曲折。

作者站长头像
站长
· 阅读数 11

起因:生产环境内存资源占用异常,频繁FullGC,影响用户使用的流畅性及系统稳定性

部署环境: K8S,Docker容器

POD资源设置情况: 10个副本均是如此

最小CPU(m)最小内存(m)最大CPU(G)最大内存
100409624096

JVM-CMS空间简单介绍:

堆分为年轻代(Young)或老年代(Old),年轻代 和 老年代 的 内存划分比例为新生代 : 老年代 = 1 : 2(默认); 新生代内部空间比例为Eden(伊甸园空间) : Survivor(幸存者空间) from : Survivor(幸存者空间) to = 8 :1 : 1 ,既下图表述:

真丶干货!记一次实实在在的线上JVM调优,过程较为曲折。 新的对象始终在 Eden 空间上创建。一旦一个对象在一次垃圾收集后还幸存,就会被移动到幸存者空间。当一个对象在多次垃圾收集之后还存活时,它会移动到年老代。这样做的目的是在年轻代和年老代采用不同的收集算法,以达到较高的收集效率。


原JVM参数:-server -Xms3g -Xmx3g,释义:给定JVM最小堆内存3G,最大堆内存3G。GC算法为JDK8默认的Parallel Scavenge(新生代)+ Serial Old(老年代)垃圾回收器。这两个垃圾回收器专注领域及简单解释(后面会对吞吐量及暂停时间再做说明):

Parallel Scavenge:吞吐量,目的是尽快完成工作,而很少考虑延迟(暂停)。会在STW(全局暂停)期间,以更紧凑的方式,将正在使用中的内存移动(复制)到堆中的其他位置,从而制造出大片的空闲内存区域。当内存分配请求无法满足时就会发生STW暂停,然后JVM完全停止应用程序运行,投入尽可能多的处理器线程,让垃圾回收算法执行内存压缩工作,然后分配请求的内存,最后恢复应用程序执行。

Serial Old:内存大小和启动时间,这个GC像是更简单、更慢的Parallel GC,它在STW暂停期间仅使用一个线程完成所有工作。堆也是按照分代组织的。但是Serial GC占用的内存更小、启动速度更快。由于它更简单,所以更适合小型、短时间运行的应用程序。

第一次调整后\color{#FF0000}{第一次调整后}第一次调整后:-XX:NewRatio=1 -XX:MinRAMPercentage=75.0 -XX:MaxRAMPercentage=75.0

-XX:NewRatio:这个参数用于设置新生代和老年代的比例,默认值是2,表示新生代:老年代=1:2。可以适当调整这个参数来改变新生代和老年代的大小比例,从而达到更好的内存利用效率。
-XX:MaxRAMFraction、-XX:MinRAMFractionDocker容器模式下,我们可以给每个JVM实例所属的POD分配任意大小的内存上限。比如,给每个账户服务分配4G,给每个支付服务分配8G。如此一来,启动脚本就不好写成通用的了,指定3G也不是,指定6G也不是。但是,有了这三个新增参数,我们就可以在通用的启动脚本中指定75%  
(-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=75 -XX:MinRAMPercentage=75

-XX:NewRatio:此次变更成了1,年轻代年老代的比例为1:1,既1536/1536

调整后出现的问题:正常设置完年轻代老年代的比例后,在查看grafana观察老年代的GC情况过程中发现老年代FullGC更加频繁,原因是JVM使用的是默认的gc算法(既Parallel、Serial),它会根据系统吞吐量自动调整内存使用,尤其是新生代的Survivor区的大小,导致对象很快进入老年代从而引发fullgc问题。(下面简单解释一下Full GC问题)

full gc:在JVM进行Full GC时,需要停止所有的应用程序线程,因此在Full GC期间应用程序无法执行。这意味着Full GC会对应用程序的性能和响应速度产生一定的影响,但不会对线程本身产生损害。具体来说,在进行Full GC时,JVM会暂停所有的应用程序线程,等待垃圾回收完成之后再恢复线程的执行。这个过程会导致应用程序暂停执行,造成一定的性能损失和响应延迟。

着重介绍一下吞吐量和暂停时间

吞吐量(throughput):吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)比如:虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%

这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的,吞吐量优先意味着在单位时间内STW的时间最短:0.2 + 0.2 = 0.4

STW: Stop-The-World: 是在垃圾回收算法执⾏过程当中,将JVM内存冻结,应用程序停顿的⼀种状态

真丶干货!记一次实实在在的线上JVM调优,过程较为曲折。

暂停时间(pause time):是指一个时间段内应用程序线程暂停,让 GC 线程执行的状态,例如,GC 期间 100 毫秒的暂停时间意味着在这 100 毫秒期间内没有应用程序线程是活动的。暂停时间优先,意味着尽可能让单次 STW 的时间最短:0.1+ 0.1+ 0.1 +0.1+0.1 = 0.5

真丶干货!记一次实实在在的线上JVM调优,过程较为曲折。

吞吐量与暂停时间的对比:

  • 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做 “生产性” 工作。直觉上,吞吐量越高程序运行越快

  • 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起,始终是不好的。这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序,因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。

不幸的是 “高吞吐量” 和 “低暂停时间” 是一对相互竞争的目标(矛盾)

相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。在设计(或使用) GC算法时,我们必须确定我们的目标: 一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷

现在标准:在最大吞吐量优先的情况下,降低停顿时间

第二次调整后\color{#FF0000}{第二次调整后}第二次调整后:-XX:NewRatio=1 -XX:MinRAMPercentage=75.0 -XX:MaxRAMPercentage=75.0 -XX:+UseConcMarkSweepGC

XX:+UseConcMarkSweepGC:此次变更为了CMS垃圾回收器,更换了GC算法

此次变更就是为了均衡默认垃圾回收器带来的吞吐量和暂停时间的痛点,再看一下CMS将堆内存区域的划分情况:

真丶干货!记一次实实在在的线上JVM调优,过程较为曲折。

为什么是这样子分配呢??

主要根据业务情况来定,例如高并发就尽可能把年轻代给大点,因为并发的对象大都是朝生夕死的,年轻代给大点 可防止突然的高并发把年轻代占满,使这些生命短暂的对象进到老年代,造成频繁full gc.

选用CMS注重于他提供的垃圾回收算法,既年轻代的复制 - 整理算法,老年代的标记 - 清理算法,下面简单介绍一下CMS的GC过程:

年轻代

  • 所有的年轻代首先会在Eden区进行分配,当Eden区满了之后会进行第1次Minor GC
  • 第1次GC之后仍然存活的对象,会复制到Survivor From区,同时对象年龄+1 (此时年龄=1),然后清理其之前占用的内存·
  • 第2次会对Edent+From同时进行GC,之后仍然存活的对象会复制到Survivor To区,年龄+1,同时清理之前占用的内存(此时From区会变成空)
  • 第3次GC之后,From区会存放存活的对象,而To区被清空
  • 以此类推
  • 当Suvivor区域对象的年龄达到 -xX:MaxTerurinothreshold 设定的值(默认15),会将此对象移到老年代,同时清空他们在年轻代占用的内存空间To区
-XX:MaxTenuringThreshold:这个参数用于设置对象进入老年代的阈值,默认值是15。可以根据应用程序的具体情况,适当调整这个参数,使得对象能够更加合理地晋升到老年代,从而减少 Full GC 的触发。

老年代

  • 初始标记:在这个阶段,需要虚拟机停顿正在执行的任务,既STW。该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,只扫描到能够和"根对象"直接关联的对象,并作标记。
  • 并发标记:这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。并发标记阶段,应用程序的线程和并发标记的线程并发执行,所以用户不会感受到停顿。该阶段进行GC ROOT TRACING,在第一个阶段被暂停的线程重新开始运行。并发标记阶段是和用户线程并发执行的过程。
  • 并发预清理:在这个阶段,虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代, 或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段"重新标记"的工作,因此重标记的工作尽可能多的在并发阶段完成来减少STW的时间。
  • 重新标记:这个阶段会暂停虚拟机,收集器线程扫描在CMS堆中剩余的对象。扫描从"跟对象"开始向下追溯,并处理对象关联。
  • 并发清理:清理垃圾对象,这个阶段收集器线程和应用程序线程并发执行。用户线程被重新激活,同时清理那些无效的对象。
  • 并发重置:这个阶段,重置CMS收集器的数据结构,CMS清除内部状态,等待下一次垃圾回收。
-XX:CMSInitiatingOccupancyFraction:这个参数用于设置 CMS 垃圾收集器在老年代达到多少使用率时开始进行垃圾回收,默认值是68。可以适当调整这个参数,使得 CMS 垃圾收集器能够更早地进行垃圾回收,从而减少 Full GC 的触发。

当老年代空间不够用了,会发生Full GC(回收整个堆内存)

总结: From和To区总是互相复制,每次GC之后总有其中一个区域会被清空

ps:当某些大对象需要分配一块较大的连续空间时,会直接进入老年代,而不会经过以上步骤

CMS优缺点

优点:并发收集、低停顿。其实最主要的是CMS把收集过程中步骤拆分了,而最耗时的操作都是并发执行,自然就会低停顿了。

缺点:产生大量空间碎片(可以通过配置重新整理,但是加长停顿时间)、并发阶段会降低吞吐量。无法处理浮动垃圾。

什么时候用CMS?

应用程序对停顿比较敏感,并且在应用程序运行的时候可以提供更大的内存和更多的CPU(也就是硬件牛逼),那么使用CMS来收集会给你带来好处。还有,如果在JVM中,有相对较多存活时间较长的对象(老年代比较大)会更适合使用CMS。

关于CMS写在后面:

对于CMS 收集器来说,最重要的是合理地设置年轻代和年老代的大小。年轻代太小的话,会导致频繁的 Minor GC,并且很有可能存活期短的对象也不能被回收,GC 的效率就不高。而年老代太小的话,容纳不下从年轻代过来的新对象,会频繁触发单线程 Full GC,导致较长时间的 GC 暂停,影响 Web 应用的响应时间。如果我们看年老代的内存使用率处在高位,导致频繁的 Full GC, 这样分析两种情况:

1.如果每次 Full GC 后年老代的内存占用率没有下来,可以怀疑是内存泄漏;
2.如果 Full GC 后年老代的内存占用率下来了,说明不是内存泄漏,我们要考虑调大年老代。

如果把年轻代和年老代都设置得很大,会有什么问题?

主要是会引起gc停顿时间过长,设置过大,回收频率会降低,导致单次回收时间过长,因为需要回收的对象更多,导致GC stop the world时间过长,卡顿明显,导致请求无法及时处理。

年轻代设置过大:

  • 生命周期长的对象会长时间停留在年轻代,在S0和S1来回复制,增加复制开销。
  • 年轻代太大会增加YGC每次停顿的时间,不过通过根节点遍历,OopMap,old scan等优化手段这一部分的开销其实比较少。
  • 浪费内存。内存也是钱

但是.....,此次调整完后仍有问题!!!!,而且形式更加严峻!!!!生成环境在中午12点10个副本都险些出现意外

问题:改为CMS(-XX:+UseConcMarkSweepGC)垃圾回收器后,此算法无法与新的jvm参数Min(Max)RAMPercentage协同工作,导致线上gc更加异常!

第三次调整后\color{#FF0000}{第三次调整后}第三次调整后:-Xms3g -Xmx3g -Xmn1536m -XX:+UseConcMarkSweepGC

主要调整内容就是将-XX:InitialRAMFraction、-XX:MaxRAMFraction、-XX:MinRAMFraction取容器资源的百分比调整为了固定的值,后面查了一下以上几个指标已经被标记deprecated(废弃),看来不是没有原因的,好在及时处理。

本次调整的重点就是选择合适的垃圾回收器 调整了一下相对稳定的 年轻代和年老代的空间占比,使其(吞吐量与暂停时间)达到一个相对均衡的点位

最后附一张近两天老年代的GC曲线,看起来效果还是不错的,共同学习吧。

真丶干货!记一次实实在在的线上JVM调优,过程较为曲折。

参考文档:

JVM Parameters InitialRAMPercentage, MinRAMPercentage, and MaxRAMPercentage | Baeldung