写点什么

JVM 实战—线上 FGC 的几种案例

  • 2025-04-11
    福建
  • 本文字数:13395 字

    阅读完需:约 44 分钟

1.如何优化每秒十万 QPS 的社交 APP 的 JVM 性能(增加 S 区大小 + 优化内存碎片)


(1)案例背景


本案例的背景是一个高峰期每秒有十万 QPS 的社交 APP。该 APP 每日有数百万的日活用户,用户操作最多的功能是浏览某个陌生人的个人页面,流量最大的功能模块是个人主页模块,并且高峰期在晚上。

 

所以会有大量活跃用户在一个集中的时间段内频繁的访问个人主页数据,而这类数据的量还通常很大,要包含很多信息。通常一个个人主页的数据甚至可能有几 M,大致可以认为:一次个人主页的查询,就会加载出大概 5M 的数据。而且一般在高峰期内,一些活跃用户会连续点击他感兴趣的个人主页,比如连续 1 个小时都在不停的点击。

 

所以该社交 APP 的高峰期 QPS 是很高的,假设这个社交 APP 流量最大的个人主页模块高峰期最多每秒有 10 万 QPS。当然在底层存储中,这些个人主页数据一定是基于缓存来存放的,个人主页模块会基于 Redis 缓存来查询个人主页数据。



(2)高并发查询导致对象快速进入老年代


由于每秒并发量太高,导致在高峰期这个系统的新生代 Eden 区被迅速填满并频繁触发 YGC。如下图示:



而且每次在 YGC 时,还有很多请求是没处理完毕的。因为每秒请求太多,所以在触发 YGC 一瞬间,必然有很多请求没处理完。这就导致每次 YGC 时,Eden 区都会有很多对象需要存活下来。如下图示:



因此在高峰期经常出现 YGC 后存活对象较多,在 S 区中放不下的问题。如下图示:



于是又会导致大量对象快速进入老年代,如下图示:



(3)老年代必然会触发频繁 GC


一旦在高并发场景下 YGC 后存活对象过多,导致对象快速进入老年代,必然会频繁触发老年代 GC,对老年代进行垃圾回收。所以上述 APP 在高峰期会出现主页服务对应的 JVM 频繁发生老年代 GC,如下图示:



(4)优化前的线上系统 JVM 参数


针对上述场景,最核心的优化点是:

一.增加机器,尽量让每台机器承载更少的并发请求,减轻压力

二.给新生代的 Survivor 区更大内存空间,让每次 YGC 后的存活对象停留在 Survior 区,别进入老年代

 

但是这里先不考虑上述优化,在优化前的线上系统中,JVM 有两个比较关键的参数如下:


 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5
复制代码


由于 CMS 垃圾回收器采用标记清理算法,所以 CMS 回收后会造成大量的内存碎片。上述两个参数就指定了:在 5 次 FGC 后会触发一次 Compaction 压缩操作。这个压缩操作会把存活对象放到紧邻在一起,避免出现大量内存碎片。

 

(5)频繁 Full GC 导致的大量内存碎片


 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5
复制代码


上述参数会设置 5 次 FGC 后才进行一次压缩操作,以此来解决内存碎片问题,以便可以空出大片连续可用内存空间。

 

所以这就导致这 5 次 FGC 过程中,每次 FGC 后都会产生大量的内存碎片。大量内存碎片又会导致很多问题,其中一个问题就是提高了 FGC 频率。

 

因为触发老年代 GC 的一个重要条件:就是 YGC 后的存活对象无法放入 Survivior 要放入老年代。如果老年代也没足够的连续可用内存放这些对象,那就必须触发 FGC 了。

 

所以假设一次 FGC 过后,老年代中有一部分内存里都是大量的内存碎片。没法放入完整的一些大对象,只有部分内存是连续可用的内存空间。如下图示:



大量对象快速进入老年代会导致老年代的连续可用内存很快就满了,此时很多内存碎片是无法放入更多对象的,于是就会触发一次 FGC。

 

比如老年代有 2G 内存,其中 1.5G 是连续可用的,0.5G 是内存碎片。如果老年代中都是连续空闲内存,则对象占用达将近 2G 时才会触发 FGC。但现在对象占用达 1.5G 就触发 FGC 了,剩下 0.5G 是没法放入存活对象的。所以就会产生如下的问题:每进行一次 FGC,老年代就会产生更多内存碎片,内存碎片越来越多。内存碎片越来越多会导致连续可用内存越来越少,更快触发下次 FGC。直到几次 FGC 后,才会触发一次 Compaction 操作去整理内存碎片。

 

(6)这个案例如何进行优化


一.增加新生代和 Survivor 区大小


用 jstat 分析各机器的 JVM 运行状况,然后判断每次 YGC 后存活对象大小。然后增加 Survivor 区的内存,避免对象快速进入老年代。

 

虽然增加了新生代和 Survivor 区大小,但还是会慢慢有对象进入老年代。毕竟系统负载很高,彻底让对象不进入老年代也很难做到。所以当时增加新生代和 Survivor 区大小后,每小时还是会有一次 FGC。

 

