性能调优调优的常见思路和方法

Posted sysu_lluozh

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了性能调优调优的常见思路和方法相关的知识,希望对你有一定的参考价值。

在通过工具得到异常指标,初步定位瓶颈点后,如何进一步进行确认和调优?在这里提供一些可实践、可借鉴、可参考的性能调优套路,即:如何在众多异常性能指标中,找出最核心的那一个,进而定位性能瓶颈点,最后进行性能调优

以下会按照代码、CPU、内存、网络、磁盘等方向进行组织,针对某一个优化点系统的套路总结,便于思路的迁移实践

一、应用代码相关

遇到性能问题,首先做的是检查否与业务代码相关——不是通过阅读代码解决问题,而是通过日志或代码,排除掉一些与业务代码相关的低级错误

性能优化的最佳位置是应用内部,比如:

  1. 查看业务日志,检查日志内容里是否有大量的报错产生,应用层、框架层的一些性能问题,大多数都能从日志里找到端倪(日志级别设置不合理,导致线上疯狂打日志)
  2. 检查代码的主要逻辑,如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类型的容器:
    a. 如果对数据要求有强一致性,可使用Hashtable或者Map +锁
    b. 读远大于写,使用CopyOnWriteArrayList
    c. 存取数据量小、对数据没有强一致性的要求、变更不频繁的,使用ConcurrentHashMap
    d. 存取数据量大、读写频繁、对数据没有强一致性的要求,使用ConcurrentSkipListMap

  8. 锁的优化思路
    减少锁的粒度、循环中使用锁粗化、减少锁的持有时间(读写锁的选择)等
    同时,考虑使用一些JDK优化后的并发类,如对一致性要求不高的统计场景中,使用LongAdder 替代AtomicLong进行计数,使用ThreadLocalRandom替代Random类等

可以观察到,在这些要点里有一些共性的优化思路可以抽取出来的,比如:

  1. 空间换时间
    使用内存或者磁盘,换取更宝贵的CPU 或者网络,如缓存的使用
  2. 时间换空间
    通过牺牲部分 CPU,节省内存或者网络资源,如把一次大的网络传输变成多次
  3. 其他诸如并行化、异步化、池化技术等

二、CPU 相关

更应该关注CPU负载,CPU利用率高一般不是问题,CPU负载是判断系统计算资源是否健康的关键依据

2.1 CPU利用率高&&平均负载高

这种情况常见于CPU密集型的应用,大量的线程处于可运行状态,I/O很少

常见的大量消耗CPU资源的应用场景:

  1. 正则操作
  2. 数学运算
  3. 序列化/反序列化
  4. 反射操作
  5. 死循环或者不合理的大量循环
  6. 基础/第三方组件缺陷

排查高 CPU 占用的一般思路:

  • 通过jstack多次(> 5次)打印线程栈,一般可以定位到消耗CPU较多的线程堆栈
  • 通过Profiling的方式(基于事件采样或者埋点),得到应用在一段时间内的on-CPU火焰图,也能较快定位问题

频繁的GC引发分析:

还有一种可能的情况,此时应用存在频繁的GC(包括Young GC、Old GC、Full GC),这也会导致CPU利用率和负载都升高。排查思路:

  1. 使用jstat -gcutil持续输出当前应用的GC统计次数和时间
  2. 频繁GC导致的负载升高,一般还伴随着可用内存不足,可用free或者top等命令查看下当前机器的可用内存大小

CPU本身性能瓶颈分析:

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上下文切换次数变高

排查&&验证思路:

  1. 用vmstat查看系统的上下文切换次数
  2. 通过pidstat观察进程的自愿上下文切换(cswch)和非自愿上下文切换(nvcswch)情况
    a. 自愿上下文切换,是因为应用内部线程状态发生转换所致,譬如调用sleep()、join()、wait()等方法,或使用Lock或synchronized锁结构
    b. 非自愿上下文切换,是因为线程由于被分配的时间片用完或由于执行优先级被调度器调度所致
  3. 如果自愿上下文切换次数较高,意味着CPU存在资源获取等待,比如I/O、内存等系统资源不足等
  4. 如果非自愿上下文切换次数较高,可能的原因是应用内线程数过多,导致CPU时间片竞争激烈,频频被系统强制调度,此时可以结合jstack统计的线程数和线程状态分布加以佐证\\

三、内存相关

