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”选项
JDK1.7 或 JDK1.8
-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
<a name="lZhzO"></a>
#### (3) 分析基础版的系统内存运行模型
- 支付系统每秒开销 60MB 的内存,且一秒后这些对象变成垃圾对象;
- 1.5G 的新生代,按照默认 8:1:1 的比例,Eden 大小 1.2G,每个 Survivor 大小 150MB;
- 大概只需要 20 秒,Eden 区 1.2G 就满了,触发 Minor GC 时机,刚开始肯定可以通过“-XX:HandlePromotionFailure”开启的检查,直接运行 Minor GC;
- Minor GC 之后,一般会回收掉99%的新生代对象,可能存活对象 100MB(最近一秒的订单还在处理中);
- 存活的 100MB 对象进入一块空的 Survivor 区;
- 如此 20 秒一个轮回;
![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)
<a name="iBdEQ"></a>
#### (4) JVM 内存空间分配的进阶版v1
<a name="VWVSG"></a>
##### a. 新生代垃圾回收优化之一:Survivor 空间够不够?
- 潜在的问题:
- 每次 Minor GC 之后估算存活 100MB 对象,其实是很有可能突破 150MB,导致存活对象无法放入 Survivor 中,从而频繁转入老年代;
- 即使每次 Minor Gc 之后的存活对象少于 150MB,进入 Survivor 区,但因为这一批对象都是同龄的,直接超过了 Survivor 空间的 50%,从而也可能导致对象进入老年代;
- 解决办法:
- 分析系统业务,明显大部分业务对象都是短生命周期,不应该频繁进入老年代,没必要给老年代维持过大的内存空间,让对象尽量留在新生代里;
- 建议调整新生代核老年代的大小:老年代维持 1G,新生代 2G(Eden 1.6G、Survivor 200MB);
- 目前的 JVM 参数大大降低了新生代对象进入老年代的概率:
```shell
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
b. 新生代垃圾回收优化之二:对象躲过多少次垃圾回收后进入老年代?
- 目前的情况:
- 20秒触发一次 Minor GC,“-XX:MaxTenuringThreshold”参数默认值15次,即一个对象在新生代停留超过几分钟之后,就会进入老年代;
- 问题分析:
- 设置“-XX:MaxTenuringThreshold”参数,一定要结合系统的运行模型来考虑;
- 系统业务中大部分对象都是短生命周期的,如果一个对象躲过了15次 GC,在新生代里存活了几分钟,说明它不是业务数据对象,大概率是系统里类似 @Service、@Controller 等的注解标注的那种需要长期存活的核心业务逻辑组件,这种对象实例一般全局就只有有一个实例就可以了,要一直使用的;
- 这种核心业务逻辑组件对象,是应该进入老年代的,且这种对象一般很少,一个系统总共最多也就 几十MB 而已;
解决办法:
JVM 规范:大对象可以直接进入老年代,因为大对象可能是要长期存活和使用的;
- 潜在可能:
- 在 JVM 里可能需要缓存一些数据,如提前分配一个大数组,大的 List 之类的东西用来放缓存的数据;
解决办法:
新生代使用 ParNew,老年代使用 CMS;
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
:::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 优化版本下,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;
- Minor GC 前检查,发现以下情况:
- 以上几种触发时机,基本都是老年代几乎占满的时候,而我们上面估算的:系统运行半小时~1小时,老年代才会占满;
建议:结合系统场景,决定如何优化
问题分析:
- 假设系统运行1小时后,老年代基本占满,快要触发 Full GC,可用空间只有10%,老年代里面大概有 900M 的对象,触发一次 Full GC;
- CMS 在并发清理阶段,系统工作线程仍在工作,有小概率的可能,产生200M的存活对象进入老年代,此时老年代可用空间只有 100M,触发 Concurrent Mode Failure 问题;
- Serial Old 替代 CMS,进入 Stop the world,单线程进行老年代垃圾回收,清理掉 900M 对象,系统再继续运行;
- 建议:
- 这种小概率情况,理论上是有可能发生的,但一般情况下,没有必要针对小概率事件特意优化 JVM 参数;
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 参数不做太多优化,系统性能基本上就不会太差; :::