二.优化 CMS 内存碎片


针对 CMS 内存碎片问题进行优化,在降低 FGC 频率后,务必设置如下参数:


 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0
复制代码


这两个参数的意思是:每次 FGC 后都整理一下内存碎片。如果进行很多次 FGC 才整理一下内存碎片:那么每次 FGC 过后,老年代内存碎片会越来越多,下次 FGC 会更快到来。也就是如果不及时解决 CMS 内存碎片问题,就会导致 FGC 越来越频繁。比如第一次 FGC 是一小时后,第二次是 40 分钟后,第三次是 20 分钟后。

 

2.如何对垂直电商 APP 后台系统的 FGC 进行深度优化(定制 JVM 参数模版)


(1)垂直电商业务背景


国内有很多中小型垂直类电商公司的,主要做一些细分领域的电商业务。比如有的 APP 专门做消费分期类电商业务,在该 APP 里购物可分期付费。有的 APP 专门做服装定制、有的 APP 是做时尚潮流服饰等。

 

某垂直电商 APP,注册用户数百万,每日活跃用户也就几十万。每天 APP 的整体请求量也就几千万,高峰期的 QPS 也就每秒数百请求。

 

这个 APP 虽然不大,看起来很普通,且它的后台系统也不会有多大压力。但同样也可能会有 JVM 相关的性能问题,需要进行一些细致的优化。

 

(2)垂直电商 APP 的 JVM 性能问题


类似这样的一个垂直电商 APP,它会出现哪些 JVM 性能问题呢?

 

问题就出在类似这样的一个创业型公司:虽然有少数架构师,但大部分一线工程师可能对 JVM 都没那么精通。架构师又没那么多精力把控细节的地方,所以直接导致一个很大的问题,就是大部分一线工程师开发完系统后,上线时不对 JVM 进行参数设置。可能很多时候都是使用一些默认 JVM 参数,当系统负载逐渐增高时这些默认参数就会有问题。

 

如果不设置-Xmx、-Xms 之类的堆内存大小:那么启动一个系统,默认会给堆内存几百 M、新生代和老年代也是几百 M。

 

所以该公司的很多后台系统,基本都是采用默认 JVM 参数部署启动的。前期没什么问题,但中后期开始,有一定用户量和负载就会出现问题了。

 

默认参数下:新生代内存过小,会导致 S 区内存过小,同时 Eden 区也会过小。Eden 区过小,就会导致频繁触发 YGC。Survivor 区过小,就会导致放不下 YGC 后的存活对象,只能进入老年代。从而导致老年代很快就会放满了,然后频繁触发 FGC。

 

所以该公司的垂直电商 APP 的各个系统通过 jstat 分析 JVM GC 后发现:基本上在高峰期的时候,每小时都会发生好几次 FGC。

 

一般在正常情况下,FGC 都是以天为单位发生的。比如每天发生一次 FGC,或者几天发生一次 FGC。如果每小时都发生几次 FGC,那么就会导致系统每小时都卡顿好几次。

 

所以我们可以在分析系统情况后,给该公司定制一套 JVM 参数模板。在大部分工程师都对 JVM 优化不是很精通的情况下:通过推行一个 JVM 参数模板,可让各系统短时间内就优化好 JVM 的性能。

 

(3)公司级别的 JVM 参数模板


其实这个公司级别的或者团队级别的 JVM 参数模板,是很有用的,因为并不是每位一线开发都精通 JVM 的核心运行原理和性能优化。

 

所以作为一个团队的 Leader,或者是一个中小型公司的架构师,那么必然需要为团队或者公司定制一套基本的 JVM 参数模板。然后尽量让大部分系统套用这个模板,基本保证 JVM 性能别太差。避免初中级工程师直接使用默认的 JVM 参数,比如可能 8G 内存的机器,JVM 堆内存只分配了几百 M。

 

下面是为该公司定制出来的、适合这种创业公司的、JVM 参数模板:


 -Xms4096M -Xmx4096M -Xmn3072M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC  -XX:CMSInitiatingOccupancyFraction=92  -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0
复制代码


为什么如此定制 JVM 参数模板呢?

 

一.首先 8G 内存的机器,分配 4G 内存给 JVM 堆,是比较合理的


因为可能还有其他进程会使用内存,一般不让 JVM 堆内存占满机器内存。

 

二.然后分配 3G 内存给新生代(因为基本高峰期每次 YGC 存活几十 M 对象)


之所以分配 3G 是因为能尽量让新生代大一些,从而让 S 区有 300M 左右。假设用默认的 JVM 参数,可能新生代就几百 M,Survivor 区就几十 M。根据对该业务系统的分析,每次垃圾回收过后存活对象可能会有几十 M。因为在垃圾回收时可能有部分请求没处理完,此时会有几十 M 对象存活。在默认参数下很容易触发动态年龄判定规则,让部分对象进入老年代。

 

