1. 案例背景

  • 分析高峰期下,支付系统的内存使用模型,然后合理优化新生代、老年代、Eden 和 Survivor 各区域的内存大小,接着再尽量优化 JVM 参数避免新生代的对象进入老年代,尽量让对象留在新生代里被回收掉;

2. 分析和优化 JVM 参数

(1) 估算支付系统的内存使用模型

  • 高峰期,支付系统每秒收到 1000个下单请求;
  • 3台4核8G的机器,平均每台机器要每秒接收 300 个下单请求;
  • 假设核心的订单对象大小是 1KB,算上与订单关联的库存、促销、优惠券等其他业务对象,开销扩大20被;算上除了下单操作之外的其他操作(订单查询等),开销再扩大10倍;
  • 一台机器上的支付系统每秒大概会开销 1KB2010*300=60MB 的内存空间;
  • 每个订单处理耗时1秒,即一秒后这 60MB 的对象就是垃圾对象;

(2) JVM 内存空间分配的基础版

  • 机器 4核8G,分给 JVM 进程 4G,剩下的留给操作系统之类的使用;
  • 堆内存 3G:新生代 1.5G、老年代 1.5G;
  • 每个线程的 Java 虚拟机栈 1M;(每300个订单,可能JVM里有几百个线程,几百兆)
  • 永久代 256M;
  • 打开“-XX:HandlePromotionFailure”选项
    • 1.6 或以前的版本,需要设置;
    • 1.6 以后的版本,废弃了此参数,只要判断“老年代可用空间”> “新生代对象总和”,或者“老年代可用空间”> “历次 Minor GC 升入老年代对象的平均大小”,两个条件满足一个,就可以直接进行 Minor GC,不需要提前触发 Full GC了。 ```shell

      JDK1.6

      -Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:HandlePromotionFailure

JDK1.7 或 JDK1.8

-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M

  1. <a name="lZhzO"></a>
  2. #### (3) 分析基础版的系统内存运行模型
  3. - 支付系统每秒开销 60MB 的内存,且一秒后这些对象变成垃圾对象;
  4. - 1.5G 的新生代,按照默认 8:1:1 的比例,Eden 大小 1.2G,每个 Survivor 大小 150MB;
  5. - 大概只需要 20 秒,Eden 区 1.2G 就满了,触发 Minor GC 时机,刚开始肯定可以通过“-XX:HandlePromotionFailure”开启的检查,直接运行 Minor GC;
  6. - Minor GC 之后,一般会回收掉99%的新生代对象,可能存活对象 100MB(最近一秒的订单还在处理中);
  7. - 存活的 100MB 对象进入一块空的 Survivor 区;
  8. - 如此 20 秒一个轮回;
  9. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/1471554/1627397129572-3b531d00-8b8a-4edd-b102-cee3108b6692.png#align=left&display=inline&height=287&margin=%5Bobject%20Object%5D&name=image.png&originHeight=287&originWidth=1113&size=43102&status=done&style=shadow&width=1113)![image.png](https://cdn.nlark.com/yuque/0/2021/png/1471554/1627376283538-54a28041-731d-4d77-a7b2-6333b3d5b0d6.png#align=left&display=inline&height=758&margin=%5Bobject%20Object%5D&name=image.png&originHeight=758&originWidth=1542&size=186891&status=done&style=shadow&width=1542)
  10. <a name="iBdEQ"></a>
  11. #### (4) JVM 内存空间分配的进阶版v1
  12. <a name="VWVSG"></a>
  13. ##### a. 新生代垃圾回收优化之一:Survivor 空间够不够?
  14. - 潜在的问题:
  15. - 每次 Minor GC 之后估算存活 100MB 对象,其实是很有可能突破 150MB,导致存活对象无法放入 Survivor 中,从而频繁转入老年代;
  16. - 即使每次 Minor Gc 之后的存活对象少于 150MB,进入 Survivor 区,但因为这一批对象都是同龄的,直接超过了 Survivor 空间的 50%,从而也可能导致对象进入老年代;
  17. - 解决办法:
  18. - 分析系统业务,明显大部分业务对象都是短生命周期,不应该频繁进入老年代,没必要给老年代维持过大的内存空间,让对象尽量留在新生代里;
  19. - 建议调整新生代核老年代的大小:老年代维持 1G,新生代 2G(Eden 1.6G、Survivor 200MB);
  20. - 目前的 JVM 参数大大降低了新生代对象进入老年代的概率:
  21. ```shell
  22. -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M

