【Java面试进阶】谈一谈你是怎么做性能优化的?

1. 性能优化的背景

性能问题,可能是由多方面的因素共同作用的结果:代码质量一般、业务发展太快、应用架构设计不合理等,这些问题处理起来一般耗时较长、分析链路复杂,大家都不愿意干,因此可能会被一些临时性的补救手段所掩盖,如:系统水位高或者单机的线程池队列爆炸,那就集群扩容增加机器;内存占用高/高峰时段 OOM,那就重启分分钟解决…

临时性的补救措施只是在给应用埋雷,同时也只能解决部分问题。长期来看是得不偿失的。对应用进行合理的性能优化,可在应用稳定性、成本核算获得很大的收益。

性能优化常见的潜在痛点:

  • 对性能优化的流程不是很清晰。初步定为一个疑似瓶颈点后,就兴高采烈地吭哧吭哧开始干,最终解决的问题其实只是一个浅层次的性能瓶颈,真实的问题的根源并未触达;
  • 对性能瓶颈点的分析思路不是很清晰。CPU、网络、内存…这么多的性能指标,我到底该关注什么,应该从哪一块儿开始入手?
  • 对性能优化的工具不了解。遇到问题后,不清楚该用哪个工具,不知道通过工具得到的指标代表什么

2. 性能优化的流程

对于绝大多数的优化场景,可以将其过程抽象为下面四个步骤:

  • 准备阶段:主要工作是是通过性能测试,了解应用的概况、瓶颈的大概方向,明确优化目标;
  • 分析阶段:通过各种工具或手段,初步定位性能瓶颈点;
  • 调优阶段:根据定位到的瓶颈点,进行应用性能调优;
  • 测试阶段:让调优过的应用进行性能测试,与准备阶段的各项指标进行对比,观测其是否符合预期,如果瓶颈点没有消除或者性能指标不符合预期,则重复步骤 2 和3。

流程如下:
性能优化流程

2.1 准备阶段

在这个阶段,首先需要对我们进行调优的对象进行详尽的了解:

  • 对性能问题进行粗略评估,过滤一些因为低级的业务逻辑导致的性能问题。譬如,线上应用日志级别不合理,可能会在大流量时导致 CPU 和磁盘的负载飙高,这种情况调整日志级别即可;
  • 了解应用的的总体架构,比如应用的外部依赖和核心接口有哪些,使用了哪些组件和框架,哪些接口、模块的使用率较高,上下游的数据链路是怎么样的等;
  • 了解应用对应的服务器信息,如服务器所在的集群信息、服务器的 CPU/内存信息、安装的 Linux 版本信息、服务器是容器还是虚拟机、所在宿主机混部后是否对当前应用有干扰等

其次,需要获取基准数据,然后结合基准数据和当前的一些业务指标,确定此次性能优化的最终目标

  • 使用基准测试工具获取系统细粒度指标。可以使用若干 Linux 基准测试工具(eg. jmeterabloadrunnerwrkwrk 等),得到文件系统、磁盘 I/O、网络等的性能报告。 除此之外,类似 GC、Web 服务器、网卡流量等信息,如有必要也是需要了解记录的;
  • 通过压测工具或者压测平台(如果有的话),对应用进行压力测试,获取当前应用的宏观业务指标,譬如:响应时间、吞吐量、TPS、QPS、消费速率(对于有 MQ 的应用)等。压力测试也可以省略,可以结合当前的实际业务和过往的监控数据,去统计当前的一些核心业务指标,如午高峰的服务 TPS.

2.2 测试阶段

基于上一步的分析,我们已经初步确定了应用性能瓶颈的所在,而且进行了初步的调优。检测我们调优是否有效的方式,就是在仿真的条件下,对应用进行压力测试。注意:由于 Java 有 JIT(just-in-time compilation)过程,因此压力测试时可能需要进行前期预热。

如果压力测试的结果符合了预期的调优目标,或者与基准数据相比,有很大的改善,则我们可以继续通过工具定位下一个瓶颈点,否则,则需要暂时排除这个瓶颈点,继续寻找下一个变量。

