Java 性能问题定位:从系统指标到应用排查
参考阿里云大佬的博客,大佬写的非常好
本文整理 Java 应用在实际线上环境中进行性能问题定位时常见的分析思路,主要从业务代码、CPU、线程、内存、GC、磁盘 I/O、网络 I/O 以及常用命令几个角度展开。
性能问题往往不是单一指标异常导致的,而是系统层、组件层和应用层相互影响后的结果。因此,排查时建议先从应用层入手,再结合系统指标进行验证。
性能优化工具图谱 ├── 系统层 │ ├── CPU │ │ ├── 🚩 CPU利用率(top/vmstat/sar/dstat) │ │ ├── 🚩 CPU平均负载(top/uptime) │ │ └── 上下文切换次数(pidstat/vmstat/dstat) │ ├── 内存 │ │ ├── 全局内存使用 │ │ │ ├── 🚩 已用/剩余/可用内存(free/vmstat/sar) │ │ │ └── 缓冲区/缓存(pcstat/cachestat/cachetop) │ │ └── 进程内存使用 │ │ ├── 🚩 虚拟内存/常驻内存/共享内存(top/ps/pidstat) │ │ ├── 🚩 SWAP 内存使用/换入换出速度(top/free/vmstat/sar) │ │ ├── 缺页异常(ps/pidstat) │ │ └── 内存分布(pmap/jmap) │ ├── 磁盘 │ │ ├── 空间容量(df/du) │ │ ├── 🚩 吞吐量/磁盘 I/O 使用率(iostat/dstat/sar) │ │ └── 缓冲区/缓存(pcstat/cachestat/cachetop) │ └── 网络 │ ├── 🚩 吞吐量(sar) │ ├── 网络延迟(ping) │ ├── 🚩 网络连接数/错误数(netstat/ss/sar) │ └── 网络抓包(tcpdump/wireshark) ├── 组件层 │ ├── 数据库 │ │ ├── SQL 调优 │ │ ├── 🚩 索引调优 │ │ └── 连接池配置 │ ├── 网络 IO │ │ ├── I/O 调度模型 │ │ ├── 序列化框架 │ │ └── 线程调度模型 │ ├── Web 容器 │ │ └── 线程池配置 │ └── 缓存/MQ…… └── 应用层 ├── 线程 │ ├── 死锁检查(jstack/arthas) │ ├── 🚩 线程状态分布(jstack/arthas) │ ├── 锁竞争分布(jstack/arthas) │ ├── 🚩 代码执行热点(jprofiler/zprofiler) │ ├── 🚩 占用 CPU 较重的线程(top + pidstat + jstack) │ └── 代码追踪(btrace/housemd/greys/arthas) ├── 内存 │ ├── 内存分配 │ │ ├── 常驻内存/虚拟内存(top) │ │ ├── 对象分配热点(jprofiler/zprofiler) │ │ ├── 🚩 堆内对象分布(jmap/zprofiler/MAT) │ │ ├── 类加载相关(jstat/greys/arthas) │ │ ├── 🚩 内存泄漏(gperf/MAT/zprofiler) │ │ └── 堆外内存(jmap + MAT + NMT + gdb + perf) │ └── 垃圾回收 │ ├── GC 线程使用(jinfo) │ ├── 对象晋升年龄(gclog) │ ├── 🚩 GC 的频率和时间(jstat/gclog) │ ├── 垃圾回收器类型/JVM 参数(jinfo/jcmd) │ └── 🚩 堆大小设置及分区大小(jinfo/jstat) ├── 网络 │ ├── 带宽使用 │ ├── 流量异动 │ └── 网络分区 └── ★ 业务(日志、监控…) ├── 🚩 代码逻辑 ├── 远程调用 └── 架构设计
CPU&&线程
和 CPU 相关的指标主要有以下几个。常用的工具有 top、 ps、uptime、 vmstat、 pidstat等。
CPU利用率(CPU Utilization) CPU 平均负载(Load Average) 上下文切换次数(Context Switch)
top - 12:20:57 up 25 days, 20:49, 2 users, load average: 0.93, 0.97, 0.79
Tasks: 51 total, 1 running, 50 sleeping, 0 stopped, 0 zombie
%Cpu(s): 1.6 us, 1.8 sy, 0.0 ni, 89.1 id, 0.1 wa, 0.0 hi, 0.1 si, 7.3 st
KiB Mem : 8388608 total, 476436 free, 5903224 used, 2008948 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 0 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
119680 admin 20 0 600908 72332 5768 S 2.3 0.9 52:32.61 obproxy
65877 root 20 0 93528 4936 2328 S 1.3 0.1 449:03.61 alisentry_cli
第一行显示的内容:当前时间、系统运行时间以及正在登录用户数。load average 后的三个数字,依次表示过去 1 分钟、5 分钟、15 分钟的平均负载(Load Average)。平均负载是指单位时间内,系统处于可运行状态(正在使用 CPU 或者正在等待 CPU 的进程,R 状态)和不可中断状态(D 状态)的平均进程数,也就是平均活跃进程数,CPU 平均负载和 CPU 使用率并没有直接关系。
第三行的内容表示 CPU 利用率,每一列的含义可以使用 man 查看。CPU 使用率体现了单位时间内 CPU 使用情况的统计,以百分比的方式展示。计算方式为:CPU 利用率 = 1 - (CPU 空闲时间)/ CPU 总的时间。需要注意的是,通过性能分析工具得到的 CPU 的利用率其实是某个采样时间内的 CPU 平均值。注:top 工具显示的的 CPU 利用率是把所有 CPU 核的数值加起来的,即 8 核 CPU 的利用率最大可以到达800%(可以用 htop 等更新一些的工具代替 top)。
使用 vmstat 命令,可以查看到「上下文切换次数」这个指标,如下表所示,每隔1秒输出1组数据:
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 504804 0 1967508 0 0 644 33377 0 1 2 2 88 0 9
上表的 cs(context switch) 就是每秒上下文切换的次数,按照不同场景,CPU 上下文切换还可以分为中断上下文切换、线程上下文切换和进程上下文切换三种,但是无论是哪一种,过多的上下文切换,都会把 CPU 时间消耗在寄存器、内核栈以及虚拟内存等数据的保存和恢复上,从而缩短进程真正运行的时间,导致系统的整体性能大幅下降。vmstat 的输出中 us、sy 分别用户态和内核态的 CPU 利用率,这两个值也非常具有参考意义。
vmstat 的输只给出了系统总体的上下文切换情况,要想查看每个进程的上下文切换详情(如自愿和非自愿切换),需要使用 pidstat,该命令还可以查看某个进程用户态和内核态的 CPU 利用率。
image
CPU 相关指标异常的分析思路是什么?
1)CPU 利用率:如果我们观察某段时间系统或应用进程的 CPU利用率一直很高(单个 core 超过80%),那么就值得我们警惕了。我们可以多次使用 jstack 命令 dump 应用线程栈查看热点代码,非 Java 应用可以直接使用 perf 进行 CPU 采采样,离线分析采样数据后得到 CPU 执行热点(Java 应用需要符号表进行堆栈信息映射,不能直接使用 perf得到结果)。
2)CPU 平均负载:平均负载高于 CPU 数量 70%,意味着系统存在瓶颈点,造成负载升高的原因有很多,在这里就不展开了。需要注意的是,通过监控系统监测平均负载的变化趋势,更容易定位问题,有时候大文件的加载等,也会导致平均负载瞬时升高。如果 1 分钟/5 分钟/15 分钟的三个值相差不大,那说明系统负载很平稳,则不用关注,如果这三个值逐渐降低,说明负载在渐渐升高,需要关注整体性能;
3)CPU 上下文切换:上下文切换这个指标,并没有经验值可推荐(几十到几万都有可能),这个指标值取决于系统本身的 CPU 性能,以及当前应用工作的情况。但是,如果系统或者应用的上下文切换次数出现数量级的增长,就有很大概率说明存在性能问题,如非自愿上下切换大幅度上升,说明有太多的线程在竞争 CPU。
上面这三个指标是密切相关的,如频繁的 CPU 上下文切换,可能会导致平均负载升高。如何根据这三者之间的关系进行应用调优,将在下一部分介绍。
CPU 上的的一些异动,通常也可以从线程上观测到,但需要注意的是,线程问题并不完全和 CPU 相关。与线程相关的指标,主要有下面几个(均都可以通过 JDK 自带的 jstack 工具直接或间接得到):
应用中的总的线程数; 应用中各个线程状态的分布; 线程锁的使用情况,如死锁、锁分布等; 关于线程,可关注的异常有:
1)线程总数是否过多。过多的线程,体现在 CPU 上就是导致频繁的上下文切换,同时线程过多也会消耗内存,线程总数大小和应用本身和机器配置相关;
2)线程的状态是否异常。观察 WAITING/BLOCKED 线程是否过多(线程数设置过多或锁竞争剧烈),结合应用内部锁使用的情况综合分析;
3)结合 CPU 利用率,观察是否存在大量消耗 CPU 的线程。
image
内存&&堆
和内存相关的指标主要有以下几个,常用的分析工具有:top、free、vmstat、pidstat 以及 JDK 自带的一些工具。
系统内存的使用情况,包括剩余内存、已用内存、可用内存、缓存/缓冲区; 进程(含 Java 进程)的虚拟内存、常驻内存、共享内存; 进程的缺页异常数,包含主缺页异常和次缺页异常; Swap 换入和换出的内存大小、Swap 参数配置; JVM 堆的分配,JVM 启动参数; JVM 堆的回收,GC 情况。 使用 free 可以查看系统内存的使用情况和 Swap 分区的使用情况,top 工具可以具体到每个进程,如我们可以用使用 top 工具查看 Java 进程的常驻内存大小(RES),这两个工具结合起来,可用覆盖大多数内存指标。下面是使用 free命令的输出:
$free -h
total used free shared buff/cache available
Mem: 125G 6.8G 54G 2.5M 64G 118G
Swap: 2.0G 305M 1.7G
上述输出各列的具体含义在这里不在赘述,也比较容易理解。重点介绍下 swap 和 buff/cache 这两个指标。
Swap 的作用是把一个本地文件或者一块磁盘空间作为内存来使用,包括换出和换入两个过程。Swap 需要读写磁盘,所以性能不是很高,事实上,包括 ElasticSearch 、Hadoop 在内绝大部分 Java 应用都建议关掉 Swap,这是因为内存的成本一直在降低,同时这也和 JVM 的垃圾回收过程有关:JVM在 GC 的时候会遍历所有用到的堆的内存,如果这部分内存被 Swap 出去了,遍历的时候就会有磁盘 I/O 产生。Swap 分区的升高一般和磁盘的使用强相关,具体分析时,需要结合缓存使用情况、swappiness 阈值以及匿名页和文件页的活跃情况综合分析。
buff/cache 是缓存和缓冲区的大小。缓存(cache):是从磁盘读取的文件的或者向磁盘写文件时的临时存储数据,面向文件。使用 cachestat 可以查看整个系统缓存的读写命中情况,使用 cachetop 可以观察每个进程缓存的读写命中情况。缓冲区(buffer)是写入磁盘数据或从磁盘直接读取的数据的临时存储,面向块设备。free 命令的输出中,这两个指标是加在一起的,使用 vmstat 命令可以区分缓存和缓冲区,还可以看到 Swap 分区换入和换出的内存大小。
了解到常见的内存指标后,常见的内存问题又有哪些?总结如下:
系统剩余内存/可用不足(某个进程占用太多、系统本身内存不足),内存溢出; 内存回收异常:内存泄漏(进程在一段时间内内存使用持续走高)、GC 频率异常; 缓存使用过大(大文件读取或写入)、缓存命中率不高; 缺页异常过多(频繁的 I/O 读); Swap 分区使用异常(使用过大); 内存相关指标异常后,分析思路是怎么样的?
使用 free/top 查看内存的全局使用情况,如系统内存的使用、Swap 分区内存使用、缓存/缓冲区占用情况等,初步判断内存问题存在的方向:进程内存、缓存/缓冲区、Swap 分区; 观察一段时间内存的使用趋势。如通过 vmstat 观察内存使用是否一直在增长;通过 jmap 定时统计对象内存分布情况,判断是否存在内存泄漏,通过 cachetop 命令,定位缓冲区升高的根源等; 根据内存问题的类型,结合应用本身,进行详细分析。 举例:使用 free 发现缓存/缓冲区占用不大,排除缓存/缓冲区对内存的影响后 -> 使用 vmstat 或者 sar 观察一下各个进程内存使用变化趋势 -> 发现某个进程的内存时候用持续走高 -> 如果是 Java 应用,可以使用 jmap / VisualVM / heap dump 分析等工具观察对象内存的分配,或者通过 jstat 观察 GC 后的应用内存变化 -> 结合业务场景,定位为内存泄漏/GC参数配置不合理/业务代码异常等。
磁盘&&文件
在分析和磁盘相关的问题时,通常是将其和文件系统同时考虑的,下面不再区分。和磁盘/文件系统相关的指标主要有以下几个,常用的观测工具为 iostat和 pidstat,前者适用于整个系统,后者可观察具体进程的 I/O。
磁盘 I/O 利用率:是指磁盘处理 I/O 的时间百分比; 磁盘吞吐量:是指每秒的 I/O 请求大小,单位为 KB; I/O 响应时间,是指 I/O 请求从发出到收到响应的间隔,包含在队列中的等待时间和实际处理时间; IOPS(Input/Output Per Second):每秒的 I/O 请求数; I/O 等待队列大小,指的是平均 I/O 队列长度,队列长度越短越好; 使用 iostat 的输出界面如下:
$iostat -dx
Linux 3.10.0-327.ali2010.alios7.x86_64 (loginhost2.alipay.em14) 10/20/2019 x86_64 (32 CPU)
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.01 15.49 0.05 8.21 3.10 240.49 58.92 0.04 4.38 2.39 4.39 0.09 0.07
上图中 %util ,即为磁盘 I/O 利用率,同 CPU 利用率一样,这个值也可能超过 100%(存在并行 I/O);rkB/s 和 wkB/s分别表示每秒从磁盘读取和写入的数据量,即吞吐量,单位为 KB;磁盘 I/O处理时间的指标为 r_await 和 w_await 分别表示读/写请求处理完成的响应时间,svctm 表示处理 I/O 所需要的平均时间,该指标已被废弃,无实际意义。r/s + w/s 为 IOPS 指标,分别表示每秒发送给磁盘的读请求数和写请求数;aqu-sz 表示等待队列的长度。
pidstat 的输出大部分和 iostat 类似,区别在于它可以实时查看每个进程的 I/O 情况。
如何判断磁盘的指标出现了异常?
当磁盘 I/O 利用率长时间超过 80%,或者响应时间过大(对于 SSD,从 0.0x 毫秒到 1.x 毫秒不等,机械磁盘一般为5ms~10ms),通常意味着磁盘 I/O 存在性能瓶颈; 如果 %util 很大,而 rkB/s 和 wkB/s 很小,一般是因为存在较多的磁盘随机读写,最好把随机读写优化成顺序读写,(可以通过 strace 或者 blktrace 观察 I/O 是否连续判断是否是顺序的读写行为,随机读写应可关注 IOPS 指标,顺序读写可关注吞吐量指标); 如果 avgqu-sz 比较大,说明有很多 I/O 请求在队列中等待。一般来说,如果单块磁盘的队列长度持续超过2,一般认为该磁盘存在 I/O 性能问题。 image
网络
网络这个概念涵盖的范围较广,在应用层、传输层、网络层、网络接口层都有不同的指标去衡量。这里我们讨论的「网络」,特指应用层的网络,通常使用的指标如下:
网络带宽:表示链路的最大传输速率; 网络吞吐:表示单位时间内成功传输的数据量大小; 网络延时:表示从网络请求发出后直到收到远端响应,所需要的时间; 网络连接数和错误数; 一般来说,应用层的网络瓶颈有如下几类:
集群或机器所在的机房的网络带宽饱和,影响应用 QPS/TPS 的提升; 网络吞吐出现异常,如接口存在大量的数据传输,造成带宽占用过高; 网络连接出现异常或错误; 网络出现分区。 带宽和网络吞吐这两个指标,一般我们会关注整个应用的,通过监控系统可直接得到,如果一段时间内出现了明显的指标上升,说明存在网络性能瓶颈。对于单机,可以使用 sar 得到网络接口、进程的网络吞吐。
使用 ping 或者 hping3 可以得到是否出现网络分区、网络具体时延。对于应用,我们更关注整个链路的时延,可以通过中间件埋点后输出的 trace 日志得到链路上各个环节的时延信息。
使用 netstat、ss 和 sar 可以获取网络连接数或网络错误数。过多网络链接造成的开销是很大的,一是会占用文件描述符,二是会占用缓存,因此系统可以支撑的网络链接数是有限的。
工具总结
可以看到的是,在分析 CPU、内存、磁盘等的性能指标时,有几种工具是高频出现的,如 top、vmstat、pidstat,这里稍微总结一下:
CPU:top、vmstat、pidstat、sar、perf、jstack、jstat; 内存:top、free、vmstat、cachetop、cachestat、sar、jmap; 磁盘:top、iostat、vmstat、pidstat、du/df; 网络:netstat、sar、dstat、tcpdump; 应用:profiler、dump分析。 上述的很多工具,大部分是用于查看系统层指标的,在应用层,除了有 JDK 提供的一系列工具,一些商用的产品如 gceasy.io(分析 GC 日志)、fastthread.io(分析线程 dump 日志)也是不错的。
排查 Java 应用的线上异常或者分析应用代码瓶颈,可以使用阿里开源的 Arthas ,这个工具非常强大,下面简单介绍下。
Arthas 主要面向线上应用实时诊断,解决的是类似「线上应用异常了,需要在线进行分析和定位」的问题,当然,Arthas 提供的一些方法调用追踪工具,对我们排查诸如「慢查询」等问题,也是非常有帮助的。Arthas 提供的主要功能有:
获取线程统计,如线程持有的锁统计、CPU 利用率统计等; 类加载信息、动态类加载、方法加载信息; 调用栈追踪,调用耗时统计; 方法调用参数、结果检测; 系统配置、应用配置信息; 反编译加载类; …. 需要注意的是,性能工具只是解决性能问题的手段,我们了解常用工具的一般用法即可,不要在工具学习上投入过多精力。
在通过工具得到异常指标,初步定位瓶颈点后,如何进一步进行确认和调优?下面将给出常见的一些调优分析思路,内容会按照CPU、内存、网络、磁盘等进行组织
性能瓶颈、故障的分析
1 代码相关
遇到性能问题,首先应该做的是检查否与业务代码相关——不是通过阅读代码解决问题,而是通过日志或代码,排除掉一些与业务代码相关的低级错误。性能优化的最佳位置,是应用内部。
譬如,查看业务日志,检查日志内容里是否有大量的报错产生,应用层、框架层的一些性能问题,大多数都能从日志里找到端倪(日志级别设置不合理,导致线上疯狂打日志);再者,检查代码的主要逻辑,如 for 循环的不合理使用、NPE、正则表达式、数学计算等常见的一些问题,都可以通过简单地修改代码修复问题。
别动辄就把性能优化和缓存、异步化、JVM 调优等名词挂钩,复杂问题可能会有简单解,「二八原则」在性能优化的领域里里依然有效。当然了,了解一些基本的「代码常用踩坑点」,可以加速我们问题分析思路的过程,从 CPU、内存、JVM 等分析到的一些瓶颈点优化思路,也有可能在代码这里体现出来。
下面是一些高频的,容易造成性能问题的编码要点。
一、正则表达式非常消耗 CPU(如贪婪模式可能会引起回溯),慎用字符串的 split()、replaceAll() 等方法;正则表达式表达式一定预编译。
二、String.intern() 在低版本(Java 1.6 以及之前)的 JDK 上使用,可能会造成方法区(永久代)内存溢出。在高版本 JDK 中,如果 string pool 设置太小而缓存的字符串过多,也会造成较大的性能开销。
三、输出异常日志的时候,如果堆栈信息是明确的,可以取消输出详细堆栈,异常堆栈的构造是有成本的。注意:同一位置抛出大量重复的堆栈信息,JIT 会将其优化后成,直接抛出一个事先编译好的、类型匹配的异常,异常堆栈信息就看不到了。
四、避免引用类型和基础类型之间无谓的拆装箱操作,请尽量保持一致,自动装箱发生太频繁,会非常严重消耗性能。
五、Stream API 的选择。复杂和并行操作,推荐使用 Stream API,可以简化代码,同时发挥来发挥出 CPU 多核的优势,如果是简单操作或者 CPU 是单核,推荐使用显式迭代。
六、根据业务场景,通过 ThreadPoolExecutor 手动创建线程池,结合任务的不同,指定线程数量和队列大小,规避资源耗尽的风险,统一命名后的线程也便于后续问题排查。
七、根据业务场景,合理选择并发容器。如选择 Map 类型的容器时,如果对数据要求有强一致性,可使用 Hashtable 或者 「Map + 锁」 ;读远大于写,使用 CopyOnWriteArrayList;存取数据量小、对数据没有强一致性的要求、变更不频繁的,使用 ConcurrentHashMap;存取数据量大、读写频繁、对数据没有强一致性的要求,使用 ConcurrentSkipListMap。
八、锁的优化思路有:减少锁的粒度、循环中使用锁粗化、减少锁的持有时间(读写锁的选择)等。同时,也考虑使用一些 JDK 优化后的并发类,如对一致性要求不高的统计场景中,使用 LongAdder 替代 AtomicLong 进行计数,使用 ThreadLocalRandom 替代 Random 类等。
代码层的优化除了上面这些,还有很多就不一一列出了。我们可以观察到,在这些要点里,有一些共性的优化思路,是可以抽取出来的,譬如:
空间换时间:使用内存或者磁盘,换取更宝贵的CPU 或者网络,如缓存的使用; 时间换空间:通过牺牲部分 CPU,节省内存或者网络资源,如把一次大的网络传输变成多次; 其他诸如并行化、异步化、池化技术等。
2 CPU 相关
前面讲到过,我们更应该关注 CPU 负载,CPU 利用率高一般不是问题,CPU 负载 是判断系统计算资源是否健康的关键依据。
2.1 CPU 利用率高&&平均负载高
这种情况常见于 CPU 密集型的应用,大量的线程处于可运行状态,I/O 很少,常见的大量消耗 CPU 资源的应用场景有:
正则操作 数学运算 序列化/反序列化 反射操作 死循环或者不合理的大量循环 基础/第三方组件缺陷 排查高 CPU 占用的一般思路:通过 jstack 多次(> 5次)打印线程栈,一般可以定位到消耗 CPU 较多的线程堆栈。或者通过 Profiling 的方式(基于事件采样或者埋点),得到应用在一段时间内的 on-CPU 火焰图,也能较快定位问题。
还有一种可能的情况,此时应用存在频繁的 GC (包括 Young GC、Old GC、Full GC),这也会导致 CPU 利用率和负载都升高。排查思路:使用 jstat -gcutil 持续输出当前应用的 GC 统计次数和时间。频繁 GC 导致的负载升高,一般还伴随着可用内存不足,可用 free 或者 top 等命令查看下当前机器的可用内存大小。
CPU 利用率过高,是否有可能是 CPU 本身性能瓶颈导致的呢?也是有可能的。可以进一步通过 vmstat 查看详细的 CPU 利用率。用户态 CPU 利用率(us)较高,说明用户态进程占用了较多的 CPU,如果这个值长期大于50%,应该着重排查应用本身的性能问题。内核态 CPU 利用率(sy)较高,说明内核态占用了较多的 CPU,所以应该着重排查内核线程或者系统调用的性能问题。如果 us + sy 的值大于 80%,说明 CPU 可能不足。
2.2 CPU 利用率低&&平均负载高
如果CPU利用率不高,说明我们的应用并没有忙于计算,而是在干其他的事。CPU 利用率低而平均负载高,常见于 I/O 密集型进程,这很容易理解,毕竟平均负载就是 R 状态进程和 D 状态进程的和,除掉了第一种,就只剩下 D 状态进程了(产生 D 状态的原因一般是因为在等待 I/O,例如磁盘 I/O、网络 I/O 等)。
排查&&验证思路:使用 vmstat 1 定时输出系统资源使用,观察 %wa(iowait) 列的值,该列标识了磁盘 I/O 等待时间在 CPU 时间片中的百分比,如果这个值超过30%,说明磁盘 I/O 等待严重,这可能是大量的磁盘随机访问或直接的磁盘访问(没有使用系统缓存)造成的,也可能磁盘本身存在瓶颈,可以结合 iostat 或 dstat 的输出加以验证,如 %wa(iowait) 升高同时观察到磁盘的读请求很大,说明可能是磁盘读导致的问题。
此外,耗时较长的网络请求(即网络 I/O)也会导致 CPU 平均负载升高,如 MySQL 慢查询、使用 RPC 接口获取接口数据等。这种情况的排查一般需要结合应用本身的上下游依赖关系以及中间件埋点的 trace 日志,进行综合分析。
2.3 CPU 上下文切换次数变高
先用 vmstat 查看系统的上下文切换次数,然后通过 pidstat 观察进程的自愿上下文切换(cswch)和非自愿上下文切换(nvcswch)情况。自愿上下文切换,是因为应用内部线程状态发生转换所致,譬如调用 sleep()、join()、wait()等方法,或使用了 Lock 或 synchronized 锁结构;非自愿上下文切换,是因为线程由于被分配的时间片用完或由于执行优先级被调度器调度所致。
如果自愿上下文切换次数较高,意味着 CPU 存在资源获取等待,比如说,I/O、内存等系统资源不足等。如果是非自愿上下文切换次数较高,可能的原因是应用内线程数过多,导致 CPU 时间片竞争激烈,频频被系统强制调度,此时可以结合 jstack 统计的线程数和线程状态分布加以佐证。
3 内存相关
前面提到,内存分为系统内存和进程内存(含 Java 应用进程),一般我们遇到的内存问题,绝大多数都会落在进程内存上,系统资源造成的瓶颈占比较小。对于 Java 进程,它自带的内存管理自动化地解决了两个问题:如何给对象分配内存以及如何回收分配给对象的内存,其核心是垃圾回收机制。
垃圾回收虽然可以有效地防止内存泄露、保证内存的有效使用,但也并不是万能的,不合理的参数配置和代码逻辑,依然会带来一系列的内存问题。此外,早期的垃圾回收器,在功能性和回收效率上也不是很好,过多的 GC 参数设置非常依赖开发人员的调优经验。比如,对于最大堆内存的不恰当设置,可能会引发堆溢出或者堆震荡等一系列问题。
下面看看几个常见的内存问题分析思路。
3.1 系统内存不足
Java 应用一般都有单机或者集群的内存水位监控,如果单机的内存利用率大于 95%,或者集群的内存利用率大于80%,就说明可能存在潜在的内存问题(注:这里的内存水位是系统内存)。
除了一些较极端的情况,一般系统内存不足,大概率是由 Java 应用引起的。使用 top 命令时,我们可以看到 Java 应用进程的实际内存占用,其中 RES 表示进程的常驻内存使用,VIRT 表示进程的虚拟内存占用,内存大小的关系为:VIRT > RES > Java 应用实际使用的堆大小。除了堆内存,Java 进程整体的内存占用,还有方法区/元空间、JIT 缓存等,主要组成如下:
Java 应用内存占用 = Heap(堆区)+ Code Cache(代码缓存区) + Metaspace(元空间)+ Symbol tables(符号表)+ Thread stacks(线程栈区)+ Direct buffers(堆外内存)+ JVM structures(其他的一些 JVM 自身占用)+ Mapped files(内存映射文件)+ Native Libraries(本地库)+ …
Java 进程的内存占用,可以使用 jstat -gc 命令查看,输出的指标中可以得到当前堆内存各分区、元空间的使用情况。堆外内存的统计和使用情况,可以利用 NMT(Native Memory Tracking,HotSpot VM Java8 引入)获取。线程栈使用的内存空间很容易被忽略,虽然线程栈内存采用的是懒加载的模式,不会直接使用 +Xss 的大小来分配内存,但是过多的线程也会导致不必要的内存占用,可以使用 jstackmem 这个脚本统计整体的线程占用。
系统内存不足的排查思路:
首先使用 free 查看当前内存的可用空间大小,然后使用 vmstat 查看具体的内存使用情况及内存增长趋势,这个阶段一般能定位占用内存最多的进程; 分析缓存 / 缓冲区的内存使用。如果这个数值在一段时间变化不大,可以忽略。如果观察到缓存 / 缓冲区的大小在持续升高,则可以使用 pcstat、cachetop、slabtop 等工具,分析缓存 / 缓冲区的具体占用; 排除掉缓存 / 缓冲区对系统内存的影响后,如果发现内存还在不断增长,说明很有可能存在内存泄漏,具体分析过程见2.3 节。
3.2 Java 内存溢出
内存溢出是指应用新建一个对象实例时,所需的内存空间大于堆的可用空间。内存溢出的种类较多,一般会在报错日志里看到 OutOfMemoryError 关键字。常见内存溢出种类及分析思路如下:
java.lang.OutOfMemoryError: Java heap space。原因:堆中(新生代和老年代)无法继续分配对象了、某些对象的引用长期被持有没有被释放,垃圾回收器无法回收、使用了大量的 Finalizer 对象,这些对象并不在 GC 的回收周期内等。一般堆溢出都是由于内存泄漏引起的,如果确认没有内存泄漏,可以适当通过增大堆内存。 java.lang.OutOfMemoryError:GC overhead limit exceeded。原因:垃圾回收器超过98%的时间用来垃圾回收,但回收不到2%的堆内存,一般是因为存在内存泄漏或堆空间过小。 java.lang.OutOfMemoryError: Metaspace或java.lang.OutOfMemoryError: PermGen space。排查思路:检查是否有动态的类加载但没有及时卸载,是否有大量的字符串常量池化,永久代/元空间是否设置过小等。 java.lang.OutOfMemoryError : unable to create new native Thread。原因:虚拟机在拓展栈空间时,无法申请到足够的内存空间。可适当降低每个线程栈的大小以及应用整体的线程个数。此外,系统里总体的进程/线程创建总数也受到系统空闲内存和操作系统的限制,请仔细检查。注:这种栈溢出,和 StackOverflowError 不同,后者是由于方法调用层次太深,分配的栈内存不够新建栈帧导致。 此外,还有 Swap 分区溢出、本地方法栈溢出、数组分配溢出等 OutOfMemoryError 类型,由于不是很常见,就不一一介绍了。
3.3 Java 内存泄漏
Java 内存泄漏可以说是开发人员的噩梦,内存泄漏与内存溢出不同则,后者简单粗暴,现场也比较好找。内存泄漏的表现是:应用运行一段时间后,内存利用率越来越高,响应越来越慢,直到最终出现进程「假死」。
Java 内存泄漏可能会造成系统可用内存不足、进程假死、OOM 等,排查思路却不外乎下面两种:
通过 jmap 定期输出堆内对象统计,定位数量和大小持续增长的对象; 使用 Profiler 工具对应用进行Profiling,寻找内存分配热点。 此外,在堆内存持续增长时,建议 dump 一份堆内存的快照,后面可以基于快照做一些分析。快照虽然是瞬时值,但也是有一定的意义的。
3.4 垃圾回收相关
GC(垃圾回收,下同)的各项指标,是衡量 Java 进程内存使用是否健康的重要标尺。垃圾回收最核心指标:GC Pause(包括 MinorGC 和 MajorGC) 的频率和次数,以及每次回收的内存详情,前者可以通过 jstat 工具直接得到,后者需要分析 GC 日志。需要注意的是,jstat 输出列中的 FGC/FGCT 表示的是一次老年代垃圾回收中,出现 GC Pause (即 Stop-the-World)的次数,譬如对于 CMS 垃圾回收器,每次老年代垃圾回收这个值会增加2(初始标记和重新标记着两个 Stop-the-World 的阶段,这个统计值会是 2。
什么时候需要进行 GC 调优?这取决于应用的具体情况,譬如对响应时间的要求、对吞吐量的要求、系统资源限制等。一些经验:GC 频率和耗时大幅上升、GC Pause 平均耗时超过 500ms、Full GC 执行频率小于1分钟等,如果 GC 满足上述的一些特征,说明需要进行 GC 调优了。
由于垃圾回收器种类繁多,针对不同的应用,调优策略也有所区别,因此下面介绍几种通用的的 GC 调优策略。
1.选择合适的 GC 回收器。根据应用对延迟、吞吐的要求,结合各垃圾回收器的特点,合理选用。推荐使用 G1 替换 CMS 垃圾回收器,G1 的性能是在逐步优化的,在 8GB 内存及以下的机器上,其各方面的表现也在赶上甚至有超越之势。G1 调参较方便,而 CMS 垃圾回收器参数太过复杂、容易造成空间碎片化、对 CPU 消耗较高等弊端,也使其目前处于废弃状态。Java 11 里新引入的 ZGC 垃圾回收器,基本可用做到全阶段并发标记和回收,值得期待。
2.合理的堆内存大小设置。堆大小不要设置过大,建议不要超过系统内存的 75%,避免出现系统内存耗尽。最大堆大小和初始化堆的大小保持一致,避免堆震荡。新生代的大小设置比较关键,我们调整 GC 的频率和耗时,很多时候就是在调整新生代的大小,包括新生代和老年代的占比、新生代中 Eden 区和 Survivor 区的比例等,这些比例的设置还需要考虑各代中对象的晋升年龄,整个过程需要考虑的东西还是比较多的。如果使用 G1 垃圾回收器,新生代大小这一块需要考虑的东西就少很多了,自适应的策略会决定每一次的回收集合(CSet)。新生代的调整是 GC 调优的核心,非常依赖经验,但是一般来说,Young GC 频率高,意味着新生代太小(或 Eden 区和 Survivor 配置不合理),Young GC 时间长,意味着新生代过大,这两个方向大体不差。
3.降低 Full GC 的频率。如果出现了频繁的 Full GC 或者 老年代 GC,很有可能是存在内存泄漏,导致对象被长期持有,通过 dump 内存快照进行分析,一般能较快地定位问题。除此之外,新生代和老年代的比例不合适,导致对象频频被直接分配到老年代,也有可能会造成 Full GC,这个时候需要结合业务代码和内存快照综合分析。
此外,通过配置 GC 参数,可以帮助我们获取很多 GC 调优所需的关键信息,如配置-XX:+PrintGCApplicationStoppedTime-XX:+PrintSafepointStatistics-XX:+PrintTenuringDistribution,分别可以获取 GC Pause 分布、安全点耗时统计、对象晋升年龄分布的信息,加上 -XX:+PrintFlagsFinal 可以让我们了解最终生效的 GC 参数等。
4 磁盘I/O和网络I/O
磁盘 I/O 问题排查思路:
使用工具输出磁盘相关的输出的指标,常用的有 %wa(iowait)、%util,根据输判断磁盘 I/O 是否存在异常,譬如 %util 这个指标较高,说明有较重的 I/O 行为; 使用 pidstat 定位到具体进程,关注下读或写的数据大小和速率; 使用 lsof + 进程号,可查看该异常进程打开的文件列表(含目录、块设备、动态库、网络套接字等),结合业务代码,一般可定位到 I/O 的来源,如果需要具体分析,还可以使用 perf 等工具进行 trace 定位 I/O 源头。 需要注意的是,%wa(iowait)的升高不代表一定意味着磁盘 I/O 存在瓶颈,这是数值代表 CPU 上 I/O 操作的时间占用的百分比,如果应用进程的在这段时间内的主要活动就是 I/O,那么也是正常的。
网络 I/O 存在瓶颈,可能的原因如下:
一次传输的对象过大,可能会导致请求响应慢,同时 GC 频繁; 网络 I/O 模型选择不合理,导致应用整体 QPS 较低,响应时间长; RPC 调用的线程池设置不合理。可使用 jstack 统计线程数的分布,如果处于 TIMED_WAITING 或 WAITING 状态的线程较多,则需要重点关注。举例:数据库连接池不够用,体现在线程栈上就是很多线程在竞争一把连接池的锁; RPC 调用超时时间设置不合理,造成请求失败较多; Java 应用的线程堆栈快照非常有用,除了上面提到的用于排查线程池配置不合理的问题,其他的一些场景,如 CPU 飙高、应用响应较慢等,都可以先从线程堆栈入手。
5 有用的一行命令
这一小节给出若干在定位性能问题的命令,用于快速定位。
1)查看系统当前网络连接数
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
2)查看堆内对象的分布 Top 50(定位内存泄漏)
jmap –histo:live $pid | sort-n -r -k2 | head-n 50
3)按照 CPU/内存的使用情况列出前10 的进程
#内存
ps axo %mem,pid,euser,cmd | sort -nr | head -10
#CPU
ps -aeo pcpu,user,pid,cmd | sort -nr | head -10
4)显示系统整体的 CPU利用率和闲置率
grep "cpu " /proc/stat | awk -F ' ' '{total = $2 + $3 + $4 + $5} END {print "idle \t used\n" $5*100/total "% " $2*100/total "%"}'
5)按线程状态统计线程数(加强版)
jstack $pid | grep java.lang.Thread.State:|sort|uniq -c | awk '{sum+=$1; split($0,a,":");gsub(/^[ \t]+|[ \t]+$/, "", a[2]);printf "%s: %s\n", a[2], $1}; END {printf "TOTAL: %s",sum}';
6)查看最消耗 CPU 的 Top10 线程机器堆栈信息
推荐大家使用 show-busy-java-threads 脚本,该脚本可用于快速排查 Java 的 CPU 性能问题(top us值过高),自动查出运行的 Java 进程中消耗 CPU 多的线程,并打印出其线程栈,从而确定导致性能问题的方法调用,该脚本已经用于阿里线上运维环境。链接地址:https://github.com/oldratlee/useful-scripts/。
7)火焰图生成(需要安装 perf、perf-map-agent、FlameGraph 这三个项目):
# 1. 收集应用运行时的堆栈和符号表信息(采样时间30秒,每秒99个事件);
sudo perf record -F 99 -p $pid -g -- sleep 30; ./jmaps
# 2. 使用 perf script 生成分析结果,生成的 flamegraph.svg 文件就是火焰图。
sudo perf script | ./pkgsplit-perf.pl | grep java | ./flamegraph.pl > flamegraph.svg
8)按照 Swap 分区的使用情况列出前 10 的进程
for file in /proc/*/status ; do awk '/VmSwap|Name|^Pid/{printf $2 " " $3}END{ print ""}' $file; done | sort -k 3 -n -r | head -10
9)JVM 内存使用及垃圾回收状态统计
#显示最后一次或当前正在发生的垃圾收集的诱发原因
jstat -gccause $pid
#显示各个代的容量及使用情况
jstat -gccapacity $pid
#显示新生代容量及使用情况
jstat -gcnewcapacity $pid
#显示老年代容量
jstat -gcoldcapacity $pid
#显示垃圾收集信息(间隔1秒持续输出)
jstat -gcutil $pid 1000
10)其他的一些日常命令
# 快速杀死所有的 java 进程
ps aux | grep java | awk '{ print $2 }' | xargs kill -9
# 查找/目录下占用磁盘空间最大的top10文件
find / -type f -print0 | xargs -0 du -h | sort -rh | head -n 10
首先,虽然从系统、组件、应用两个三个角度去描述瓶颈点的分布,但在实际运行时,这三者往往是相辅相成、相互影响的。系统是为应用提供了运行时环境,性能问题的本质就是系统资源达到了使用的上限,反映在应用层,就是应用/组件的各项指标开始下降;而应用/组件的不合理使用和设计,也会加速系统资源的耗尽。因此,分析瓶颈点时,需要我们结合从不同角度分析出的结果,抽出共性,得到最终的结论。
其次,建议先从应用层入手,分析图中标注的高频指标,抓出最重要的、最可疑的、最有可能导致性能的点,得到初步的结论后,再去系统层进行验证。这样做的好处是:很多性能瓶颈点体现在系统层,会是多变量呈现的,譬如,应用层的垃圾回收(GC)指标出现了异常,通过 JDK 自带的工具很容易观测到,但是体现在系统层上,会发现系统当前的 CPU 利用率、内存指标都不太正常,这就给我们的分析思路带来了困扰。
最后,如果瓶颈点在应用层和系统层均呈现出多变量分布,建议此时使用 ZProfiler、JProfiler 等工具对应用进行 Profiling,获取应用的综合性能信息(注:Profiling 指的是在应用运行时,通过事件(Event-based)、统计抽样(Sampling Statistical)或植入附加指令(Byte-Code instrumentation)等方法,收集应用运行时的信息,来研究应用行为的动态分析方法)。譬如,可以对 CPU 进行抽样统计,结合各种符号表信息,得到一段时间内应用内的代码热点。
下面介绍在不同的分析层次,我们需要关注的核心性能指标,同时,也会介绍如何初步根据这些指标,判断系统或应用是否存在性能瓶颈点,至于瓶颈点的确认、瓶颈点的成因、调优手段,将会在下一部分展开。