所以应该给新生代更大内存空间,让 Survivor 区的空间也更大。这样即使在 YGC 瞬间有部分请求没处理完毕,有几十 M 的存活对象。这时在几百 M 的 Survivor 空间中也可以轻松放下,而不会进入老年代。

 

基本在这个内存分配下,该公司的大部分后台业务系统都没问题了。不同系统运行的情况略有不同,但基本每次 YGC 后都会存活几十 M 对象。所以这个 JVM 参数模板,都可以适用。

 

只要按上述 JVM 参数模板分配内存,那么对象进入老年代速度会很慢。该公司的全部系统,配合这个 JVM 参数模板的重新部署和上线后。通过 jstat 观察各系统,基本上发现 FGC 变成了几天才会发生一次。

 

三.参数模板里加入 Compaction 相关的参数


保证每次 FGC 后都执行一次压缩,避免内存碎片。

 

(4)如何优化每次 FGC 的性能


下面介绍进行 JVM 优化时可能会调整的两个参数,这两个参数可以优化 FGC 的性能,把每次 FGC 的时间进一步降低一些。

 

一.-XX:+CMSParallelInitialMarkEnabled


这个参数会在 CMS 垃圾回收器的"初始标记阶段"开启多线程并发执行,CMS 在初始标记阶段,是会进行 Stop the World 的,这会导致系统停顿。所以该阶段开启多线程并发,可以尽量优化该阶段性能,减少 STW 时间。

 

二.-XX:+CMSScavengeBeforeRemark


这个参数会在 CMS 的重新标记阶段前先尽量执行一次 YGC,这样做有什么作用呢?因为 CMS 的重新标记也是会 Stop the World 的。所以在重新标记前,先执行一次 YGC,就能回收一些新生代的垃圾对象。如果能提前回收一些垃圾对象,在重新标记阶段就可以少扫描一些对象。此时就可以提升 CMS 的重新标记阶段的性能,减少耗时。

 

所以在 JVM 参数模板中也加入这两个参数:


 -Xms4096M -Xmx4096M -Xmn3072M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC  -XX:CMSInitiatingOccupancyFaction=92  -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0  -XX:+CMSParallelInitialMarkEnabled -XX:+CMSScavengeBeforeRemark
复制代码


(5)采用 JVM 参数模板后的效果


经过使用 jstat 观察各业务系统的 JVM GC 情况,发现明显有很大好转。基本上各系统的 YGC 都是几分钟~十几分钟一次,每次耗时几十毫秒,FGC 基本都在几天一次,每次耗时也在几百毫秒。

 

当各个系统的 JVM 达到上述这个 GC 情况,就对线上系统没多大影响了。哪怕不太懂 JVM 优化的开发只要套用这个模板,那么对一些普通系统,都能保证 JVM 性能不出现大问题,比如频繁 YGC 和 FGC 导致频繁卡顿。

 

3.不合理设置 JVM 参数可能导致频繁 FGC(优化反射的软引用被每次 YGC 回收)


(1)案例背景


这个案例是因为新手工程师对 JVM 优化了解不足,然后不知道从哪里找来一个非常特殊的 JVM 参数进行了错误设置,从而导致线上系统频繁出现 FGC。

 

(2)问题的产生


这个场景的发生过程大致如下:某天团队里一个新手工程师心血来潮,在当天上线一个系统时,自作主张设置了某个 JVM 参数。设置了完这个 JVM 参数后,就导致线上频繁接到 JVM 的 FGC 告警。大家就很奇怪,于是就开始排查那个系统。

 

(3)查看 GC 日志


一般公司都会接入类似 Zabbix、OpenFalcon 或自研的一些监控系统。监控系统一般都做的很好,可以直接接入业务系统。然后在监控系统上看到每台机器的 CPU、磁盘、内存、网络的一些负载、JVM 内存使用波动折线图、JVM GC 发生的频率折线图、甚至业务系统自己上报的某些业务指标情况。而且一般都会针对线上运行的机器和系统设置一些告警,比如可以设置如果发现系统 10 分钟内发生超过 3 次 FGC,就发送告警。

 

一.一旦发生告警,可以登录到线上机器,查看对应的 GC 日志,此时发现 GC 日志中有大量 FGC 记录。

 

二.那么是什么原因导致 FGC 呢?在日志里,看到了包含"Metadata GC Threshold"关键字的日志:


[Full GC(Metadata GC  Threshold)xxxxx, xxxxx]
复制代码


从这里可知,频繁的 FGC 就是由 Metaspace 元数据区(永久代)导致的,这个 Metaspace 区域一般是放一些加载到 JVM 的类。

 

三.为什么 Metaspace 元数据区会频繁被占满而触发 FGC?根据 FGC 定义,FGC 是针对年轻代、老年代、永久代进行的整体的 GC,所以 FGC 会进行 Metaspace 元数据区的垃圾回收。



(4)查看 Metaspace 内存占用情况


接着需要看一看 Metaspace 区域的内存占用情况,简单点可以通过 jstat 来观察。如果有监控系统,监控系统会展示出 Metaspace 内存占用的波动曲线图,类似如下图示:


