阿里妹导读:在《如何回答性能优化的问题,才能打动阿里面试官?》中,主要是介绍了应用常见性能瓶颈点的分布,及如何初判若干指标是否出现了异常。
今天,齐光将会基于之前列举的众多指标,给出一些常见的调优分析思路,即:如何在众多异常性能指标中,找出最核心的那一个,进而定位性能瓶颈点,最后进行性能调优。整篇文章会按照代码、CPU、内存、网络、磁盘等方向进行组织,针对对某一各优化点,会有系统的「套路」总结,便于思路的迁移实践。
1. 代码相关
遇到性能问题,首先应该做的是检查否与业务代码相关——不是通过阅读代码解决问题,而是通过日志或代码,排除掉一些与业务代码相关的低级错误。性能优化的最佳位置,是应用内部。
譬如,查看业务日志,检查日志内容里是否有大量的报错产生,应用层、框架层的一些性能问题,大多数都能从日志里找到端倪(日志级别设置不合理,导致线上疯狂打日志);再者,检查代码的主要逻辑,如 for 循环的不合理使用、NPE、正则表达式、数学计算等常见的一些问题,都可以通过简单地修改代码修复问题。
别动辄就把性能优化和缓存、异步化、JVM 调优等名词挂钩,复杂问题可能会有简单解,「二八原则」在性能优化的领域里里依然有效。当然了,了解一些基本的「代码常用踩坑点」,可以加速我们问题分析思路的过程,从 CPU、内存、JVM 等分析到的一些瓶颈点优化思路,也有可能在代码这里体现出来。
下面是一些高频的,容易造成性能问题的编码要点。
1)正则表达式非常消耗 CPU(如贪婪模式可能会引起回溯),慎用字符串的 split()、replaceAll() 等方法;正则表达式表达式一定预编译。
2)String.intern() 在低版本(Java 1.6 以及之前)的 JDK 上使用,可能会造成方法区(永久代)内存溢出。在高版本 JDK 中,如果 string pool 设置太小而缓存的字符串过多,也会造成较大的性能开销。
3)输出异常日志的时候,如果堆栈信息是明确的,可以取消输出详细堆栈,异常堆栈的构造是有成本的。注意:同一位置抛出大量重复的堆栈信息,JIT 会将其优化后成,直接抛出一个事先编译好的、类型匹配的异常,异常堆栈信息就看不到了。
4)避免引用类型和基础类型之间无谓的拆装箱操作,请尽量保持一致,自动装箱发生太频繁,会非常严重消耗性能。
5)Stream API 的选择。复杂和并行操作,推荐使用 Stream API,可以简化代码,同时发挥来发挥出 CPU 多核的优势,如果是简单操作或者 CPU 是单核,推荐使用显式迭代。
6)根据业务场景,通过 ThreadPoolExecutor 手动创建线程池,结合任务的不同,指定线程数量和队列大小,规避资源耗尽的风险,统一命名后的线程也便于后续问题排查。
7)根据业务场景,合理选择并发容器。如选择 Map 类型的容器时,如果对数据要求有强一致性,可使用 Hashtable 或者 「Map + 锁」 ;读远大于写,使用 CopyOnWriteArrayList;存取数据量小、对数据没有强一致性的要求、变更不频繁的,使用 ConcurrentHashMap;存取数据量大、读写频繁、对数据没有强一致性的要求,使用 ConcurrentSkipListMap。
8)锁的优化思路有:减少锁的粒度、循环中使用锁粗化、减少锁的持有时间(读写锁的选择)等。同时,也考虑使用一些 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 等工具,分析缓存 / 缓冲区的具体占用;
排除掉缓存 / 缓冲区对系统内存的影响后,如果发现内存还在不断增长,说明很有可能存在内存泄漏。
3.2 Java 内存溢出
内存溢出是指应用新建一个对象实例时,所需的内存空间大于堆的可用空间。内存溢出的种类较多,一般会在报错日志里看到 OutOfMemoryError 关键字。常见内存溢出种类及分析思路如下:
1)java.lang.OutOfMemoryError: Java heap space。原因:堆中(新生代和老年代)无法继续分配对象了、某些对象的引用长期被持有没有被释放,垃圾回收器无法回收、使用了大量的 Finalizer 对象,这些对象并不在 GC 的回收周期内等。一般堆溢出都是由于内存泄漏引起的,如果确认没有内存泄漏,可以适当通过增大堆内存。
2)java.lang.OutOfMemoryError:GC overhead limit exceeded。原因:垃圾回收器超过98%的时间用来垃圾回收,但回收不到2%的堆内存,一般是因为存在内存泄漏或堆空间过小。
3)java.lang.OutOfMemoryError: Metaspace或java.lang.OutOfMemoryError: PermGen space。排查思路:检查是否有动态的类加载但没有及时卸载,是否有大量的字符串常量池化,永久代/元空间是否设置过小等。
4)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
4.1 磁盘 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,那么也是正常的。
4.2 网络 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/...。
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
6. 总结
性能优化是一个很大的领域,这里面的每一个小点,都可以拓展为数十篇文章去阐述。对应用进行性能优化,除了上面介绍的之外,还有前端优化、架构优化(分布式、缓存使用等)、数据存储优化、代码优化(如设计模式优化)等,限于篇幅所限,在这里并未一一展开,本文的这些内容,只是起一个抛砖引玉的作用。同时,本文的东西是我的一些经验和知识,并不一定全对,希望大家指正和补充。
性能优化是一个综合性的工作,需要不断地去实践,将工具学习、经验学习融合到实战中去,不断完善,形成一套属于自己的调优方法论。
此外,虽然性能优化很重要,但是不要过早在优化上投入太多精力(当然完善的架构设计和编码是必要的),过早优化是万恶之源。一方面,提前做的优化工作,可能会不适用快速变化的业务需求,反倒给新需求、新功能起了阻碍的作用;另一方面,过早优化使得应用复杂性升高,降低了应用的可维护性。何时进行优化、优化到什么样的程度,是一个需要多方权衡的命题。
参考资料:
本文作者:齐光
本文来自云栖社区合作伙伴“阿里技术”,如需转载请联系原作者。