运维开发网

亿级流量系统如何玩转 JVM

运维开发网 https://www.qedev.com 2020-09-02 10:15 出处:51CTO 作者:小几斤
本文转载自微信公众号「Shooter茶杯」,作者Shooter。转载本文请联系Shooter茶杯公众号。 抱歉很久没写新文章了 , 这段时间一直在学习扩大自己的知识盲区 , 工作上也挺忙的 , 拖更了好久答应了朋友要出个 JVM 系列 , 应该会有几篇文章 , 我会努力在保证质量的前提下进行输出~So 进入今天的主题前言有被 JVM 相关问题刁难过吗?上个月朋友去面某东说被 JVM 难哭了面试官

 

亿级流量系统如何玩转 JVM

本文转载自微信公众号「Shooter茶杯」,作者Shooter。转载本文请联系Shooter茶杯公众号。 

抱歉很久没写新文章了 , 这段时间一直在学习扩大自己的知识盲区 , 工作上也挺忙的 , 拖更了好久

答应了朋友要出个 JVM 系列 , 应该会有几篇文章 , 我会努力在保证质量的前提下进行输出~

So 进入今天的主题

前言

有被 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 参数:

  1. -Xmx12288m 初始堆大小.

  2. -Xms12288m 最大堆大小

  3. -Xss256k 每个线程的栈内存大小

  4. -XX:MetaspaceSize=256m 元空间初始大小

  5. -XX:MaxMetaspaceSize=1g 元空间最大大小

  6. -XX:MaxGCPauseMillis=200 每次YGC / MixedGC 的最多停顿时间 (期望最长停顿时间)

  7. -XX:+UseG1GC java8 指定使用G1垃圾回收器

  8. -XX:-OmitStackTraceInFastThrow 对异常做的一个优化,抛出异常非常快,但是看不到异常的堆栈信息(仅供参考)

  9. -XX:MinHeapFreeRatio=30 GC后java堆中空闲量占的最小比例,小于该值,则堆内存会增加

  10. -XX:MaxHeapFreeRatio=50 GC后java堆中空闲量占的最大比例,大于该值,则堆内存会减少

  11. -XX:CICompilerCount=4 设置的相对较大可以一定程度提升JIT编译的速度,默认为2

  12. -XX:SoftRefLRUPolicyMSPerMB=0 任何软引用对象在下一次 GC 都尽快释放掉,给内存释放空间。

  13. -XX:+PrintGC 输出GC日志

  14. -XX:+PrintGCDetails 输出GC的详细日志

  15. -XX:+PrintGCDateStamps 输出GC的时间戳(以基准时间的形式)

  16. -XX:+UseGCLogFileRotation 开或关闭GC日志滚动记录功能

  17. -XX:NumberOfGCLogFiles=5 设置滚动日志文件的个数

  18. -XX:GCLogFileSize=32M 设置滚动日志文件的大小,当前写日志文件大小超过该参数值时,日志将写入下一个文件

  19. -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 哪些对象会进入老年代?

  1. 一个对象在年轻代里躲过15次垃圾回收,年龄太大了,寿终正寝,进入老年代

  2. 大对象直接送到老年代 参数 XX:PretenureSizeThreshold 来控制多大的对象才算大。XX:PretenureSizeThreshold=100000000 单位为btye

  3. 动态年龄判定规则,如果一旦发现某次新生代 GC 过后,存活对象超过了 Survivor 50%

  4. 一次 ygc 过后存活对象太多了,导致 Survivor 区域放不下了,这批对象会进入老年代

这个接口的耗时一般在 200ms 左右,但在高并发情况下,内存资源这么吃紧,CPU 和 线程资源都会有很高的负载,这时候就很有可能出现一些性能抖动的情况

相应的表现就是接口的响应时间延长,甚至会出现超时,在频繁的 fgc 情况下:

  1. 一些对象在 Survivor区 经过 15 次 ygc 后,就会晋升到老年代

  2. 很多接口的响应时间都延长,导致触发动态年龄判断规则,就会有一大批对象晋升到老年代,

看起来这么大的内存,Survivor区 也足够大,这个晋升规则也比较严格,但是高并发的场景下,上面这个流程只要反复的来几次

老年代的对象就会越来越多

什么是 Mixed GC (混合回收)?

因为 G1 是基于 Region 的,并没有严格的区分老年代新生代,

G1有一个参数,XX:InitiatingHeapOccupancyPercent ,它的默认值是 45% ,意思就是说,如果 老年代 占据了堆内存的 45% 的 Region 的时候,此时就会尝试触发一个新生代+老年代一起回收的混合回收。

什么时候发生 Full GC(fgc) ?

异常情况

  1. 大对象太多,对象都跑老年代去了,老年代内存吃紧会触发 fgc ,如果fgc 内存还是不够使用,那再申请内存的时候就会抛出 OOM 异常,然后再 fgc 如此往复循环,系统并不会直接挂掉,表现是系统假死,非常卡顿,用户体验极差。

  2. 元空间、直接内存这些区域快满了都会触发 fgc

后续 堆空间、元空间、直接内存(堆外内存) OOM 都会有真实的生产环境案例 敬请期待