看起来 Metaspace 区的内存呈现一个波动的状态,它总是会先不断增加,达到一个顶点后,就会把 Metaspace 区占满。然后就触发一次 FGC,FGC 会回收 Metaspace 区的垃圾,所以接下来 Metaspace 区的内存占用又变得很小了。

 

(5)一个综合性的分析思路


很明显,系统在运行过程中,不停地产生新的类。然后这些类被加载到 Metaspace 区,逐渐把 Metaspace 区占满,接着触发一次 FGC 回收掉 Metaspace 区中的部分类。这个过程不断循环,从而造成 Metaspace 区反复被占满,导致反复 FGC。如下图示:



(6)到底是什么类在不停地被加载


到底是什么类不停被加载到 JVM 的 Metaspace 里?这时可以在 JVM 启动参数中加入如下两个参数:


 -XX:TraceClassLoading -XX:TraceClassUnloading
复制代码


这两个参数会追踪类加载和类卸载的情况,会通过日志打印出 JVM 中加载了哪些类、卸载了哪些类。

 

加入这两个参数后,就可以看到在日志文件中,输出了一堆日志,日志里面显示类似如下内容:


[Loaded sun.reflect.GeneratedSerializationConstructorAccessor from __JVM_Defined_Class]
复制代码


可以看到,JVM 在运行期间不停地加载了大量的类到 Metaspace 区域里,这个类就是 GeneratedSerializationConstructorAccessor。如下图示:



就是因为在 JVM 运行期间不停地加载这种奇怪的类,然后不停地把 Metaspace 区域占满,最后才会频繁引发不停地执行 FGC。

 

所以频繁 FGC 不仅仅只会由老年代触发,有时也会因为 Metaspace 区的类太多而触发。

 

(7)为什么会频繁加载奇怪的类


遇到这种问题,先看这种不停被加载的类到底是什么类。是业务系统自己的类,还是 JDK 内置的类?

 

如果查阅一些资料就很容易明白,其实这个类 GeneratedSerializationConstructorAccessor 会在使用 Java 的反射时被加载。

 

反射代码类似如下:


Method method = XXX.class.getDeclaredMethod(xx, xx);method.invoke(target, params);
复制代码


简单来说,就是首先通过 XXX.class 获取某个类。然后通过 getDeclaredMethod 获取该类的方法,这个方法是一个 Method 对象,通过 Method.invoke 可以调用该类的方法。

 

在执行这种反射代码时,JVM 会在反射调用一定次数后动态生成一些类。这些类就是类似 GeneratedSerializationConstructorAccessor 的类,这样下次再执行反射时,就可以直接调用这些类的方法,这属于 JVM 底层的一个优化机制。

 

所以我们可以得出如下结论:如果代码里大量使用了反射,那么 JVM 就会动态生成一些类并放入到 Metaspace 区域里。如下图示:



(8)JVM 创建的奇怪类有什么玄机


JVM 为什么会不停地创建那些奇怪的类然后放入到 Metaspace 中?因为上面这种 JVM 创建的类,其 Class 对象都是 SoftReference 软引用的。

 

一.每个类本身也是一个 Class 对象


一个 Class 对象就代表了一个类,同时这个 Class 对象代表的类,可以派生出来很多实例对象。比如 Class Boy 就是一个类,它本身是由一个 Class 类型的对象来表示。但如果 Boy boy = new Boy(),那么就是实例化了这个 Boy 类的一个对象,这个对象就是一个 Boy 类型的实例对象。

 

二.JVM 在反射中动态生成的类的 Class 对象都是 SoftReference 软引用的


这里所说的 Class 对象,是 JVM 在反射过程中动态生成的类的 Class 对象,这些 Class 对象都是 SoftReference 软引用的。

 

三.软引用和软引用需要在 YGC 时回收的公式


所谓的软引用,正常情况下不会回收,但如果内存比较紧张就会回收。那么 SoftReference 对象在 YGC 时要不要回收是怎么进行判断的呢?SoftReference 对象需要在 YGC 时回收的判断公式如下:


clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB
复制代码


这个公式的意思是:clock - timestamp 代表了一个软引用对象它有多久没被访问过了,freespace 代表 JVM 中的空闲内存空间,SoftRefLRUPolicyMSPerMB 代表:每 M 空闲内存空间可以允许 SoftReference 对象存活多久。

 

四.举个例子


假设现在 JVM 创建了一大堆奇怪的类,而且这些类本身的 Class 对象都是被 SoftReference 软引用的,然后现在 JVM 里的内存空间有 3000M,SoftRefLRUPolicyMSPerMB 默认是 1000 毫秒。

 

那么就意味着:那些奇怪的被 SoftReference 软引用的 Class 对象,可以存活 3000 * 1000 = 3000 秒 = 50 分钟。

 

一般发生 GC 时,其实 JVM 内部或多或少总有一些空闲内存的,所以基本上如果不是快要发生 OOM 内存溢出了,软引用也不会被回收。

 

