# JVM 性能调优实战
# 调优的一些原则
- 多数的 Java 应用不需要在服务器上进行 GC 优化,虚拟机内部已有很多优化来保证应用的稳定运行,所以不要为了调优而调优,不当的调优可能适得其反
- 在应用上线之前,先考虑将机器的 JVM 参数设置到最优(适合)
- 在进行 GC 优化之前,需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用,通过 GC 优化令其性能达到一个质的飞跃
- GC 优化是一个系统而复杂的工作,没有万能的调优策略可以满足所有的性能指标。GC 优化必须建立在我们深入理解各种垃圾回收器的基础上,才能有事半功倍的效果
- 处理吞吐量和延迟问题时,垃圾处理器能使用的内存越大,即 java 堆空间越大垃圾收集效果越好,应用运行也越流畅。这称之为 GC 内存最大化原则
- 在这三个属性(吞吐量、延迟、内存)中选择其中两个进行 jvm 调优,称之为 GC 调优 3 选 2
# 什么情况下需要调优
- Heap 内存(老年代)持续上涨达到设置的最大内存值
- Full GC 次数频繁
- GC 停顿(Stop World)时间过长(超过 1 秒,具体值按应用场景而定)
- 应用出现 OutOfMemory 等内存异常
- 应用出现 OutOfDirectMemoryError 等内存异常( failed to allocate 16777216 byte (s) of direct memory (used: 1056964615, max: 1073741824))
- 应用中有使用本地缓存且占用大量内存空间
- 系统吞吐量与响应性能不高或下降
- 应用的 CPU 占用过高不下或内存占用过高不下
# 调优前需知的一些概念
- ** 吞吐量:** 用户代码时间 / (用户代码执行时间 + 垃圾回收时间)。是评价垃圾收集器能力的重要指标之一,是不考虑垃圾收集引起的停顿时间或内存消耗,垃圾收集器能支撑应用程序达到的最高性能指标。吞吐量越高算法越好。
- ** 低延迟:**STW 越短,响应时间越好。评价垃圾收集器能力的重要指标,度量标准是缩短由于垃圾收集引起的停顿时间或完全消除因垃圾收集所引起的停顿,避免应用程序运行时发生抖动。暂停时间越短算法越好
- 在设计(或使用)GC 算法时,我们必须确定我们的目标:一个 GC 算法只可能针对两个目标之一(即只专注于最大吞吐量或最小暂停时间),或尝试找到一个二者的折中
- MinorGC 尽可能多的收集垃圾对象。我们把这个称作 MinorGC 原则,遵守这一原则可以降低应用程序 FullGC 的发生频率。FullGC 较耗时,是应用程序无法达到延迟要求或吞吐量的罪魁祸首
- 堆大小调整的着手点、分析点:
- 统计 Minor GC 持续时间
- 统计 Minor GC 的次数
- 统计 Full GC 的最长持续时间
- 统计最差情况下 Full GC 频率
- 统计 GC 持续时间和频率对优化堆的大小是主要着手点
- 我们按照业务系统对延迟和吞吐量的需求,在按照这些分析我们可以进行各个区大小的调整
- 一般来说吞吐量优先的垃圾回收器:-XX:+UseParallelGC -XX:+UseParallelOldGC,即常规的(PS/PO)
- 响应时间优先的垃圾回收器:CMS、G1
# JVM 常用参数解读
- Xms 是指设定程序启动时占用内存大小。一般来讲,大点,程序会启动的快一点,但是也可能会导致机器暂时变慢
- Xmx 是指设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出 OutOfMemory 异常
- Xss 是指设定每个线程的堆栈大小。这个就要依据你的程序,看一个线程大约需要占用多少内存,可能会有多少线程同时运行等
- **-Xmn、-XX:NewSize/-XX:MaxNewSize、-XX:NewRatio **
- 高优先级:-XX:NewSize/-XX:MaxNewSize
- 中优先级:-Xmn(默认等效 -Xmn=-XX:NewSize=-XX:MaxNewSize=?)
- 低优先级:-XX:NewRatio
- 如果想在日志中追踪类加载与类卸载的情况,可以使用启动参数 **-XX:TraceClassLoading -XX:TraceClassUnloading **
# 常用性能调优工具
- MAT
- 提示可能的内存泄露的点
- jvisualvm
- jconsole
- Arthas
- show-busy-java-threads
- https://github.com/oldratlee/useful-scripts/blob/master/docs/java.md#-show-busy-java-threads
#
- https://github.com/oldratlee/useful-scripts/blob/master/docs/java.md#-show-busy-java-threads
# 线上排查问题的一般流程
- CPU 占用过高排查流程
- 利用 top 命令可以查出占 CPU 最高的的进程 pid ,如果 pid 为 9876
- 然后查看该进程下占用最高的线程 id【top -Hp 9876】
- 假设占用率最高的线程 ID 为 6900,将其转换为 16 进制形式 (因为 java native 线程以 16 进制形式输出) 【printf '% x\n' 6900】
- 利用 jstack 打印出 java 线程调用栈信息【jstack 9876 | grep '0x1af4' -A 50 --color】,这样就可以更好定位问题
- 内存占用过高排查流程
- 查找进程 id: 【top -d 2 -c】
- 查看 JVM 堆内存分配情况:jmap -heap pid
- 查看占用内存比较多的对象 jmap -histo pid | head -n 100
- 查看占用内存比较多的存活对象 jmap -histo:live pid | head -n 100
# OOM 内存泄露分析
# 什么情况下,会抛出 OOM 呢?
- JVM98% 的时间都花费在内存回收
- 每次回收的内存小于 2%
满足这两个条件将触发 OutOfMemoryException,这将会留给系统一个微小的间隙以做一些 Down 之前的操作,比如手动打印 Heap Dump。并不是内存被耗空的时候才抛出
# 系统 OOM 之前的一些现象
- 每次垃圾回收的时间越来越长,由之前的 10ms 延长到 50ms 左右,FullGC 的时间也有之前的 0.5s 延长到 4、5s
- FullGC 的次数越来越多,最频繁时隔不到 1 分钟就进行一次 FullGC
- 老年代的内存越来越大并且每次 FullGC 后,老年代只有少量的内存被释放掉
# 堆 Dump 文件分析
可以通过指定启动参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/app/data/dump/heapdump.hpro 在发生 OOM 的时候自动导出 Dump 文件
# GC 日志分析
为了方便分析 GC 日志信息,可以指定启动参数 【-Xloggc: app-gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps】, 方便详细地查看 GC 日志信息
- 使用 【jinfo pid】查看当前 JVM 堆的相关参数
- 继续使用 【jstat -gcutil 2315 1s 10】查看 10s 内当前堆的占用情况
- 也可以使用【jmap -heap pid】查看当前 JVM 堆的情况
- 我们可以继续使用 【jmap -F -histo pid | head -n 20】,查看前 20 行打印,即查看当前 top20 的大对象,一般从这里可以发现一些异常的大对象,如果没有,那么可以继续排名前 50 的大对象,分析
- 最后使用【jmap -F -dump:file=a.bin pid】,如果 dump 文件很大,可以压缩一下【tar -czvf a.tar.gz a.bin】
- 再之后,就是对 dump 文件进行分析了,使用 MAT 分析内存泄露
- 参考案例: https://www.lagou.com/lgeduarticle/142372.html
# 线上死锁排查
- jps 查找一个可能有问题的进程 id
- 然后执行 【jstack -F 进程 id】
- 如果环境允许远程连接 JVM,可以使用 jconsole 或者 jvisualvm,图形化界面检测是否存在死锁
# 线上 YGC 耗时过长优化
- 如果生命周期过长的对象越来越多(比如全局变量或者静态变量等),会导致标注和复制过程的耗时增加
- 对存活对象标注时间过长:比如重载了 Object 类的 Finalize 方法,导致标注 Final Reference 耗时过长;或者 String.intern 方法使用不当,导致 YGC 扫描 StringTable 时间过长。可以通过以下参数显示 GC 处理 Reference 的耗时 - XX:+PrintReferenceGC
- 长周期对象积累过多:比如本地缓存使用不当,积累了太多存活对象;或者锁竞争严重导致线程阻塞,局部变量的生命周期变长
- 案例参考: https://my.oschina.net/lishangzhi/blog/4703942
# 线上频繁 FullGC 优化
- 线上频繁 FullGC 一般会有这么几个特征:
- 线上多个线程的 CPU 都超过了 100%,通过 jstack 命令可以看到这些线程主要是垃圾回收线程
- 通过 jstat 命令监控 GC 情况,可以看到 Full GC 次数非常多,并且次数在不断增加
- 排查流程:
- top 找到 cpu 占用最高的一个 进程 id
- 然后 【top -Hp 进程 id】,找到 cpu 占用最高的 线程 id
- 【printf "%x\n" 线程 id 】,假设 16 进制结果为 a
- jstack 线程 id | grep '0xa' -A 50 --color
- 如果是正常的用户线程, 则通过该线程的堆栈信息查看其具体是在哪处用户代码处运行比较消耗 CPU
- 如果该线程是 VMThread,则通过 jstat-gcutil 命令监控当前系统的 GC 状况,然后通过 jmapdump:format=b,file = 导出系统当前的内存数据。导出之后将内存情况放到 eclipse 的 mat 工具中进行分析即可得出内存中主要是什么对象比较消耗内存,进而可以处理相关代码;正常情况下会发现 VM Thread 指的就是垃圾回收的线程
- 再执行【jstat -gcutil ** 进程 id】, ** 看到结果,如果 FGC 的数量很高,且在不断增长,那么可以定位是由于内存溢出导致 FullGC 频繁,系统缓慢
- 然后就可以 Dump 出内存日志,然后使用 MAT 的工具分析哪些对象占用内存较大,然后找到对象的创建位置,处理即可
- 参考案例:https://mp.weixin.qq.com/s/g8KJhOtiBHWb6wNFrCcLVg
#
# 线上堆外内存泄漏分析(Netty 尤其居多)
- JVM 的堆外内存泄露的定位一直是个比较棘手的问题
- 对外内存的泄漏分析一般都是先从堆内内存分析的过程中衍生出来的。有可能我们分析堆内内存泄露过程中发现,我们计算出来的 JVM 堆内存竟然大于了整个 JVM 的 Xmx 的大小,那说明多出来的是堆外内存
- 如果使用了 Netty 堆外内存,那么可以自行监控堆外内存的使用情况,不需要借助第三方工具,我们是使用的 “反射” 拿到的堆外内存的情况
- 逐渐缩小范围,直到 Bug 被找到。当我们确认某个线程的执行带来 Bug 时,可单步执行,可二分执行,定位到某行代码之后,跟到这段代码,然后继续单步执行或者二分的方式来定位最终出 Bug 的代码。这个方法屡试不爽,最后总能找到想要的 Bug
- 熟练掌握 idea 的调试,让我们的 “捉虫” 速度快如闪电(“闪电侠” 就是这么来的)。这里,最常见的调试方式是预执行表达式,以及通过线程调用栈,死盯某个对象,就能够掌握这个对象的定义、赋值之类
- 在使用直接内存的项目中,最好建议配置 -XX:MaxDirectMemorySize,设定一个系统实际可达的最大的直接内存的值,默认的最大直接内存大小等于 -Xmx 的值
- 排查堆外泄露,建议指定启动参数: -XX:NativeMemoryTracking=summary - Dio.netty.leakDetection.targetRecords=100-Dio.netty.leakDetection.level=PARANOID,后面两个参数是 Netty 的相关内存泄露检测的级别与采样级别
- 参考案例: https://tech.meituan.com/2018/10/18/netty-direct-memory-screening.html
# 线上元空间内存泄露优化
- 需要注意的一点是 Java8 以及 Java8 + 的 JVM 已经将永久代废弃了,取而代之的是元空间,且元空间是不是在 JVM 堆中的,而属于堆外内存,受最大物理内存限制。最佳实践就是我们在启动参数中最好设置上 -XX:MetaspaceSize=1024m -XX:MaxMetaspaceSize=1024m。具体的值根据情况设置。为避免动态申请,可以直接都设置为最大值
- 元空间主要存放的是类元数据,而且 metaspace 判断类元数据是否可以回收,是根据加载这些类元数据的 Classloader 是否可以回收来判断的,只要 Classloader 不能回收,通过其加载的类元数据就不会被回收。所以线上有时候会出现一种问题,由于框架中,往往大量采用类似 ASM、javassist 等工具进行字节码增强,生成代理类。如果项目中由主线程频繁生成动态代理类,那么就会导致元空间迅速占满,无法回收
- 具体案例可以参见: [https://zhuanlan.zhihu.com/p/200802910](