2.3 注意事项

  • 性能瓶颈点通常呈现 2/8 分布,即 80%的性能问题通常是由 20%的性能瓶颈点导致的,2/8 原则也意味着并不是所有的性能问题都值得去优化;
  • 性能优化是一个渐进、迭代的过程,需要逐步、动态地进行。记录基准后,每次改变一个变量,引入多个变量会给我们的观测、优化过程造成干扰;
  • 不要过度追求应用的单机性能,如果单机表现良好,则应该从系统架构的角度去思考; 不要过度追求单一维度上的极致优化,如过度追求 CPU 的性能而忽略了内存方面的瓶颈
  • 选择合适的性能优化工具,可以使得性能优化取得事半功倍的效果;
  • 整个应用的优化,应该与线上系统隔离,新的代码上线应该有降级方案。

3. 性能瓶颈分析工具箱

性能优化其实就是找出应用存在性能瓶颈点,然后设法通过一些调优手段去缓解。性能瓶颈点的定位是较困难的,快速、直接地定位到瓶颈点,需要具备下面两个条件:

  • 恰到好处的工具;
  • 一定的性能优化经验

3.1 一张神奇的图

首先介绍一张传奇的图:
Linux Performance Observability Tools
Linux Performance Observability Tools

该图从 Linux 内核的各个子系统出发,列出了我们在对各个子系统进行性能分析时,可使用的工具,涵盖了监测、分析、调优等性能优化的方方面面。除了这张全景图之外,Brendan Gregg 还单独提供了基准测试工具(Linux Performance Benchmark Tools)图、性能监测工具(Linux Performance Observability Tools)图等。

但是上图在使用中存在一些问题:1. 对分析经验要求较高,要求对Linux各个子系统的功能、原理要有所了解。2. 适用性和完整性不是很好。

这里,阿里巴巴的工程师们给出了另一张更为实用的性能优化工具图谱
阿里巴巴性能优化分析图谱

该图分别从系统层、应用层(含组件层)的角度出发,列举了我们在分析性能问题时首先需要关注的各项指标(其中?标注的是最需要关注的),这些点是最有可能出现性能瓶颈的地方。需要注意的是,一些低频的指标或工具,在图中并没有列出来,如 CPU 中断、索引节点使用、I/O 事件跟踪等,这些低频点的排查思路较复杂,一般遇到的机会也不多,在这里我们聚焦最常见的一些就可以了。

该图的优势在于:把具体的工具同性能指标结合了起来,同时从不同的层次去描述了性能瓶颈点的分布,实用性和可操作性更强一些。系统层的工具分为CPU、内存、磁盘(含文件系统)、网络四个部分,工具集同性能工具(Linux Performance Tools-full)图中的工具基本一致。组件层和应用层中的工具构成为:JDK 提供的一些工具 + Trace 工具 + dump 分析工具 + Profiling 工具等。

可以使用 man 命令得到工具详尽的使用说明,除此之外,还有另外一个查询命令手册的方法:info。info 可以理解为 man 的详细版本,如果 man 的输出不太好理解,可以去参考 info 文档,命令太多,记不住也没必要记住。

那么,如何使用上面的性能优化工具图呢?

  • 首先,虽然从系统、组件、应用两个三个角度去描述瓶颈点的分布,但在实际运行时,这三者往往是相辅相成、相互影响的。系统是为应用提供了运行时环境,性能问题的本质就是系统资源达到了使用的上限,反映在应用层,就是应用/组件的各项指标开始下降;而应用/组件的不合理使用和设计,也会加速系统资源的耗尽。因此,分析瓶颈点时,需要我们结合从不同角度分析出的结果,抽出共性,得到最终的结论
  • 其次,建议先从应用层入手,分析图中标注的高频指标,抓出最重要的、最可疑的、最有可能导致性能的点,得到初步的结论后,再去系统层进行验证。这样做的好处是:很多性能瓶颈点体现在系统层,会是多变量呈现的,譬如,应用层的垃圾回收(GC)指标出现了异常,通过 JDK 自带的工具很容易观测到,但是体现在系统层上,会发现系统当前的 CPU 利用率、内存指标都不太正常,这就给我们的分析思路带来了困扰。
  • 最后,如果瓶颈点在应用层和系统层均呈现出多变量分布,建议此时使用 ZProfiler、JProfiler 等工具对应用进行 Profiling,获取应用的综合性能信息(注:Profiling 指的是在应用运行时,通过事件(Event-based)、统计抽样(Sampling Statistical)或植入附加指令(Byte-Code instrumentation)等方法,收集应用运行时的信息,来研究应用行为的动态分析方法)。譬如,可以对 CPU 进行抽样统计,结合各种符号表信息,得到一段时间内应用内的代码热点。