所以 JVM 理应会:随着反射代码的执行,动态创建一些奇怪的类,这些类的 Class 对象都是被 SoftReference 软引用的。在正常情况下这些 Class 对象不会被回收,但也不应快速增长。

 

(9)为什么 JVM 创建的奇怪的类会不停地变多


因为那个新手工程师把 SoftRefLRUPolicyMSPerMB 参数,直接设置为 0。他希望一旦这个参数设置为 0,任何软引用对象都可以尽快释放掉。从而尽量释放内存空间出来,这样就可以提高内存利用效率了。但实际上一旦这个参数设置为 0 后,直接会导致:


clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB
复制代码


这个公式的右半边是 0,从而导致所有的软引用对象,刚创建出来就可以被 YGC 回收掉。如下图示:



比如 JVM 好不容易给动态生成 100 个奇怪的类,结果因为设置软引用的这个参数为 0,就导致在一次 YGC 时,就回收掉堆里面的这几十个类对象,但是 Metaspace 中对应的类信息并没有被回收掉。

 

接着 JVM 在反射代码执行的过程中,还会继续创建这种奇怪的类。在 JVM 的机制下,会导致 Metaspace 中这种奇怪类越来越多。

 

下次 YGC 又会回收掉一些奇怪的类对象,但 JVM 马上又继续生成这种类,最终导致 Metaspace 区被占满了。一旦 Metaspace 区域被占满,就会触发 FGC,然后回收掉很多类,接着再次重复上述循环。如下图示:



为什么软引用的类被快速回收后,会导致 JVM 不停创建更多的新的类呢?这涉及到底层 JDK 源码实现,比较复杂,要详细分析 JDK 底层实现细节。

 

(10)如何解决这个问题


在有大量反射代码的场景下:只要把-XX:SoftRefLRUPolicyMSPerMB=0 这个参数值设置大一些即可。千万不能设置为 0,可以设置个 1000、2000、3000、或者 5000 毫秒,让反射过程中 JVM 自动创建的一些类的 Class 对象不要被随便回收。

 

(11)案例总结


因为-XX:SoftRefLRUPolicyMSPerMB=0,导致 YGC 时回收了调用反射时 JVM 创建的大部分软引用对象(在堆中),导致下一次调用反射又继续创建类和 Class,而 Class 被放在元空间,从而导致元空间很快满了,于是就触发 FGC。

 

多次调用反射时,会因为 NativeMethodAccessor 的次数影响(默认为 15 次),而生成最终的类似于 generateXXXAccessorXXX 这样的类。generateXXXAccessorXXX 类会将反射调用转化为本地调用,提升性能。但如果 generateXXXAccessorXXX 类的软引用被回收了,就会导致元数据区多次生成相同的类,导致元数据区很快占满,触发 FGC。

 

4.线上系统每天数十次 FGC 导致频繁卡顿的优化(大对象问题)


(1)背景


一个运行良好的系统,应该几天一次 FGC,或者最多一天几次 FGC。但有个新系统上线后,发现一天的 FGC 次数高达数十次,甚至上百次。可见这个新系统在线上的表现非常不好,明显会存在经常性的卡顿。因此要进行一连串的排查、定位、分析和优化,下面介绍整个优化过程。

 

(2)未优化前的 JVM 性能分析


通过监控平台 + jstat 工具分析,可以得出该系统优化前的 JVM 性能表现:

一.机器配置是 2 核 4G

二.JVM 堆内存大小是 2G

三.系统运行时间是 6 天

四.系统运行 6 天内发生的 FGC 次数和耗时是 250 次和 70 多秒

五.系统运行 6 天内发生的 YGC 次数和耗时是 2.6 万次和 1400 秒

 

综合分析可知:每天会发生 40 多次 FGC,每小时 2 次,每次 FGC 在 300 毫秒左右;每天会发生 4000 多次 YGC,每分钟发生 3 次,每次 YGC 在 50 毫秒左右。

 

上述数据对任何一个线上系统,都可以用 jstat 轻松看出来。因为 jstat 显示出来的 FGC 和 YGC 的次数都是系统启动以来的总次数,jstat 显示的耗时都是所有 GC 加起来的总耗时,所以可直接拿到上述数据。

 

所以整体看来,这个系统的性能比较差。每分钟 3 次 YGC,每小时 2 次 FGC,必须要进行优化了。

 

(3)未优化前的线上 JVM 参数


未优化前的线上 JVM 参数如下:


 -Xms1536M -Xmx1536M -Xmn512M -Xss256K  -XX:SurvivorRatio=5 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC  -XX:CMSInitiatingOccupancyFraction=68  -XX:+CMSParallelRemarkEnabled -XX:+UseCMSInitiatingOccupancyOnly  -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC
复制代码


上述参数基本上和我们前面看到的参数没多大不同:一个 4G 的机器,给 JVM 堆内存设置 1.5G,其中新生代 512M,老年代 1G。

 

-XX:SurvivorRatio 设置了 5,即 Eden : S1 : S2 的比例是 5 : 1 : 1。所以此时 Eden 区大致为 365M,每个 Survivor 区域大致为 70M。

 