内存分为系统内存和进程内存(含Java应用进程),一般遇到的内存问题,绝大多数都会落在进程内存上,系统资源造成的瓶颈占比较小

对于Java进程,它自带的内存管理自动化地解决了两个问题:

  • 如何给对象分配内存
  • 如何回收分配给对象的内存

其核心是垃圾回收机制
垃圾回收虽然可以有效地防止内存泄露、保证内存的有效使用,但也并不是万能的,不合理的参数配置和代码逻辑,依然会带来一系列的内存问题。比如,对于最大堆内存的不恰当设置,可能会引发堆溢出或者堆震荡等一系列问题

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这个脚本统计整体的线程占用

系统内存不足的排查思路:

  1. 使用free查看当前内存的可用空间大小,然后使用vmstat查看具体的内存使用情况及内存增长趋势,这个阶段一般能定位占用内存最多的进程

  2. 分析缓存/缓冲区的内存使用。如果这个数值在一段时间变化不大,可以忽略。如果观察到缓存/缓冲区的大小在持续升高,则可以使用pcstat、cachetop、slabtop等工具,分析缓存/缓冲区的具体占用

  3. 排除掉缓存/缓冲区对系统内存的影响后,如果发现内存还在不断增长,说明很有可能存在内存泄漏

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不同,后者是由于方法调用层次太深,分配的栈内存不够新建栈帧导致

  5. Swap分区溢出、本地方法栈溢出、数组分配溢出等 OutOfMemoryError类型
    这些问题并不是很常见

3.3 Java内存泄漏

Java内存泄漏与内存溢出不同,内存泄漏的表现是:应用运行一段时间后,内存利用率越来越高,响应越来越慢,直到最终出现进程假死

Java内存泄漏可能会造成系统可用内存不足、进程假死、OOM等,排查思路却不外乎下面两种:

  • 通过jmap定期输出堆内对象统计,定位数量和大小持续增长的对象
  • 使用Profiler工具对应用进行Profiling,寻找内存分配热点

此外,在堆内存持续增长时,建议dump一份堆内存的快照,后面可以基于快照做一些分析。快照虽然是瞬时值,但也是有一定的意义

3.4 垃圾回收相关

GC(垃圾回收)的各项指标,是衡量Java进程内存使用是否健康的重要标尺

垃圾回收最核心指标:

  1. GC Pause(包括MinorGC和MajorGC)的频率和次数
  2. 每次回收的内存详情

频率和次数可以通过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调优策略
由于垃圾回收器种类繁多,针对不同的应用,调优策略也有所区别,下面介绍几种通用的GC调优策略

  1. 选择合适的GC回收器

根据应用对延迟、吞吐的要求,结合各垃圾回收器的特点,合理选用
推荐使用G1替换CMS垃圾回收器,G1的性能是在逐步优化的,在8GB内存及以下的机器上,其各方面的表现也在赶上甚至有超越之势。G1调参较方便,而CMS垃圾回收器参数太过复杂、容易造成空间碎片化、对CPU消耗较高等弊端,也使其目前处于废弃状态

  1. 合理的堆内存大小设置
  • 堆大小不要设置过大,建议不要超过系统内存的75%,避免出现系统内存耗尽
  • 最大堆大小和初始化堆的大小保持一致,避免堆震荡
  • 新生代的大小设置比较关键,调整GC的频率和耗时,很多时候就是在调整新生代的大小,包括新生代和老年代的占比、新生代中Eden区和Survivor区的比例等,这些比例的设置还需要考虑各代中对象的晋升年龄
    如果使用G1垃圾回收器,新生代大小这一块需要考虑的东西就少很多,自适应的策略会决定每一次的回收集合(CSet)
    新生代的调整是GC调优的核心,非常依赖经验,但是一般情况下:
    • Young GC频率高,意味着新生代太小(或 Eden区和Survivor配置不合理)
    • Young GC时间长,意味着新生代过大
  1. 降低Full GC的频率

如果出现了频繁的Full GC或者老年代GC,很有可能是存在内存泄漏,导致对象被长期持有
通过dump内存快照进行分析,一般能较快地定位问题
除此之外,新生代和老年代的比例不合适,导致对象频频被直接分配到老年代,也有可能会造成Full GC,这个时候需要结合业务代码和内存快照综合分析

  1. 配置GC参数