下面介绍在不同的分析层次,我们需要关注的核心性能指标,同时,也会介绍如何初步根据这些指标,判断系统或应用是否存在性能瓶颈点,至于瓶颈点的确认、瓶颈点的成因、调优手段,将会在下一部分展开。

3.2 分析CPU&线程

和 CPU 相关的指标主要有以下几个。常用的工具有 toppsuptimevmstatpidstat等。

  • CPU利用率(CPU Utilization)
  • CPU 平均负载(Load Average)
  • 上下文切换次数(Context Switch)

使用top命令可以查看系统当前总体的运行状态:

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 利用率

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 的线程

3.3 分析内存&堆

和内存相关的指标主要有以下几个,常用的分析工具有:topfreevmstatpidstat 以及 JDK 自带的一些工具。

  1. 系统内存的使用情况,包括剩余内存、已用内存、可用内存、缓存/缓冲区;
  2. 进程(含 Java 进程)的虚拟内存、常驻内存、共享内存;
  3. 进程的缺页异常数,包含主缺页异常和次缺页异常;
  4. Swap 换入和换出的内存大小、Swap 参数配置;
  5. JVM 堆的分配,JVM 启动参数;
  6. 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 分区换入和换出的内存大小。

了解到常见的内存指标后,**常见的内存问题又有哪些?**总结如下:
a. 系统剩余内存/可用不足(某个进程占用太多、系统本身内存不足),内存溢出
b. 内存回收异常:内存泄漏(进程在一段时间内内存使用持续走高)、GC 频率异常;
c. 缓存使用过大(大文件读取或写入)、缓存命中率不高;
d. 缺页异常过多(频繁的 I/O 读);
e. Swap 分区使用异常(使用过大);

内存相关指标异常后,分析思路是怎么样的?

  1. 使用 free/top 查看内存的全局使用情况,如系统内存的使用、Swap 分区内存使用、缓存/缓冲区占用情况等,初步判断内存问题存在的方向:进程内存、缓存/缓冲区、Swap 分区;
  2. 观察一段时间内存的使用趋势。如通过 vmstat 观察内存使用是否一直在增长;通过 jmap 定时统计对象内存分布情况,判断是否存在内存泄漏,通过 cachetop 命令,定位缓冲区升高的根源等;
  3. 根据内存问题的类型,结合应用本身,进行详细分析

举例:使用 free 发现缓存/缓冲区占用不大,排除缓存/缓冲区对内存的影响后 -> 使用 vmstat 或者 sar 观察一下各个进程内存使用变化趋势 -> 发现某个进程的内存时候用持续走高 -> 如果是 Java 应用,可以使用 jmap / VisualVM / heap dump 分析等工具观察对象内存的分配,或者通过 jstat 观察 GC 后的应用内存变化 -> 结合业务场景,定位为内存泄漏/GC参数配置不合理/业务代码异常等。

3.4 分析磁盘&文件

在分析和磁盘相关的问题时,通常是将其和文件系统同时考虑的,下面不再区分。和磁盘/文件系统相关的指标主要有以下几个,常用的观测工具为 iostat和 pidstat,前者适用于整个系统,后者可观察具体进程的 I/O
a. 磁盘 I/O 利用率:是指磁盘处理 I/O 的时间百分比;
b. 磁盘吞吐量:是指每秒的 I/O 请求大小,单位为 KB;
c. I/O 响应时间,是指 I/O 请求从发出到收到响应的间隔,包含在队列中的等待时间和实际处理时间;
d. IOPS(Input/Output Per Second):每秒的 I/O 请求数;
e. 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 情况。

如何判断磁盘的指标出现了异常?

  1. 当磁盘 I/O 利用率长时间超过 80%,或者响应时间过大(对于 SSD,从 0.0x 毫秒到 1.x 毫秒不等,机械磁盘一般为5ms~10ms),通常意味着磁盘 I/O 存在性能瓶颈;
  2. 如果 %util 很大,而 rkB/s 和 wkB/s 很小,一般是因为存在较多的磁盘随机读写,最好把随机读写优化成顺序读写,(可以通过 strace 或者 blktrace 观察 I/O 是否连续判断是否是顺序的读写行为,随机读写应可关注 IOPS 指标,顺序读写可关注吞吐量指标);
  3. 如果 avgqu-sz 比较大,说明有很多 I/O 请求在队列中等待。一般来说,如果单块磁盘的队列长度持续超过2,一般认为该磁盘存在 I/O 性能问题

3.5 分析网络问题