-XX:CMSInitiatingOccupancyFraction 设置了 68,所以一旦老年代内存占用达到 68%,大概 680M 时,就会触发一次 FGC。

 

此时整个系统的内存模型图如下:



(4)根据线上系统的 GC 情况倒推运行内存模型


接下来根据系统的内存模型及 GC 情况,推导出系统运行时的内存模型。

 

一.首先可以知道每分钟会发生 3 次 YGC


这说明系统运行 20 秒就会让 Eden 区满了,也就是产生 300 多 M 的对象。所以平均下来系统每秒会产生 15~20M 的对象,20 秒左右就会导致 Eden 区满,然后触发一次 YGC。

 

二.接着根据每小时 2 次 FGC 推断出,每 30 分钟会触发一次 FGC


-XX:CMSInitiatingOccupancyFraction=68 表示:当 1G 的老年代有 68%空间(600 多 M)被占满时,就会触发 CMS 的 GC。再根据每小时 2 次 FGC 推断出,每 30 分钟会触发一次 FGC。所以系统每运行 30 分钟就会导致老年代里有 600 多 M 的对象,从而触发 CMS 垃圾回收器对老年代进行 GC。如下图示:



所以根据 JVM 实际运行情况 + JVM 参数内存模型,可以得出如下结论:每隔 20 秒会让 365M 的 Eden 区占满,从而触发一次耗时 50 毫秒的 YGC;每隔 30 分会让 680M 的老年代占满,从而触发一次耗时 300 毫秒的 FGC。

 

三.此时其实可以进行猜测


是不是因为 Survivor 区域太小了,导致 YGC 后的存活对象太多放不下,就一直有对象流入老年代,从而导致 30 分钟后触发 FGC。

 

为什么老年代里有那么多对象?一.可能是每次 YGC 后存活对象较多而 S 区放不下,或触发动态年龄判断;二.也可能是有很多长时间存活对象,都积累在老年代,始终回收不掉,从而导致老年代很容易达到 68%的占比,触发 GC。但仅仅是分析而已,还不能轻易下结论。

 

(5)老年代里到底为什么会有那么多的对象


分析到这里,仅仅根据可视化监控和推论是没法往下分析了,因为我们并不知道老年代里到底为什么会有那么多的对象。

 

一.此时可以用 jstat 在高峰期观察一下 JVM 实际运行的情况


通过 jstat 的观察可以明确看到,每次 YGC 过后升入老年代的对象很少。一般来说,每次 YGC 过后大概会存活几十 M 对象。由于 Survivor 区只有 70M,所以很容易会触发动态年龄判断规则,导致偶尔一次 YGC 过后有几十 M 对象进入老年代。如下图示:



因此分析到这里就很奇怪:通过 jstat 追踪观察,并不是每次 YGC 后都有几十 MB 对象进入老年代的。而是偶尔一次 YGC 才会有几十 MB 对象进入老年代,是偶尔一次而已。所以正常来说,应该不至于 30 分钟就导致老年代占用空间达到 68%。

 

二.为什么老年代里会有那么多对象


通过 jstat 观察到一个现象:在系统正常运行时,会突然出现五六百 M 的对象进入老年代。如下图示:



正是因为在系统运行时,突然有几百 M 对象进入老年代,所以才导致:即使偶尔一次 YGC 才有几十 M 对象进入老年代,也平均 30 分一次 FGC。

 

三.为何系统运行时会突然有几百 M 对象进入老年代


原因只能是大对象了。系统运行时,每隔一段时间会突然产生几百 M 的大对象。这些大对象会直接进入老年代,不会进入新生代的 Eden 区。然后加上新生代偶尔一次 YGC 才有几十 M 对象进入老年代,所以才出现 30 分钟触发一次 FGC。如下图示:



(6)定位系统的大对象


分析到这里问题就很简单了,接下来只需要通过 jstat 工具,观察系统什么时候老年代里会突然进入几百 M 的大对象,然后在这个时候紧接着使用 jmap 工具导出一份 dump 内存快照。

 

接着可以使用 jhat 等可视化工具来分析 dump 内存快照,通过内存快照的分析,定位出那个几百 MB 的大对象。可能是几个 Map 之类的数据结构,大概率是从数据库查出来的大对象。

 

接下来可以地毯式排查这个系统的所有 SQL 语句,找出可能导致每隔一段时间系统会出现几个上百 M 大对象的 SQL 查询,然后进行优化调整。

 

(7)针对本案例的 JVM 和代码优化


第一步:让开发解决代码中的 bug,避免一些极端情况下 SQL 语句会查出大量数据,从而导致出现大对象。

 

第二步:新生代明显过小,Survivor 区空间不够,只有 70MB。由于每次 YGC 后存活几十 M 对象,容易触发动态年龄判定进入老年代,所以直接调整 JVM 参数如下:


 -Xms1536M -Xmx1536M -Xmn1024M -Xss256K  -XX:SurvivorRatio=5 -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC  -XX:CMSInitiatingOccupancyFraction=92  -XX:+CMSParallelRemarkEnabled -XX:+UseCMSInitiatingOccupancyOnly  -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC
