前言
有被 JVM 相关问题刁难过吗?
上个月朋友去面某东说被 JVM 难哭了
面试官上来就是素质三连:
有没有 高并发项目经验
、频繁 gc 怎么解决
、有没有搞过 JVM 调优
我那个朋友公司做的是 to b
方向 , 系统流量不是很大 , 加上才工作 2
年直接被问懵逼
回来就问我高并发系统怎么玩 , 为了避免重复劳动 , 遂有此文~
一、亿级流量系统回顾
在上篇文章中有对这个系统的亿级流量怎么来的有详细的介绍:亿级流量系统简介
接下来做个回顾:
OTA
平台 4亿
用户
高峰期 百万
订单
高峰期 12
小时 1.8亿
访问量
每小时的流量是:1.8亿 / 12 = 1250w
每分的流量是:1250w / 60 = 20.8w
每秒的流量是:20.8w / 60 = 3472
2
个集群 32
台 8C/16G
的机器
一次核心接口查询平均占用 5mb
内存
每秒钟 JVM
会有 550mb
的新生代堆内存空间被占用
二、系统的 JVM 参数
基于G1垃圾收集器 这里我截取了这个服务生产环境的 JVM 参数:
-Xmx12288m
初始堆大小.-Xms12288m
最大堆大小-Xss256k
每个线程的栈内存大小-XX:MetaspaceSize=256m
元空间初始大小-XX:MaxMetaspaceSize=1g
元空间最大大小-XX:MaxGCPauseMillis=200
每次YGC / MixedGC 的最多停顿时间 (期望最长停顿时间)-XX: UseG1GC
java8 指定使用G1垃圾回收器-XX:-OmitStackTraceInFastThrow
对异常做的一个优化,抛出异常非常快,但是看不到异常的堆栈信息(仅供参考)-XX:MinHeapFreeRatio=30
GC后java堆中空闲量占的最小比例,小于该值,则堆内存会增加-XX:MaxHeapFreeRatio=50
GC后java堆中空闲量占的最大比例,大于该值,则堆内存会减少-XX:CICompilerCount=4
设置的相对较大可以一定程度提升JIT编译的速度,默认为2-XX:SoftRefLRUPolicyMSPerMB=0
任何软引用对象在下一次 GC 都尽快释放掉,给内存释放空间。-XX: PrintGC
输出GC日志-XX: PrintGCDetails
输出GC的详细日志-XX: PrintGCDateStamps
输出GC的时间戳(以基准时间的形式)-XX: UseGCLogFileRotation
开或关闭GC日志滚动记录功能-XX:NumberOfGCLogFiles=5
设置滚动日志文件的个数-XX:GCLogFileSize=32M
设置滚动日志文件的大小,当前写日志文件大小超过该参数值时,日志将写入下一个文件-XX: HeapDumpOnOutOfMemoryError
JVM会在遇到OutOfMemoryError时拍摄一个堆转储快照,并将其保存在一个文件中
注意
-XX:SoftRefLRUPolicyMSPerMB=0
这个参数在某些情况下会造成元空间 OOM ,一般最好给个 2000 / 5000,
0 是经过调优确认不会引起这个问题才用。
为什么会造成 OOM 我会在以后的文章会中提到。
三、高并发下 JVM 是怎么玩的?
堆空间怎么分配内存?
虽然给堆空间分配了 12G
的内存,但新生代并不是一开始就把这 12G
一下就占满了,老年代还得占一部分。
也不是一开始就将新老生代按个比例分配好空间,新生代一开始只会分配 5%
的堆内存空间,然后慢慢的增大,
这个是可以通过 -XX:G1NewSizePercent
来设置新生代初始占比的,其实维持这个默认值就可以了
同样老年代也是,并不是以开始就分配几个G ;因为 G1
是基于 Region
的逻辑来分区的。
到底多久会触发一次新生代的 YoungGC(ygc)?
有人说:新生代的 Eden 区空间不够用了就会触发 ygc
那到底 Eden区使用多少了才是内存不够呢?
有一个参数 -XX:G1MaxNewSizePercent
默认值:60%
,限定了新生代最多占用堆内存 60%
的空间,
那就是是 12G * 60% = 7.2G
,然后 新生代又有 Eden
和 两个 Survivor
组成 默认比例是: 8:1:1
,
7.2G * 0.8 = 5.76G
, 是 Eden
区快到 5.7G
就触发 ygc
么?
并不是,G1 有个很重要的参数 -XX:MaxGCPauseMillis
这个参数的默认值是 200
意味着每次 进行垃圾回收,最长的停顿时间不超过 200ms
。这也是为什么 G1 号称它造成的 STW
是停顿可控的。
做个大胆的假设: 200ms
G1可以回收 300个Region
区域!
因为 G1 是在逻辑上区分 老年代和新生代的,整个堆被分成了 2048
个 Region
区域,12G
的堆内存平均每个 Region
的大小是 6MB
但 Region 的大小必须是 2的 N次幂,所以每个 Region 的大小会是 8mb
之前算出来了这个系统每秒钟往新生代输送的对象大小是 550mb
,550mb / 8mb = 68
,平均每秒会有 68
个 Region
被占满,
回收 300
个 Region
需要 200ms
, 300 / 68 = 4.5ms
,
大概 4.5ms
就会进行一次 ygc
,一分钟就会进行 13
次 ygc
,每次 ygc 200ms
这样分析就会发现 G1 的垃圾回收其实是很动态,很灵活的,它会根据你对 GC 的预期停顿时间来进行回收。
G1 哪些对象会进入老年代?
- 一个对象在年轻代里躲过15次垃圾回收,年龄太大了,寿终正寝,进入老年代
- 大对象直接送到老年代 参数
XX:PretenureSizeThreshold
来控制多大的对象才算大。XX:PretenureSizeThreshold=100000000
单位为btye - 动态年龄判定规则,如果一旦发现某次新生代 GC 过后,存活对象超过了
Survivor 50%
- 一次
ygc
过后存活对象太多了,导致Survivor
区域放不下了,这批对象会进入老年代
这个接口的耗时一般在 200ms 左右,但在高并发情况下,内存资源这么吃紧,CPU 和 线程资源都会有很高的负载,这时候就很有可能出现一些性能抖动的情况
相应的表现就是接口的响应时间延长,甚至会出现超时,在频繁的 fgc 情况下:
- 一些对象在
Survivor区
经过 15 次 ygc 后,就会晋升到老年代 - 很多接口的响应时间都延长,导致触发动态年龄判断规则,就会有一大批对象晋升到老年代,
看起来这么大的内存,Survivor区
也足够大,这个晋升规则也比较严格,但是高并发的场景下,上面这个流程只要反复的来几次
老年代的对象就会越来越多
什么是 Mixed GC (混合回收)?
因为 G1 是基于 Region 的,并没有严格的区分老年代新生代,
G1有一个参数,XX:InitiatingHeapOccupancyPercent
,它的默认值是 45%
,意思就是说,如果 老年代 占据了堆内存的 45%
的 Region
的时候,此时就会尝试触发一个新生代 老年代一起回收的混合回收。
什么时候发生 Full GC(fgc) ?
异常情况
- 大对象太多,对象都跑老年代去了,老年代内存吃紧会触发 fgc ,如果fgc 内存还是不够使用,那再申请内存的时候就会抛出 OOM 异常,然后再 fgc 如此往复循环,系统并不会直接挂掉,表现是系统假死,非常卡顿,用户体验极差。
- 元空间、直接内存这些区域快满了都会触发 fgc
后续 堆空间、元空间、直接内存(堆外内存) OOM 都会有真实的生产环境案例 敬请期待
正常情况
fgc
都知道是一个很耗时的操作 , G1 正常的工作状态是没有Full GC
概念的,老年代垃圾的收集任务全靠Mixed GC
来处理。- 不过在进行
Mixed
回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region
的存活对象拷贝到别的Region
里去, - 此时万一出现拷贝的过程中发现没有空闲
Region
可以承载自己的存活对象了,就会触发一次失败。一旦失败,立马就会切换为停止系统程序,切换到 G1 之外的Serial Old GC
来收集整个堆(包括 Young、Old、Metaspace )这才是真正的Full GC
(Full GC不在G1的控制范围内) - 进入这种状态的G1就跟使用参数
-XX: UseSerialGC
的Full GC
一样(背后的核心逻辑是一样的)。然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region
,使用单线程的进行gc
这个过程是极慢极慢的。 - 这也是
JVM
调优的关键所在,务必不要让你的系统触发 Full GC !
补充
-XX:MaxGCPauseMillis = 200
是一个默认值,停顿 200ms
也不算久,但一个高并发系统如果要求低延迟,快速响应
这个值就要再调低一点了,但是仍然不建议去把这个值改小,
很多时候设置的 200ms
, 实际上也只有 20 - 80ms
,这是我观察过不下 30
个生产环境的 GC
得出来的结论。
跟做性能测试的大佬也讨论过这个的原因:G1 是一个 动态、灵活、自主、性能还不错 的垃圾收集器
如果设置太小 ,可能导致每次 Mixed GC or ygc
只能回收很小一部分 Region
,最终可能无法跟上程序分配内存的速度
从而触发 Full GC
所以很多系统并没有去把这个值改成 50
或是 100
如果设置太大 ,那么可能 G1 会允许你不停的在新生代理分配新的对象,然后积累了很多对象,再一次性回收几百个 Region
此时可能一次 GC 停顿时间就会达到几百毫秒,但是 GC 的频率很低。
比如说 30
分钟才触发一次新生代 GC,但是每次停顿 500ms
,毫无疑问, 500ms
对于一个高并发的系统来说实在是太久了
四、JVM 调优该怎么做?
主要优化在新生代
新生代gc如何优化?
对于G1而言,我们首先应该给整个JVM的堆区域足够的内存,其次就是给新生代足够的内存,保证:
- 不要让对象经历 15 次垃圾回收从而进入老年代
- 不要让
Survivor
太小,从而触发动态年龄判断,也要保证每次ygc
后Survivor
都能够放下存活的对象
之前我们算过,这个系统每分钟会有 550mb
的对象会进入新生代 , 4.5s
就会来一次 ygc
,
一分钟会有 13
次左右的 gc
, 每次 gc
大概在 200ms
以内。
PS : G1 回收只有初始标记和重新标记的阶段是 stw
,其他阶段都是并发的,
gc 200ms
, 真正 stw
的时间可能只是 几十 ~ 一百ms
不过!每分钟 13次
的 ygc
频率,每次接近 200ms
左右耗时 gc
效率实在太低了
新生代优化
因为有 记忆集 (RSet) 的存在,在 G1 回收 Region
效率不变的情况下 , 优化的点就来了
扩大每个 Region 的大小 , 也就是扩大堆内存的大小 , 简而言之就是升级机器的内存 或者是 集群进行扩容增加服务器的数量
目前这个业务系统只有 32 台机器 8C 16G的机器 , 给堆空间的大小只有 12G , 对亿级的流量还是不太能抗住 ,
目前阶段性的分析后 , 性能瓶颈不在 CPU
, 我们只需要升级内存即可
升级到 8C 32G
给堆 24~26G
的空间 , 元空间给 1G
则机器数量不变 升级到 16C 64G
给堆 58~60G
的空间 , 元空间给 1G
还可以下几台机器
为什么会发生 Mixed gc ?
对于 Mixed gc
的触发,大家都知道是老年代在堆内存里占比超过 45%
就会触发
再回顾一下:年轻代的对象进入老年代的几个条件:
- 新生代
gc
过后存活对象太多没法放入Survivor
区域 - 对象年龄太大
- 动态年龄判定规则
其中尤其关键的是新生代 gc
过后存活对象过多无法放入 Survivor
区域 , 以及动态年龄判定规则这两个条件尤其可能 让很多对象快速进入老年代
一旦老年代频繁达到占用堆内存 45%
的阈值 , 那么就会频繁触发 Mixed gc
那我们的目标就是 :
尽量避免对象过快进入老年代 , 尽量避免频繁触发 mixed gc
, 就可以做到根本上优化 mixed gc
了
Mixed gc 优化思路
Mixed gc
优化的核心还是 -XX:MaxGCPauseMills
这个参数
大家可以想一下 , 假设 -XX:MaxGCPauseMills
参数设置的值很大 , 导致系统运行很久
新生代可能都占用了堆内存的 70%
了 , 此时才触发新生代 gc
那么存活下来的对象可能就会很多 , 此时就会导致 Survivor
区域放不下那么多的对象 , 就会进入老年代中
或者是新生代 gc
过后 , 存活下来的对象过多 , 导致进入 Survivor
区域后触发了动态年龄判定规则
达到了 Survivor
区域的 50%
, 也会快速导致一些对象进入老年代
所以这里核心还是在于调节 -XX:MaxGCPauseMills
这个参数的值 , 在保证新生代 gc
别太频繁的同时 , 还得考虑每次 gc
过后的存活对象有多少
避免存活对象太多快速进入老年代,频繁触发 Mixed gc
五、实际有效的调优参数
-XX:MaxGCPauseMillis:
根据系统可以接受的响应时长和指标 观察JVM
的回收时间来进行修改 单位:ms
太小跟不上分配内存的速度 , 太大gc
的时间太长。-XX:ParallelGCThreads:
在stw
阶段工作的GC
线程数 , 可以根据当前机器CPU
核数来设置 , 建议核心数-1
-XX:ConcGCThreads:
在非stw
阶段工作的GC
线程数 , 会影响系统的吞吐量 , 毕竟是要跟用户线程抢CPU
资源 系统如果是计算密集型的建议是CPU
核数的1/4 ~ 1/3
,iO
密集型建议是1/2
-XX:G1ReservePercent:
G1为分配担保预留的空间比例 也就是老年代留多少空间给 新生代来晋升 , 默认是10%
如果晋升失败会触发单线程的old gc
非常恐怖 , 建议高并发系统加大机器内存 提高这个参数的比例-XX:MaxMetaspaceSize:
元空间最大大小 , 在高并发且机器内存够的情况 建议增大元空间的大小 稍微大的点系统都会有很多依赖的组件,这些组件底层都有可能会用到一些反射 或者 字节码框架 , 会生成一些你看不懂类名的类 一旦第三方框架出现问题 , 你的系统很有可能也会受影响 调大元空间 , 有监控系统的设置报警机制 , 给自己系统争取一些缓冲时间也是有必要的-XX:TraceClassLoading -XX:TraceClassUnloading
追踪类加载和类卸载的情况 , 可以在Tomcat
的catalina.out
日志文件中 打印出来JVM中加载了哪些类,卸载了哪些类-XX:SoftRefLRUPolicyMSPerMB:
JVM 可以忍受多久 软引用不被回收 如果是0
则每次都会把软引用回收掉释放内存 有一个情况是反射在15
次后会动态生成一些软引用类来提高反射的效率 , 当ygc
的时候把这些软应用给回收了 但是它们的类加载器或者一些奇怪名字的类还在元空间 , 那下次要用这个反射对象的时候又得重新创建 就造成了元空间慢慢无限增大从而触发OOM
, 建议这个参数设置2000 - 5000
单位是:ms
-XX: DisableExplicitGC:
关闭显示的调用System.gc()
,System.gc()
是触发类似full gc
的操作
开启 or 关闭 有两个情况
关闭:
防止 team
里有刚入职的小天才写完一个业务逻辑就给你来一个 System.gc()
来优化内存 (别问 问那个小天才就是我)
开启:
项目里面有 Nio
相关的操作会用到直接内存 , 在 Java
中是 DirecByteBuffer
对象来申请的
在某些不合理的情况下导致控制这块区域的 DirecByteBuffer
会晋升到老年代
Nio
在申请堆外内存空间不足的时候会手动调用 System.gc()
去回收 DirecByteBuffer
堆外内存
有用到 Nio
的系统把这个参数关掉是有一定概率发生 Direct buffer memory
的
关闭还是打开取决于你自己的系统 , 以及能不能做到 code review
不让程序员自己去显示的调用 System.gc()
-XX:G1MixedGCCountTarget:
设置垃圾回收混合回收阶段,最多可以拆成几次回收 G1 的垃圾回收是分为 初始标记、并发标记、最终标记、混合回收 这几个阶段的 其中混合回收是可以并发的反复回收多次 , 这样的好处是避免单次停顿回收stw
时间太长 停止系统一会儿 , 回收掉一些Region
, 再让系统运行一会儿 , 然后再次停止系统一会儿 , 再次回收掉一些Region
这样可以尽可能让系统不要停顿时间过长 , 可以在多次回收的间隙 , 也运行一下在一定程度上可以防止部分接口相应超时
六、小结
相信你看到这里 , 应该对高并发系统中 对象如何吃 JVM 内存
频繁 遇到 gc 如何解决
已经有所了解了 。
尽管有效的解决办法仍然是加机器 , 但是加多少台机器 , 怎么加机器 , JVM 参数要如何设置都有所了解了。
往期推荐
一次完整的JVM堆外内存泄漏故障排查记录
Java 14 版本新特性速览
Spring事务@Transaction实战踩坑记录
API设计 | 对RESTful API、GraphQL、RPC API 的思考
淘宝订单自动确认收货的N种技术实现