网络这个概念涵盖的范围较广,在应用层、传输层、网络层、网络接口层都有不同的指标去衡量。这里我们讨论的「网络」,特指应用层的网络,通常使用的指标如下:
a. 网络带宽:表示链路的最大传输速率;
b. 网络吞吐:表示单位时间内成功传输的数据量大小;
c. 网络延时:表示从网络请求发出后直到收到远端响应,所需要的时间;
d. 网络连接数和错误数;

一般来说,应用层的网络瓶颈有如下几类
a. 集群或机器所在的机房的网络带宽饱和,影响应用 QPS/TPS 的提升;
b. 网络吞吐出现异常,如接口存在大量的数据传输,造成带宽占用过高;
c. 网络连接出现异常或错误;
d. 网络出现分区。

带宽和网络吞吐这两个指标,一般我们会关注整个应用的,通过监控系统可直接得到,如果一段时间内出现了明显的指标上升,说明存在网络性能瓶颈。对于单机,可以使用 sar 得到网络接口、进程的网络吞吐

使用 ping 或者 hping3 可以得到是否出现网络分区、网络具体时延。对于应用,我们更关注整个链路的时延,可以通过中间件埋点后输出的 trace 日志得到链路上各个环节的时延信息。

使用 netstat、ss 和 sar 可以获取网络连接数或网络错误数。过多网络链接造成的开销是很大的,一是会占用文件描述符,二是会占用缓存,因此系统可以支撑的网络链接数是有限的。

3.6 工具总结

可以看到的是,在分析 CPU、内存、磁盘等的性能指标时,有几种工具是高频出现的,如 top、vmstat、pidstat,这里稍微总结一下:
a. CPU:top、vmstat、pidstat、sar、perf、jstack、jstat;
b. 内存:top、free、vmstat、cachetop、cachestat、sar、jmap;
c. 磁盘:top、iostat、vmstat、pidstat、du/df;
d. 网络:netstat、sar、dstat、tcpdump;
e. 应用:profiler、dump分析。

上述的很多工具,大部分是用于查看系统层指标的,在应用层,除了有 JDK 提供的一系列工具,一些商用的产品如 gceasy.io(分析 GC 日志)、fastthread.io(分析线程 dump 日志)也是不错的。

排查 Java 应用的线上异常或者分析应用代码瓶颈,可以使用阿里开源的 Arthas ,这个工具非常强大,下面简单介绍下。

Arthas 主要面向线上应用实时诊断,解决的是类似「线上应用异常了,需要在线进行分析和定位」的问题,当然,Arthas 提供的一些方法调用追踪工具,对我们排查诸如「慢查询」等问题,也是非常有帮助的Arthas 提供的主要功能有:
a. 获取线程统计,如线程持有的锁统计、CPU 利用率统计等;
b. 类加载信息、动态类加载、方法加载信息;
c. 调用栈追踪,调用耗时统计;
d. 方法调用参数、结果检测;
e. 系统配置、应用配置信息;
f. 反编译加载类;
g…

需要注意的是,性能工具只是解决性能问题的手段,我们了解常用工具的一般用法即可,不要在工具学习上投入过多精力。

4. 常见的一些调优分析思路

在通过工具得到异常指标,初步定位瓶颈点后,如何进一步进行确认和调优?这里将给出常见的一些调优分析思路,内容会按照CPU、内存、网络、磁盘等进行组织,并针对某一个优化点,会有系统的「套路」总结,便于思路的迁移实践。

4.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 类等。

代码层的优化除了上面这些,还有很多就不一一列出了。我们可以观察到,在这些要点里,有一些共性的优化思路,是可以抽取出来的,譬如:
a. 空间换时间:使用内存或者磁盘,换取更宝贵的CPU 或者网络,如缓存的使用;
b. 时间换空间:通过牺牲部分 CPU,节省内存或者网络资源,如把一次大的网络传输变成多次;
c. 其他诸如并行化异步化池化技术等。

4.2 CPU相关

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

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

这种情况常见于 CPU 密集型的应用,大量的线程处于可运行状态,I/O 很少,常见的大量消耗 CPU 资源的应用场景有
a. 正则操作
b. 数学运算
c. 序列化/反序列化
d. 反射操作
e. 死循环或者不合理的大量循环
f. 基础/第三方组件缺陷

排查高 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 可能不足。

4.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 日志,进行综合分析。

4.2.3 CPU 上下文切换次数变高