正常情况

  1. fgc 都知道是一个很耗时的操作 , G1 正常的工作状态是没有 Full GC 概念的,老年代垃圾的收集任务全靠 Mixed GC 来处理。

  2. 不过在进行 Mixed 回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个 Region 的存活对象拷贝到别的 Region 里去,

  3. 此时万一出现拷贝的过程中发现没有空闲 Region 可以承载自己的存活对象了,就会触发一次失败。一旦失败,立马就会切换为停止系统程序,切换到 G1 之外的 Serial Old GC 来收集整个堆(包括 Young、Old、Metaspace )这才是真正的 Full GC(Full GC不在G1的控制范围内)

  4. 进入这种状态的G1就跟使用参数 -XX:+UseSerialGC 的 Full GC 一样(背后的核心逻辑是一样的)。然后采用单线程进行标记、清理和压缩整理,空闲出来一批 Region ,使用单线程的进行 gc 这个过程是极慢极慢的。

  5. 这也是 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% 就会触发

再回顾一下:年轻代的对象进入老年代的几个条件:

  1. 新生代 gc 过后存活对象太多没法放入 Survivor 区域

  2. 对象年龄太大

  3. 动态年龄判定规则

其中尤其关键的是新生代 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

五、实际有效的调优参数

1.-XX:MaxGCPauseMillis: 根据系统可以接受的响应时长和指标 观察 JVM 的回收时间来进行修改 单位:ms

太小跟不上分配内存的速度 , 太大 gc 的时间太长。

2.-XX:ParallelGCThreads: 在 stw 阶段工作的 GC 线程数 , 可以根据当前机器 CPU 核数来设置 , 建议核心数 -1

-XX:ConcGCThreads: 在非 stw 阶段工作的 GC 线程数 , 会影响系统的吞吐量 , 毕竟是要跟用户线程抢 CPU 资源

系统如果是计算密集型的建议是 CPU 核数的 1/4 ~ 1/3 , iO 密集型建议是 1/2

3.-XX:G1ReservePercent: G1为分配担保预留的空间比例 也就是老年代留多少空间给 新生代来晋升 , 默认是 10%

如果晋升失败会触发单线程的 old gc 非常恐怖 , 建议高并发系统加大机器内存 提高这个参数的比例

4.-XX:MaxMetaspaceSize: 元空间最大大小 , 在高并发且机器内存够的情况 建议增大元空间的大小

稍微大的点系统都会有很多依赖的组件,这些组件底层都有可能会用到一些反射 或者 字节码框架 , 会生成一些你看不懂类名的类

一旦第三方框架出现问题 , 你的系统很有可能也会受影响

调大元空间 , 有监控系统的设置报警机制 , 给自己系统争取一些缓冲时间也是有必要的

5.-XX:TraceClassLoading -XX:TraceClassUnloading

追踪类加载和类卸载的情况 , 可以在 Tomcat 的 catalina.out 日志文件中

打印出来JVM中加载了哪些类,卸载了哪些类

6.-XX:SoftRefLRUPolicyMSPerMB: JVM 可以忍受多久 软引用不被回收

如果是 0 则每次都会把软引用回收掉释放内存

有一个情况是反射在 15 次后会动态生成一些软引用类来提高反射的效率 , 当 ygc 的时候把这些软应用给回收了

但是它们的类加载器或者一些奇怪名字的类还在元空间 , 那下次要用这个反射对象的时候又得重新创建

就造成了元空间慢慢无限增大从而触发 OOM , 建议这个参数设置 2000 - 5000 单位是: ms

7.-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()

8.-XX:G1MixedGCCountTarget: 设置垃圾回收混合回收阶段,最多可以拆成几次回收

G1 的垃圾回收是分为 初始标记、并发标记、最终标记、混合回收 这几个阶段的

其中混合回收是可以并发的反复回收多次 , 这样的好处是避免单次停顿回收 stw 时间太长

停止系统一会儿 , 回收掉一些 Region , 再让系统运行一会儿 , 然后再次停止系统一会儿 , 再次回收掉一些 Region

这样可以尽可能让系统不要停顿时间过长 , 可以在多次回收的间隙 , 也运行一下

在一定程度上可以防止部分接口相应超时

六、小结

相信你看到这里 , 应该对高并发系统中 对象如何吃 JVM 内存 频繁 遇到 gc 如何解决 已经有所了解了 。

尽管有效的解决办法仍然是加机器 , 但是加多少台机器 , 怎么加机器 , JVM 参数要如何设置都有所了解了。

亿级流量系统如何玩转 JVM

【编辑推荐】

  1. 揭秘Java 之Jvm内幕

  2. 「秒懂!」JVM虚拟机图文详解!一点都不难

  3. 十个问题弄清JVM&GC

  4. JVM性能调优Java内存区域与内存溢出异常

  5. 一次完整的JVM堆外内存泄漏故障排查记录

【责任编辑:武晓燕 TEL:(010)68476606】

扫码领视频副本.gif

0

精彩评论

暂无评论...
验证码 换一张
取 消

关注公众号