b. 新生代垃圾回收优化之二:对象躲过多少次垃圾回收后进入老年代?
  • 目前的情况:
    • 20秒触发一次 Minor GC,“-XX:MaxTenuringThreshold”参数默认值15次,即一个对象在新生代停留超过几分钟之后,就会进入老年代;
  • 问题分析:
    • 设置“-XX:MaxTenuringThreshold”参数,一定要结合系统的运行模型来考虑;
    • 系统业务中大部分对象都是短生命周期的,如果一个对象躲过了15次 GC,在新生代里存活了几分钟,说明它不是业务数据对象,大概率是系统里类似 @Service、@Controller 等的注解标注的那种需要长期存活的核心业务逻辑组件,这种对象实例一般全局就只有有一个实例就可以了,要一直使用的;
    • 这种核心业务逻辑组件对象,是应该进入老年代的,且这种对象一般很少,一个系统总共最多也就 几十MB 而已;
  • 解决办法:

    • 根据 Minor GC 的频率,合理调整参数,可以让这些组件对象,尽快进入老年代,别在新生代里占着内存;
    • “-XX:MaxTenuringThreshold”参数设置5,即一个对象如果躲过5次 Minor GC,在新生代里存活超过了一分钟,就让它尽快区老年代呆着;
      -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:MaxTenuringThreshold=5
      
      c. 多大的对象直接进入老年代?
  • JVM 规范:大对象可以直接进入老年代,因为大对象可能是要长期存活和使用的;

  • 潜在可能:
    • 在 JVM 里可能需要缓存一些数据,如提前分配一个大数组,大的 List 之类的东西用来放缓存的数据;
  • 解决办法:

    • 一般情况下很少会有超过 1MB 的大对象,具体结合自己的系统中到底有没有创建大对象来决定;
      -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
      
      d. 指定垃圾回收器
  • 新生代使用 ParNew,老年代使用 CMS;

    -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
    

    image.png :::info 此版本的主要优化是针对新生代垃圾回收器 ParNew,合理的分配新生代的内存大小、Eden 和 Survivor 的比例,避免 Minor GC 之后 Survivor 放不下存活对象进入老年代,或者是动态年龄判断之后进入老年代;
    然后根据自己的系统运行模型,合理设置“-XX:MaxTenuringThreshold”,让那些长期存活的对象尽快进入老年代,别占用新生代的空间。 :::

(5) JVM 内存空间分配的进阶版v2