**先用 vmstat 查看系统的上下文切换次数,然后通过 pidstat 观察进程的自愿上下文切换(cswch)和非自愿上下文切换(nvcswch)情况。**自愿上下文切换,是因为应用内部线程状态发生转换所致,譬如调用 sleep()、join()、wait()等方法,或使用了 Lock 或 synchronized 锁结构;非自愿上下文切换,是因为线程由于被分配的时间片用完或由于执行优先级被调度器调度所致。

如果自愿上下文切换次数较高,意味着 CPU 存在资源获取等待,比如说,I/O、内存等系统资源不足等。如果是非自愿上下文切换次数较高,可能的原因是应用内线程数过多,导致 CPU 时间片竞争激烈,频频被系统强制调度,此时可以结合 jstack 统计的线程数和线程状态分布加以佐证。

4.3 内存相关

前面提到,内存分为系统内存和进程内存(含 Java 应用进程),一般我们遇到的内存问题,绝大多数都会落在进程内存上,系统资源造成的瓶颈占比较小。对于 Java 进程,它自带的内存管理自动化地解决了两个问题:如何给对象分配内存以及如何回收分配给对象的内存,其核心是垃圾回收机制

垃圾回收虽然可以有效地防止内存泄露、保证内存的有效使用,但也并不是万能的,不合理的参数配置和代码逻辑,依然会带来一系列的内存问题。此外,早期的垃圾回收器,在功能性和回收效率上也不是很好,过多的 GC 参数设置非常依赖开发人员的调优经验。比如,对于最大堆内存的不恰当设置,可能会引发堆溢出或者堆震荡等一系列问题。

下面看看几个常见的内存问题分析思路。

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

系统内存不足的排查思路
a. 首先使用 free 查看当前内存的可用空间大小,然后使用 vmstat 查看具体的内存使用情况及内存增长趋势,这个阶段一般能定位占用内存最多的进程;
b. 分析缓存 / 缓冲区的内存使用。如果这个数值在一段时间变化不大,可以忽略。如果观察到缓存 / 缓冲区的大小在持续升高,则可以使用 pcstat、cachetop、slabtop 等工具,分析缓存 / 缓冲区的具体占用;
c. 排除掉缓存 / 缓冲区对系统内存的影响后,如果发现内存还在不断增长,说明很有可能存在内存泄漏。

4.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 类型,由于不是很常见,就不一一介绍了。

4.3.3 Java 内存泄漏

Java 内存泄漏可以说是开发人员的噩梦,内存泄漏与内存溢出不同则,后者简单粗暴,现场也比较好找。内存泄漏的表现是:应用运行一段时间后,内存利用率越来越高,响应越来越慢,直到最终出现进程「假死」

Java 内存泄漏可能会造成系统可用内存不足、进程假死、OOM 等,排查思路却不外乎下面两种:
a. 通过 jmap 定期输出堆内对象统计,定位数量和大小持续增长的对象;
b. 使用 Profiler 工具对应用进行 Profiling,寻找内存分配热点。

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

4.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.4 磁盘I/O和网络I/O

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

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

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

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

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

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

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

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

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

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

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

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

5. 总结

性能优化是一个很大的领域,这里面的每一个小点,都可以拓展为数十篇文章去阐述。对应用进行性能优化,除了上面介绍的之外,还有前端优化、架构优化(分布式、缓存使用等)、数据存储优化、代码优化(如设计模式优化)等,限于篇幅所限,在这里并未一一展开,本文的这些内容,只是起一个抛砖引玉的作用。同时,本文的东西是我的一些经验和知识,并不一定全对,希望大家指正和补充。

性能优化是一个综合性的工作,需要不断地去实践,将工具学习、经验学习融合到实战中去,不断完善,形成一套属于自己的调优方法论

此外,虽然性能优化很重要,但是不要过早在优化上投入太多精力(当然完善的架构设计和编码是必要的),过早优化是万恶之源。一方面,提前做的优化工作,可能会不适用快速变化的业务需求,反倒给新需求、新功能起了阻碍的作用;另一方面,过早优化使得应用复杂性升高,降低了应用的可维护性。何时进行优化、优化到什么样的程度,是一个需要多方权衡的命题。

参考资料

[1]:https://blog.csdn.net/weixin_39860915/article/details/103519157
[2]:https://developer.aliyun.com/article/727625?spm=5176.8068049.0.0.7f0d6d19WJXuiS
[3]:阿里巴巴Java开发者面试百宝箱

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值