复制代码


直接把新生代空间调整为 1G,每个 Surivor 是 150M 左右。老年代只留 500MB 就够了,因为一般不会有对象进入老年代。

 

-XX:CMSInitiatingOccupancyFraction 的参数值调整为 92,避免老年代仅占用 68%就触发 GC,调整为要占用到 92%才会触发 GC。

 

最后主动设置永久代大小为 256MB,因为如果不主动设置永久代,默认的永久代只有几十 M。万一系统运行时采用反射,一旦动态加载的类过多,就会频繁触发 FGC。

 

这几个步骤优化完毕后,线上系统就表现非常好了,基本上每分钟发生一次 YGC,一次在几十毫秒。而 FGC 几乎就很少,大概几天才会发生一次,一次就耗时几百毫秒而已。

 

5.电商大促活动下严重 FGC 导致系统直接卡死的优化(System.gc()导致)


有一个新系统上线,平时都还算正常。结果有一次大促活动时,这个系统就直接卡死不动了。这个系统无法处理所有请求,重启这个系统也没有任何效果。

 

这时按照前面的思路去分析 JVM 的 GC 问题,首先考虑是不是由于频繁的 GC 问题导致系统被卡死。

 

一.jstat 发现每秒都有一次 FGC


首先使用 jstat 去查看系统运行情况,发现奇怪的事情:JVM 几乎每秒都执行一次 FGC,每次都耗时几百毫秒。

 

二. 各指标都正常为什么会频繁触发 FGC


继续通过 jstat 查看 JVM 各个内存区域的使用量,基本都没什么问题。年轻代对象增长并不快,老年代才占用了不到 10%的空间,永久代也就使用了 20%左右的空间,各个指标都正常,为什么会频繁触发 FGC?

 

三.是不是系统里出现一行致命代码:System.gc()


这个 System.gc()可不能随便写,System.gc()的每次执行都会指挥 JVM 去尝试执行一次 FGC,System.gc()的每次执行都会回收新生代、老年代、永久代。

 

所以马上找到负责对这个系统进行开发的同学,让他进行排查代码,结果还真的找到了。他使用 System.gc()的出发点是好的,他是这么考虑的:代码里经常会加载出大批数据,一旦请求处理完,这些数据就废弃不用。此时这些数据会占据太多内存,于是想用 System.gc()触发 GC 回收它们。

 

结果平时系统运行时,访问量很低,基本不会出问题。但到了大促活动的时候,由于访问量太高。执行 System.gc()代码太频繁,导致频繁触发 FGC,从而让系统直接卡死。

 

所以针对这个问题:一方面平时写代码时,不要使用 System.gc()去随便触发 GC。另一方面可以在 JVM 参数中加入-XX:+DisableExplicitGC,这个参数的意思就是禁止显式执行 GC,不允许通过代码触发 GC。

 

所以推荐将-XX:+DisableExplicitGC 参数加入到系统的 JVM 参数中,或者是加入到公司的 JVM 参数模板中,避免有的开发好心办坏事,导致频繁触发 GC。


6.问题汇总


问题一:


STW 的时候,系统停止,请求发生阻塞。如果老年代 STW 时间比较长,阻塞了很多请求。等这次老年代垃圾回收完,被阻塞的请求开始处理,又会创建很多对象。于是对 JVM 造成压力,然后老年代又要 GC。所以是不是 STW 时间久的话,会变相给系统制造更高的并发?

 

答:是的。因为阻塞了很多请求,确实会造成 STW 恢复后的瞬时处理请求增多。

 

问题二:


2 核 4G 或 4 核 8G 的 Linux 机器,能够分配给堆内存的大小最大能有多少?

 

答:一般不能给到最大,操作系统和其他进程都要占用一些。比如 4G 的机器,给 JVM 的内存可以是 2G~3G。8G 的机器,给 JVM 的内存可以是 4G 左右,这样就差不多了。

 

问题三:


G1 对于大对象的判定规则是超过 Region 的 50%,可以指定超过 60%吗?

 

答:G1 有参数可以控制大对象,但建议不改变。

 

问题四:


有一个有趣的现象:为什么-XX:CMSFullGCsBeforeCompaction = 5 是大部分公司的设置呢?

 

答:确实有不少博客会推荐设置为 5。但要考虑一下,如果通过优化之后,让 FGC 的频率很低。那么就完全可以让每次 FGC 后都 Compaction 一次,FGC 慢点而已,但是不至于大量内存碎片导致下一次 FGC 更快到来。

 

问题五:


分析公司的系统,服务器不接受任何请求的情况下:大概 16 分钟左右 1 次 YGC,1.5 天左右 1 次 FGC。每次 YGC 有 8M 对象进入老年代,但服务器每次启动都会有 3-4 次 FGC,服务器启动时的 FGC 是否需要优化?

 

答:服务器启动的时候很多内置对象,初始化之类的,这个不需要优化,需要的优化核心是运行期间。

 

问题六:


