任何新的业务系统在上线以前都需要去估算服务器配置和 JVM 的内存参数,这个容量与资源规划并不仅仅是系统架构师的随意估算的,需要根据系统所在业务场景去估算,推断出来一个系统运行模型,评估 JVM 性能和 GC 频率等等指标。
所以,可以粗略的推断出来一个每天 100 万次请求的登录系统,按照 4C8G 的 3 实例集群配置,分配 4G 堆内存、2G 新生代的 JVM,可以保障系统的一个正常负载。
基本上把一个新系统的资源评估了出来,所以搭建新系统要每个实例需要多少容量多少配置,集群配置多少个实例等等这些,并不是拍拍脑袋和胸脯就可以决定的下来的。
吞吐量还是响应时间?
首先引入两个概念——吞吐量和低延迟。
通常,吞吐优先还是响应优先这个在 JVM 中是一个两难之选。
堆内存增大,GC 一次能处理的数量变大,吞吐量大;但是 GC 一次的时间会变长,导致后面排队的线程等待时间变长;相反,如果堆内存小,GC 一次时间短,排队等待的线程等待时间变短,延迟减少,但一次请求的数量变小(并不绝对符合)。
无法同时兼顾,是吞吐优先还是响应优先,这是一个需要权衡的问题。
目前主流的垃圾回收器配置是新生代采用 ParNew,老年代采用 CMS 组合的方式,或者是完全采用 G1 回收器。从未来的趋势来看,G1 是官方维护和更为推崇的垃圾回收器。
业务系统:延迟敏感的推荐 CMS,大内存服务,要求高吞吐的,采用 G1 回收器。
CMS 主要是针对老年代的回收器,老年代是标记-清除,默认会在一次 Full GC 算法后做整理算法,清理内存碎片。
总之,业务系统延迟敏感的推荐 CMS。大内存服务,要求高吞吐的,采用 G1 回收器。
一般的思路为:
首先,JVM 最重要最核心的参数是去评估内存和分配,第一步需要指定堆内存的大小,这个是系统上线必须要做的,-Xms
初始堆大小,-Xmx
最大堆大小,后台 Java 服务中一般都指定为系统内存的一半,过大会占用服务器的系统资源,过小则无法发挥 JVM 的最佳性能。
其次,需要指定 -Xmn
新生代的大小,这个参数非常关键,灵活度很大,虽然 sun 官方推荐为 3/8
大小,但是要根据业务场景来定,针对于无状态或者轻状态服务(现在最常见的业务系统如 Web 应用)来说,一般新生代甚至可以给到堆内存的 3/4 大小;而对于有状态服务(常见如 IM 服务、网关接入层等系统)新生代可以按照默认比例 1/3 来设置。服务有状态,则意味著会有更多的本地缓存和会话状态信息常驻内存,应为要给老年代设置更大的空间来存放这些对象。
最后,是设置 -Xss
栈内存大小,设置单个线程栈大小,默认值和 JDK 版本、系统有关,一般默认 512~1024kb。一个后台服务如果常驻线程有几百个,那么栈内存这边也会占用了几百 M 的大小。
对于 8G 内存,一般分配一半的最大内存就可以了, 因为机器本上还要占用一定内存,一般是分配 4G 内存给 JVM。
引入性能压测环节,测试同学对登录接口压至 1 秒内 60M 的对象生成速度,采用 ParNew + CMS 的组合回收器。
正常的 JVM 参数配置如下:
-Xms3072M
-Xmx3072M
-Xss1M
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
这样设置可能会由于动态对象年龄判断原则导致频繁 Full GC。为啥呢?
压测过程中,短时间(比如 20 秒后)Eden 区就满了,此时再运行的时候对象已经无法分配,会触发 Minor GC。
假设在这次 GC 后 S1 装入 100M,马上过 20 秒又会触发一次 Minor GC,多出来的 100M 存活对象+S1区的 100M 已经无法顺利放入到 S2 区,此时就会触发 JVM 的动态年龄机制,将一批 100M 左右的对象推到老年代保存,持续运行一段时间,系统可能一个小时候内就会触发一次 Full GC。
按照默认 8:1:1 的比例来分配时,Survivor 区只有 1G 的 10% 左右,也就是几十到 100M,如果每次 Minor GC 垃圾回收过后进入 Survivor 对象很多,并且 Survivor 对象大小很快超过 Survivor 的 50%,那么会触发动态年龄判定规则,让部分对象进入老年代。
而一个 GC 过程中,可能部分 Web 请求未处理完毕,几十兆对象,进入 Survivor 的概率,是非常大的,甚至是一定会发生的。
如何解决这个问题呢?为了让对象尽可能的在新生代的 Eden 区和 Survivor 区,尽可能的让 Survivor 区内存多一点,达到 200 M左右。于是我们可以更新下 JVM 参数设置:
-Xms3072M
-Xmx3072M
-Xmn2048M
-Xss1M
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
‐Xmn2048M ‐XX:SurvivorRatio=8
年轻代大小 2G,Eden 和 Survivor 的比例为 8:1:1,也就是 1.6G:0.2G:0.2G。
达到 200M,如果几十兆对象到底 Survivor,Survivor 也不一定超过 50%。这样可以防止每次垃圾回收过后,Survivor 对象太早超过 50%,这样就降低了因为对象动态年龄判断原则导致的对象频繁进入老年代的问题。
对象进入老年代的动态年龄判断规则(动态晋升年龄计算阈值):Minor GC 时,Survivor 中年龄 1 到 N 的对象大小超过 Survivor 的 50% 时,则将大于等于年龄 N 的对象放入老年代。
核心的优化策略是:是让短期存活的对象尽量都留在 Survivor 里,不要进入老年代,这样在 Minor GC 的时候这些对象都会被回收,不会进到老年代从而导致 full gc。
这里特别说一下,JVM 最重要最核心的参数是去评估内存和分配,
第一步需要指定堆内存的大小,这个是系统上线必须要做的,-Xms 初始堆大小,-Xmx 最大堆大小,后台 Java 服务中一般都指定为系统内存的一半,过大会占用服务器的系统资源,过小则无法发挥 JVM 的最佳性能。
其次需要指定 -Xmn 新生代的大小,这个参数非常关键,灵活度很大,虽然 Oracle 官方推荐为 3/8 大小,但是要根据业务场景来定:
服务有状态,则意味着会有更多的本地缓存和会话状态信息常驻内存,应为要给老年代设置更大的空间来存放这些对象。
Xss 栈内存大小,设置单个线程栈大小,默认值和 JDK 版本、系统有关,一般默认 512~1024 kb。一个后台服务如果常驻线程有几百个,那么栈内存这边也会占用了几百 M 的大小。
假设一次 minor gc 要间隔二三十秒,并且,大多数对象一般在几秒内就会变为垃圾, 如果对象这么长时间都没被回收,比如 2 分钟没有回收,可以认为这些对象是会存活的比较长的对象,从而移动到老年代,而不是继续一直占用 Survivor 区空间。
所以,可以将默认的 15 岁改小一点,比如改为 5。那么意味着对象要经过 5 次 Minor GC 才会进入老年代,整个时间也有一两分钟了(5*30s = 150s),和几秒的时间相比,对象已经存活了足够长时间了。
所以,可以适当调整 JVM 参数如下:
‐Xms3072M
‐Xmx3072M
‐Xmn2048M
‐Xss1M
‐XX:MetaspaceSize=256M
‐XX:MaxMetaspaceSize=256M
‐XX:SurvivorRatio=8
‐XX:MaxTenuringThreshold=5
对于多大的对象直接进入老年代(参数 -XX:PretenureSizeThreshold),一般可以结合自己系统看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为 1 M 就差不多了,很少有超过 1 M 的大对象。所以,可以适当调整 JVM 参数如下:
‐Xms3072M
‐Xmx3072M
‐Xmn2048M
‐Xss1M
‐XX:MetaspaceSize=256M
‐XX:MaxMetaspaceSize=256M
‐XX:SurvivorRatio=8
‐XX:MaxTenuringThreshold=5
‐XX:PretenureSizeThreshold=1M
JDK8 默认的垃圾回收器是 -XX:+UseParallelGC(年轻代)和 -XX:+UseParallelOldGC(老年代)。如果内存较大(超过 4 个 G,只是经验值),还是建议使用 G1。
这里是 4G 以内,又是主打“低延时”的业务系统,可以使用下面的组合:
ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)
新生代的采用 ParNew 回收器,工作流程就是经典复制算法,在三块区中进行流转回收,只不过采用多线程并行的方式加快了 MinorGC 速度。
老生代的采用 CMS。再去优化老年代参数 :比如老年代默认在标记清除以后会做整理,还可以在 CMS 的增加 GC 频次还是增加 GC 时长上做些取舍。如下是响应优先的参数调优:
设定 CMS 在对内存占用率达到 70% 的时候开始 GC(因为 CMS 会有浮动垃圾,所以一般都较早启动 GC)。
XX:CMSInitiatingOccupancyFraction=70
XX:+UseCMSInitiatinpOccupancyOnly
-XX:+AlwaysPreTouch
强制操作系统把内存真正分配给 JVM,而不是用时才分配。
综上,只要年轻代参数设置合理,老年代 CMS 的参数设置基本都可以用默认值,如下所示:
‐Xms3072M
‐Xmx3072M
‐Xmn2048M
‐Xss1M
‐XX:MetaspaceSize=256M
‐XX:MaxMetaspaceSize=256M
‐XX:SurvivorRatio=8
‐XX:MaxTenuringThreshold=5
‐XX:PretenureSizeThreshold=1M
‐XX:+UseParNewGC
‐XX:+UseConcMarkSweepGC
‐XX:CMSInitiatingOccupancyFraction=70
‐XX:+UseCMSInitiatingOccupancyOnly
‐XX:+AlwaysPreTouch
参数解释
‐Xms3072M ‐Xmx3072M 最小最大堆设置为 3G,最大最小设置为一致防止内存来回分配、抖动; ‐Xss1M 线程栈 1M; ‐Xmn2048M ‐XX:SurvivorRatio=8 年轻代大小 2G,Eden 与 Survivor 的比例为 8:1:1,也就是 1.6G:0.2G:0.2G; XX:MaxTenuringThreshold=5 年龄为 5 进入老年代; ‐XX:PretenureSizeThreshold=1M 大于 1M 的大对象直接在老年代生成; ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC 使用 ParNew+cms 垃圾回收器组合; ‐XX:CMSInitiatingOccupancyFraction=70 老年代中对象达到这个比例后触发 Full GC; ‐XX:+UseCMSInitiatinpOccupancyOnly 老年代中对象达到这个比例后触发 Full GC; ‐XX:+AlwaysPreTouch 强制操作系统把内存真正分配给 IVM,而不是用时才分配。
额外增加了 GC 日志打印、OOM 自动 dump 等配置内容,帮助进行问题排查。
-XX:+HeapDumpOnOutOfMemoryError
在 Out Of Memory,JVM 快死掉的时候,输出 Heap Dump 到指定文件。不然开发很多时候还真不知道怎么重现错误。路径只指向目录,JVM 会保持文件名的唯一性,叫 java_pid${pid}.hprof。
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=${LOGDIR}/
因为如果指向特定的文件,而文件已存在,反而不能写入。
输出 4G 的 HeapDump,会导致 IO 性能问题,在普通硬盘上,会造成 20 秒以上的硬盘 IO 跑满,需要注意一下,在容器环境下,这个也会影响同一宿主机上的其他容器。
GC 的日志的输出也很重要:
-Xloggc:/dev/xxx/gc.log
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
GC 的日志实际上对系统性能影响不大,打日志对排查 GC 问题很重要。
一般来说,大企业或者架构师团队,都会为项目的业务系统定制一份较为通用的 JVM 参数模板,但是许多小企业和团队可能就疏于这一块的设计。如果老板某一天突然让你负责定制一个新系统的 JVM 参数,你上网去搜大量的 JVM 调优文章或博客,结果发现都是零零散散的、不成体系的 JVM 参数讲解,根本下不了手。这个时候你就需要一份较为通用的 JVM 参数模板了,不能保证性能最佳,但是至少能让 JVM 这一层是稳定可控的。 在这里给大家总结了一份模板:
基于 4C8G 系统的 ParNew+CMS 回收器模板(响应优先),新生代大小根据业务灵活调整!
-Xms4g
-Xmx4g
-Xmn2g
-Xss1m
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=10
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+AlwaysPreTouch
-XX:+HeapDumpOnOutOfMemoryError
-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:gc.log
如果是 GC 的吞吐优先,推荐使用 G1,基于 8C16G 系统的 G1 回收器模板: G1 收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它。
即调整 -XX:MaxGCPauseMillis=N 参数,这也符合 G1 的目的——让 GC 调优尽量简单。
同时也不要自己显式设置新生代的大小(用 -Xmn 或 -XX:NewRatio 参数),
如果人为干预新生代的大小,会导致目标时间这个参数失效。
-Xms8g
-Xmx8g
-Xss1m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=150
-XX:InitiatingHeapOccupancyPercent=40
-XX:+HeapDumpOnOutOfMemoryError
-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:gc.log
针对 -XX:MaxGCPauseMillis
来说,参数的设置带有明显的倾向性:调低 ↓:延迟更低,但 Minor GC 频繁,Mix GC 回收老年代区减少,增大 Full GC 的风险。调高 ↑:单次回收更多的对象,但系统整体响应时间也会被拉长。
针对 InitiatingHeapOccupancyPercent
来说,调参大小的效果也不一样:调低 ↓:更早触发 Mix GC,浪费 cpu。调高 ↑:堆积过多代回收 region,增大 Full GC 的风险。
系统在上线前的综合调优思路:
到目前为止,总结到的调优的过程主要基于上线前的测试验证阶段,所以我们尽量在上线之前,就将机器的 JVM 参数设置到最优。
JVM 调优只是一个手段,但并不一定所有问题都可以通过 JVM 进行调优解决,大多数的 Java 应用不需要进行 JVM 优化,我们可以遵循以下的一些原则:
通过以上原则,我们发现,其实最有效的优化手段是架构和代码层面的优化,而 JVM 优化则是最后不得已的手段,也可以说是对服务器配置的最后一次“压榨”。