a. 问题分析:v1 优化版本下,一般什么情况下会让一些对象进入老年代?
  • “-XX:MaxTenuringThreshold=5”,在一两分钟内连续两次躲过5次 Minor GC 的对象进入老年代;
    • 像那些全局只有一个实例的系统核心业务逻辑组件,要长期使用的,一般一个系统就 几十MB 大小;
  • “-XX:PretenureSizeThreshold=1M”,假设我们的系统里面没有这种大对象,所以忽略不计;
  • Minor GC 后存活对象超过 200MB 放不下 Survivor,或者转入 Survivor 后一批对象占超过 Survivor 的 50%,进入老年代;

    • 经过 v1 优化后,Minor GC 后超过 200MB 的存活对象的概率极低,但是也极端的高峰期情况下不能排除有这种可能性,假设每个5分钟会在 Minor GC 后会有一批 200MB 的对象进入老年代; :::info 估算老年代的内存运行模型:有 几十MB 是长期存在的,每5分钟爆发一次转入 200MB 的对象,1024M / 200M * 5分钟 = 25分钟,我们估算大概系统运行半小时~1小时之后,才会有1G的对象进入老年代将其占满; ::: image.png
      b. 老年代垃圾回收优化之一:多久触发一次 Full GC?
  • v1 优化版本下,Full GC 的几种触发情况:

    • Minor GC 前检查,发现以下情况:
      • JDK1.6:老年代可用内存(最多1G)< 新生代总对象大小(最多1.8G),并且没有打开“ -XX:HandlePromotionFailure”,直接触发 Full GC;
      • JDK1.7或1.8:老年代可用内存(最多1G)< 新生代总对象大小(最多1.8G),并且 老年代可用内存空间(预留最少100M) < 历次 Minor GC 后升入老年代的平均对象大小(隔几分钟转入几十M,加上五分一次的200M,平均下来很小)。这种情况发生的概率基本很小;
    • 根据之前的问题分析,其中有概率很低的可能性,Minor GC 后产生超过 200M 的存活对象放不下 Survivor (200M),需要进入老年代,然后老年代的可用空间也不足200M,放不下,直接触发 Full GC。这种情况发生的概率也很小;
    • 如果以上几种情况都没发生,且“-XX:CMSInitiatingOccupancyFaction=92”保持默认值,老年代使用空间超过92%了,直接触发 Full GC;
  • 以上几种触发时机,基本都是老年代几乎占满的时候,而我们上面估算的:系统运行半小时~1小时,老年代才会占满;
  • 建议:结合系统场景,决定如何优化

    • 现在估算的场景是系统在高峰期,一个小时支付系统处理百万订单,基本上一个高峰期也就持续1个小时左右,高峰期过后才会触发一次 Full GC,且随着系统访问压力的慢慢降低,可能就要几个小时才有一次 Full GC;
    • 一个小时一次的 Full GC,对目前这个系统的性能影响不是很大;
      d. 老年代垃圾回收优化之二:Full GC 的时候会发生”Concurrent Mode Failure”吗?
  • 问题分析:

    • 假设系统运行1小时后,老年代基本占满,快要触发 Full GC,可用空间只有10%,老年代里面大概有 900M 的对象,触发一次 Full GC;
    • CMS 在并发清理阶段,系统工作线程仍在工作,有小概率的可能,产生200M的存活对象进入老年代,此时老年代可用空间只有 100M,触发 Concurrent Mode Failure 问题;
    • Serial Old 替代 CMS,进入 Stop the world,单线程进行老年代垃圾回收,清理掉 900M 对象,系统再继续运行;
  • 建议:
    • 这种小概率情况,理论上是有可能发生的,但一般情况下,没有必要针对小概率事件特意优化 JVM 参数;

image.png

e. 老年代垃圾回收优化之二:内存碎片整理的频率应该多高?
  • 问题分析:
    • 高峰期,系统一个小时才执行一次 Full GC,高峰期之后,没那么多订单,可能几个小时就执行一次 Full GC,频率并不是很高;
  • 建议:
    • 保持默认值就好了,每次 Full GC 之后执行一次内存碎片整理,因为 Full GC 频率本身就不高;

:::info 总结:

  • 最终的 JVM 优化参数(很多是对默认值进行显式设置):
    • -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -``XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92 -XX:+UseCMSCompactAtFullCollection ``-XX:CMSFullGCsBeforeCompaction=0
  • Full GC 优化的前提是 Minor GC 的优化,Minor GC 的优化的前提是合理分配内存空间,合理分配内存空间的前提是对系统运行期间的内存使用模型进行预估;
  • 对很多普通的 Java 系统而言,只要对系统运行期间的内存使用模型做好预估,然后分配好合理的内存空间,尽量让 Minor GC 之后的存活对象留在 Survivor 里不要去老年代,然后其余的 GC 参数不做太多优化,系统性能基本上就不会太差; :::