是不是高并发 + 慢处理是导致 FGC 的元凶之一,会带来很多性能问题。

 

答:是的。

 

问题七:


G1 相对其他回收器有什么劣势,很多地方不用 G1 是否因为没必要?

 

答:G1 未来会成为一个默认的垃圾回收器。好处是只要指定一个垃圾回收停顿时间,G1 就自动优化,无需过度优化,坏处是没法精准把控内存分配、使用、垃圾回收,所以有时优先使用 ParNew + CMS。

 

问题八:


由于 CMS 是扫描老年代的对象,那么在重新标记之前进行一次 YGC,YGC 回收新生代的对象,对提升重新标记的性能帮助在哪?

 

答:CMS 扫描老年代的对象是没错的,但有时新生代和老年代之间的对象有引用关系,就会扫描到新生代里。所以提前 YGC 可以清理掉一些新生代对象,这可以有助于提升 CMS 的重新标记阶段的性能。

 

问题九:


一.JVM 内存超过 4G 且对系统响应时间敏感的是不是应该采用 G1?


二.对高并发、容易产生阻塞的系统,是否考虑减小 SurvivorRatio 的值?这样可以给 S 区分配更大的空间,避免短命的对象进入老年代。这样也可能会导致 YGC 会更频繁些,但 YGC 很快,所以关系不太大。

 

答:一.超过 4G 还不至于必须用 G1,一般超过 16G 以上的机器可以考虑用 G1。普通的机器都是 2C4G 或 4C8G,这种机器使用 CMS+ParNew 没问题的。


二.高并发、大数据量的系统,建议还是根据实际情况去优化各种参数,具体方法参考前面介绍的那种思路。核心系统一定是要使用一整套流程来优化 JVM 参数的:预估系统的并发量 -> 选择合适的服务器配置 -> 根据 JVM 内存模型设定初始参数 -> 对系统进行压测 -> 观察高峰期对象的增长速率、GC 频率、GC 后的存活 -> 然后根据实际的情况来设定 JVM 参数 -> 最后做好线上 JVM 监控。

 

问题十:


CMSScavengeBeforeRemark 参数是希望在 CMS 重新标记前进行 YGC,好处是如果 YGC 比较有效果则是能有效降低重新标记的时间长度。可以理解为如果大部分新生代对象被回收了,那么作为根的部分就少了,从而提高了 CMS 重新标记的效率。

 

问题十一:


可能新生代的某个 GC Root 引用了老年代某对象,这个对象就不能清除,所以 CMS 应该也要扫描年轻代 GC Root,所以再进行一次 YGC 就可以减少扫描的年轻代 GC 链路。另外 G1 基于 Region 收集,通过记忆集记录引用关系来避免全堆扫描。

 

问题十二:


由于 CMS 重新标记阶段需要扫描新生代,所以整个堆中对象数量会影响 Remark 阶段的耗时,所以 Remark 之前添加一次可中断的并发预清理。

 

另外为了防止并发预清理阶段等太久都不发生 YGC,提供了 CMSMaxAbortablePrecleanTime 参数,该参数可以设置等待多久没等到 YGC 就强制进行重新标记,默认是 5s。

 

但是最终一劳永逸的办法是:添加参数 CMSScavengeBeforeRemark,让 Remark 之前强制 YGC。

 

问题十三:


为什么重新标记时提前做一次 YGC 会提高效率?重新标记不是只针对老年代的对象进行标记的吗?

 

答:老年代扫描时要确认老年代里的存活对象,这时会扫描到新生代。因为有些新生代的对象可能引用了老年代的对象,所以提前做 YGC 可以把年轻代里一些对象回收掉。从而减少了扫描新生代的时间,可以提升性能。

 

问题十四:


CMS 垃圾回收不同阶段的处理总结:

初始标记:标记由 GC Roots 直接关联的对象

并发标记:对老年代所有对象进行追踪,看能否与 GC Roots 建立关系

最终标记:标记并发标记时引用变动的对象

并发清理:并发清理掉可回收的内存,但是因为用户线程依旧在运行,所以每次 FGC 都会并发清理不干净,产生浮动垃圾

 

问题十五:


JVM 问题的排查步骤总结:

步骤一:使用 jstat 分析机器情况:机器配置、堆内存大小、运行时长、FGC 次数时间、YGC 次数时间

步骤二:查看具体的 JVM 参数配置

步骤三:根据 JVM 参数配置梳理出 JVM 模型

步骤四:结合 jstat 查看的 GC 情况 + JVM 模型分析

步骤五:通过 jhat 或 MAT 查看"jmap dump 内存快照"的对象分类情况

步骤六:根据分析的结果再排查具体的问题原因:Bug 或者参数设置不合理

步骤七:修复 Bug、优化 JVM 参数


文章转载自:东阳马生架构

原文链接:https://www.cnblogs.com/mjunz/p/18651212

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

还未添加个人签名 2025-04-01 加入

还未添加个人简介

评论

发布
暂无评论
JVM实战—线上FGC的几种案例_JVM_量贩潮汐·WholesaleTide_InfoQ写作社区