通过配置 GC 参数,可以帮助获取很多GC调优所需的关键信息,如配置-XX:+PrintGCApplicationStoppedTime-XX:+PrintSafepointStatistics-XX:+PrintTenuringDistribution
分别可以获取GCPause 分布、安全点耗时统计、对象晋升年龄分布的信息

加上-XX:+PrintFlagsFinal可以了解最终生效的GC参数

四、磁盘I/O和网络I/O

4.1 磁盘I/O问题

磁盘I/O问题排查思路:

  1. 使用工具输出磁盘相关的输出的指标,常用的有%wa(iowait)、%util,根据输判断磁盘I/O是否存在异常,譬如%util这个指标较高,说明有较重的I/O行为

  2. 使用pidstat定位到具体进程,关注下读或写的数据大小和速率

  3. 使用lsof + 进程号,可查看该异常进程打开的文件列表(含目录、块设备、动态库、网络套接字等),结合业务代码一般可定位到I/O的来源
    如果需要具体分析,可以使用perf等工具进行trace定位I/O源头

需要注意的是,%wa(iowait)的升高不代表一定意味着磁盘I/O存在瓶颈,这是数值代表CPU上 I/O操作的时间占用的百分比,如果应用进程的在这段时间内的主要活动就是I/O,那么也是正常的

4.2 网络I/O瓶颈

网络I/O存在瓶颈,可能的原因如下:

  1. 一次传输的对象过大,可能会导致请求响应慢,同时GC频繁

  2. 网络I/O模型选择不合理,导致应用整体QPS较低,响应时间长

  3. RPC调用的线程池设置不合理
    可使用jstack 统计线程数的分布,如果处于TIMED_WAITING或WAITING状态的线程较多,则需要重点关注。例如:数据库连接池不够用,体现在线程栈上就是很多线程在竞争一把连接池的锁

  4. RPC调用超时时间设置不合理,造成请求失败较多

Java应用的线程堆栈快照非常有用,除了上面提到的用于排查线程池配置不合理的问题,其他的一些场景,如CPU飙高、应用响应较慢等,都可以先从线程堆栈入手

五、有用的命令行

若干在定位性能问题的命令,用于快速定位

5.1 查看系统当前网络连接数

netstat -n | awk '/^tcp/ ++S[$NF] END for(a in S) print a, S[a]'

5.2 查看堆内对象的分布Top 50

用于定位内存泄漏的问题

jmap –histo:live $pid | sort-n -r -k2 | head-n 50

5.3 按照CPU/内存的使用情况列出前10的进程

#内存
ps axo %mem,pid,euser,cmd | sort -nr | head -10
#CPU
ps -aeo pcpu,user,pid,cmd | sort -nr | head -10

5.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.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';

5.6 查看最消耗CPU的Top10线程机器堆栈信息

推荐使用show-busy-java-threads脚本,该脚本可用于快速排查Java的CPU性能问题(top us值过高),自动查出运行的Java进程中消耗CPU多的线程,并打印出其线程栈,从而确定导致性能问题的方法调用,该脚本已经用于阿里线上运维环境。链接地址:https://github.com/oldratlee/useful-scripts/

5.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

5.8 按照Swap分区的使用情况列出前10的进程

for file in /proc/*/status ; do awk '/VmSwap|Name|^Pid/printf $2 " " $3END print ""' $file; done | sort -k 3 -n -r | head -10

5.9 JVM内存使用及垃圾回收状态统计

#显示最后一次或当前正在发生的垃圾收集的诱发原因
jstat -gccause $pid

#显示各个代的容量及使用情况
jstat -gccapacity $pid

#显示新生代容量及使用情况
jstat -gcnewcapacity $pid

#显示老年代容量
jstat -gcoldcapacity $pid

#显示垃圾收集信息(间隔1秒持续输出)
jstat -gcutil $pid 1000

5.10 查找/目录下占用磁盘空间最大的top10文件

find / -type f -print0 | xargs -0 du -h | sort -rh | head -n 10

以上是关于性能调优调优的常见思路和方法的主要内容,如果未能解决你的问题,请参考以下文章

性能调优调优的常见思路和方法

Linux性能调优的优化思路

入门」全方位盘点和总结调优技术专题指南

细数.NET5性能调优的6大思路

关于 Java 性能调优的 11个简单技巧,多少人知道?

重剑无锋 | Hive性能调优