Post

性能调优

  • 调优步骤 P192

衡量系统现状

随着系统数据量的不断增长,访问量的不断提升,系统的响应通常会越来越慢,又或是编写的新的应用在性能上无法满足需求,这个时候需要对系统的性能进行调优。调优过程是一个相当复杂的过程,涉及很多的方面:硬件、操作系统、运行环境软件以及应用本身,通常调优的步骤如图所示

调优前首先要做的是衡量系统现状,这包括目前系统的请求次数、响应时间、资源消耗等信息,例如 A 系统目前 95%的请求响应时间为1秒。

在有了系统现状后可设定调优目标,通常调优目标是根据用户所能接受的响应速度或系统所拥有的机器以及所支撑的用户量制定出来的,因此通常会设定出调优目标:95%的请求要在500ms内返回。在设定了调优的目标后,需要做的是寻找出性能瓶颈,这一步最重要的是找出造成目前系统性能不足的最大瓶颈点。找出后,可结合一些工具来找出造成瓶颈点的代码,到此才完成了这个步骤。

在寻找到了造成瓶颈点的代码后,通常需要分析其需求场景,然后结合一些优化的技巧制定优化的策略。优化策略或简或繁,选择其中收益比(优化后的预期效果/优化需要付出的代价)最高的优化方案,进行优化。

优化部署后,继续衡量系统的状况,如已达到目标,则可结束此次调优:如仍未达到目标,则要看是否产生了新的性能瓶颈。或可以考虑继续尝试上一步中制定的其他优化方案,直到达成调优目标或论证在目前的体系结构上无法达成调优目标为止。 Facebook为了将其网站的访问速度提升两倍,在上面的寻找性能瓶颈、性能优化、衡量是否达到调优的目标中循环了多次,最终在花费了6个月的时间后终于达到了调优的目标。

本章主要介绍如何寻找性能的瓶颈以及性能调优常用的一些方法。

先粗粒度的划分,再细粒度地寻找具体的点

寻找性能瓶颈

通常性能瓶颈的表象是资源消耗过多、外部处理系统的性能不足,或者资源消耗不多,但程序的响应速度却仍达不到要求。

资源主要消耗在CPU、文件IO、网络IO以及内存方面,机器的资源是有限的,当某资源消耗过多时,通常会造成系统的响应速度慢。

外部处理的性能不够主要是所调用的其他系统提供的功能或数据库操作的响应速度不够,所调用的其他系统性能不足,多数情况下也是资源消耗过多,但程序的性能不足造成的:数据库操作性能不足通常可以根据数据库的 sql 执行速度、数据库机器的IOPS、数据库的 Active Sessions 等分析出来。资源消耗不多,但程序的响应速度仍达不到要求的主要原因是程序代码运行效率不够高、未充分使用资源或程序结构不合理。

对于 Java 应用而言,寻找性能瓶颈的方法通常为首先分析资源的消耗,然后结合 Java 的一些工具来查找程序中造成资源消耗过多的代码,下面就以 Linux 和 Sun JDK 为例来介绍如何查找 Java 应用的性能瓶颈。

CPU 消耗分析

在 Linux 中,CPU 主要用于中断、内核以及用户进程的任务处理,优先级为中断 > 内核 > 用户进程。

在学习如何分析 CPU 消耗状况前,还有三个重要的概念要阐述。

上下文切换

每个 CPU(或多核 CPU 中的每核 CPU)在同一时间只能执行一个线程,Linux 采用的是抢占式调度。即为每个线程分配一定的执行时间,当到达执行时间、线程中有 IO 阻塞或高优先级线程要执行时,Linux 将切换执行的线程,在切换时要存储目前线程的执行状态,并恢复要执行的线程的状态,这个过程就称为上下文切换。对于 Java 应用,典型的是在进行文件 IO 操作、网络 IO 操作、锁等待或线程 Sleep 时,当前线程会进入阻塞或休眠状态,从而触发上下文切换,上下文切换过多会造成内核占据较多的 CPU 使用,使得应用的响应速度下降。

运行队列

每个 CPU 核都维护了一个可运行的线程队列,例如一个 4 核的 CPU,Java 应用中启动了 8 个线程,且这 8 个线程都处于可运行状态,那么在分配平均的情况下每个 CPU 中的运行队列里就会有两个线程。通常而言,系统的 load 主要由 CPU 的运行队列来决定,假设以上状况维持了 1 分钟,那么这 1 分钟内系统的 load 就会是 2,但由于 load 是个复杂的值,因此也不是绝对的,运行队列值越大,就意味着线程会要消耗越长的时间才能执行完。Linux System and NetWork Performance Monitoring 中建议控制在每个 CPU 核上的运行队列为 1~3 个。

利用率

CPU 利用率为 CPU 在用户进程、内核、中断处理、IO 等待以及空闲五个部分使用百分比,这五个值是用来分析 CPU 消耗情况的关键指标。Linux System and NetWork Performance Monitoring 中建议用户进程的 CPU 消耗/内核的 CPU 消耗的比率在 65% ~ 70%/30% ~ 35% 左右。

在 Linux 中,可通过 top 或 pidstat 方式来查看进程中线程的 CPU 的消耗状况。

  1. top

输入 top 命令后即可查看 CPU 的消耗情况,CPU 的信息在 TOP 视图的上面几行中。

1
2
3
4
5
top - 08:14:36 up 8 min,  1 user,  load average: 0.82, 0.21, 0.06
Tasks:  59 total,   1 running,  58 sleeping,   0 stopped,   0 zombie
%Cpu(s):  1.5 us,  0.3 sy,  0.0 ni, 97.8 id,  0.0 wa,  0.0 hi,  0.4 si,  0.0 st
MiB Mem :  13838.3 total,   9923.8 free,   2687.4 used,   1227.1 buff/cache
MiB Swap:   4096.0 total,   4096.0 free,      0.0 used.  10887.7 avail Mem

在此需要关注的是第三行的信息,其中 1.5 us 表示为用户进程处理所占的百分比;0.3 sy 表示为内核线程处理所占的百分比;0.0 ni 表示被 nice 命令改变优先级的任务所占的百分比;97.8 id 表示 CPU 的空闲时间所占的百分比;0.0 wa 表示为在执行的过程中等待 IO 所占的百分比;0.0 hi 表示为硬件中断所占的百分比;0.4 si 表示为软件中断所占的百分比;0.0 st 见下面的解释。

1
2
3
4
5
6
7
8
9
# from `man top`
us, user    : time running un-niced user processes
sy, system  : time running kernel processes
ni, nice    : time running niced user processes
id, idle    : time spent in the kernel idle handler
wa, IO-wait : time waiting for I/O completion
hi : time spent servicing hardware interrupts
si : time spent servicing software interrupts
st : time stolen from this vm by the hypervisor

对于多个或多核的 CPU,上面的显示则会是多个 CPU 所占用的百分比的总和,因此会出现 160% us 这样的现象。如须查看每个核的消耗情况,可在进入top视图后按 1,就会按核来显示消耗情况,如下面所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
top - 08:15:06 up 9 min,  1 user,  load average: 0.46, 0.19, 0.06
Tasks:  59 total,   1 running,  58 sleeping,   0 stopped,   0 zombie
%Cpu0  :  0.3 us,  0.3 sy,  0.0 ni, 99.3 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu1  :  0.7 us,  0.0 sy,  0.0 ni, 99.3 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu2  :  0.3 us,  0.0 sy,  0.0 ni, 99.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu3  :  0.0 us,  0.3 sy,  0.0 ni, 99.3 id,  0.3 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu4  :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu5  :  2.0 us,  0.7 sy,  0.0 ni, 97.3 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu6  :  0.3 us,  0.0 sy,  0.0 ni, 99.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu7  :  0.0 us,  0.3 sy,  0.0 ni, 99.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu8  :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu9  :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu10 :  0.0 us,  0.7 sy,  0.0 ni, 99.3 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu11 :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu12 :  0.0 us,  0.3 sy,  0.0 ni, 99.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu13 :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu14 :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu15 :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  13838.3 total,   9919.4 free,   2691.6 used,   1227.3 buff/cache
MiB Swap:   4096.0 total,   4096.0 free,      0.0 used.  10883.5 avail Mem

默认情况下,TOP 视图中显示的为进程的 CPU 消耗状况,在 TOP 视图中按 shit+h 后,可按线程查看 CPU 的消耗状况,如下所示。

此时的 PID 即为线程 ID,其后的 %CPU 表示该线程所消耗的 CPU 百分比。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
top - 08:25:08 up 19 min,  1 user,  load average: 0.14, 0.10, 0.08
Threads: 404 total,   2 running, 402 sleeping,   0 stopped,   0 zombie
%Cpu(s):  1.4 us,  0.3 sy,  0.9 ni, 96.6 id,  0.5 wa,  0.0 hi,  0.3 si,  0.0 st
MiB Mem :  13838.3 total,   9944.5 free,   3012.7 used,    881.1 buff/cache
MiB Swap:   4096.0 total,   4096.0 free,      0.0 used.  10561.4 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   9599 root      20   0  281092 114112  71200 R  26.2   0.8   0:01.04 unattended-upgr
    614 risk      20   0   11.3g 134608  48684 S   3.8   0.9   0:20.25 node
      1 root      20   0  165844  11148   8172 S   0.8   0.1   0:05.37 systemd
    397 mysql     20   0 2270696 419880  36420 S   0.3   3.0   0:00.21 ib_log_writer
    398 mysql     20   0 2270696 419880  36420 S   0.3   3.0   0:02.51 ib_log_files_g
    622 risk      20   0   11.3g 134608  48684 S   0.3   0.9   0:00.63 node
    677 root      20   0   44256  37620  10212 S   0.3   0.3   0:04.22 python3
   4556 risk      20   0   15.1g   2.0g 509856 S   0.3  15.1   0:00.88 DefaultDispatch
  1. pidstat

pidstat 是 SYSSTAT 中的工具,如须使用 pidstat,请先安装 SYSSTAT。

输入pidstat 1 2,在 console 上将会每隔 1 秒输出目前活动进程的 CPU 消耗状况,共输出 2 次,结果如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
➜  ~ sudo apt install sysstat
➜  ~ pidstat 1 2
Linux 5.15.153.1-microsoft-standard-WSL2 (DESKTOP-0VC22M5)      06/27/24        _x86_64_        (16 CPU)

08:28:13      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
08:28:14      108       307    0.00    0.99    0.00    0.00    0.99     0  mysqld
08:28:14        0       556    0.00    0.99    0.00    0.00    0.99     2  Relay(557)
08:28:14     1000      4448    0.00    0.99    0.00    0.00    0.99    12  java
08:28:14     1000      9313    0.00    0.99    0.00    0.00    0.99     0  java

08:28:14      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
08:28:15     1000       614    8.00    1.00    0.00    0.00    9.00     2  node
08:28:15        0       677    1.00    0.00    0.00    0.00    1.00    12  python3
08:28:15     1000      4448    0.00    1.00    0.00    0.00    1.00    12  java

Average:      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
Average:      108       307    0.00    0.50    0.00    0.00    0.50     -  mysqld
Average:        0       556    0.00    0.50    0.00    0.00    0.50     -  Relay(557)
Average:     1000       614    3.98    0.50    0.00    0.00    4.48     -  node
Average:        0       677    0.50    0.00    0.00    0.00    0.50     -  python3
Average:     1000      4448    0.00    1.00    0.00    0.00    1.00     -  java
Average:     1000      9313    0.00    0.50    0.00    0.00    0.50     -  java
➜  ~

其中 CPU 表示的为当前进程所使用到的 CPU 个数,如须查看某进程中线程的 CPU 消耗状况,可输入pidstat -p [PID] -t 1 5这样的方式来查看,执行后的输出如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
➜  ~ pidstat -p 4448 -t 1 5
Linux 5.15.153.1-microsoft-standard-WSL2 (DESKTOP-0VC22M5)      06/27/24        _x86_64_        (16 CPU)

08:30:20      UID      TGID       TID    %usr %system  %guest   %wait    %CPU   CPU  Command
08:30:21     1000      4448         -    0.00    1.00    0.00    0.00    1.00    12  java
08:30:21     1000         -      4448    0.00    0.00    0.00    0.00    0.00    12  |__java
08:30:21     1000         -      4465    0.00    0.00    0.00    0.00    0.00     0  |__java
08:30:21     1000         -      4467    0.00    0.00    0.00    0.00    0.00    11  |__GC Thread#0
08:30:21     1000         -      4468    0.00    0.00    0.00    0.00    0.00     9  |__G1 Main Marker
08:30:21     1000         -      4469    0.00    0.00    0.00    0.00    0.00     2  |__G1 Conc#0
08:30:21     1000         -      4470    0.00    0.00    0.00    0.00    0.00     0  |__G1 Refine#0
08:30:21     1000         -      4471    0.00    0.00    0.00    0.00    0.00    12  |__G1 Service
08:30:21     1000         -      4472    0.00    0.00    0.00    0.00    0.00    13  |__VM Thread
08:30:21     1000         -      4473    0.00    0.00    0.00    0.00    0.00    10  |__Reference Handl
08:30:21     1000         -      4474    0.00    0.00    0.00    0.00    0.00     6  |__Finalizer
08:30:21     1000         -      4475    0.00    0.00    0.00    0.00    0.00     5  |__Signal Dispatch
08:30:21     1000         -      4476    0.00    0.00    0.00    0.00    0.00     0  |__Service Thread
08:30:21     1000         -      4477    0.00    0.00    0.00    0.00    0.00    10  |__Monitor Deflati
08:30:21     1000         -      4478    0.00    0.00    0.00    0.00    0.00    15  |__C2 CompilerThre
08:30:21     1000         -      4479    0.00    0.00    0.00    0.00    0.00     9  |__C1 CompilerThre
08:30:21     1000         -      4480    0.00    0.00    0.00    0.00    0.00    14  |__Sweeper thread
08:30:21     1000         -      4481    0.00    0.00    0.00    0.00    0.00     1  |__Common-Cleaner
08:30:21     1000         -      4482    0.00    0.00    0.00    0.00    0.00     1  |__Notification Th
08:30:21     1000         -      4483    0.00    0.00    0.00    0.00    0.00    10  |__VM Periodic Tas
08:30:21     1000         -      4486    0.00    0.00    0.00    0.00    0.00     6  |__DefaultDispatch

图中的 TID 即为线程 ID,较之 top 命令方式而言,pidstat 的好处为可查看每个线程的具体 CPU 利用率的状况(例如 %system)。

toppidstat外,Linux 中还可使用 vmstat 来采样(例如每秒 vmstat 1)查看 CPU 的上下文切换运行队列、利用率的具体信息。ps Hh -eo tid,pcpu方式也可用来查看具体线程的 CPU 消耗状况(再grep一把);sar来查看一定时间范围内以及历史的 cpu 消耗状况信息。

当CPU消耗严重时,主要体现在 us、sy、wa 或 hi 的值变高,wa的值是 IO 等待造成的,这个在之后的章节中阐述;hi 的值变高主要为硬件中断造成的,例如网卡接收数据频繁的状况。

对于 Java 应用而言,CPU 消耗严重主要体现在 us、sy 两个值上,来分别看看 Java 应用在这两个值高的情况下应如何寻找对应造成瓶颈的代码。

  1. us

当 us 值过高时,表示运行的应用消耗了大部分的 CPU。在这种情况下,对于 Java 应用而言,最重要的为找到具体消耗 CPU 的线程所执行的代码,可采用如下方法做到。

首先通过 linux 提供的命令找到消耗 CPU 严重的线程及其ID,将此线程ID 转化为十六进制的值。之后通过 kill -3 [javapid]或jstack 的方式 dump 出应用的 Java 线程信息,通过之前转化出的十六进制的值找到对应的 nid 值的线程。该线程即为消耗 CPU 的线程,在采样时须多执行儿次上述的过程,以确保找到真实的消耗 CPU 的线程。

kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 …

Java 应用造成 us 高的原因主要是线程一直处于可运行(Runnable)状态,通常是这些线程在执行无阻塞、循环、正则或纯粹的计算等动作造成:另外一个可能也会造成 us 高的原因是频繁的 GC。如每次请求都需要分配较多内存,当访问量高的时候就将导致不断地进行 GC,系统响应速度下降,进而造成堆积的请求更多,消耗的内存更严重,最严重的时候有可能会导致系统不断进行 FullGC,对于频繁 GC 的状况要通过分析 JVM 内存的消耗来查找原因。

  1. sy

当 sy 值高时,表示 Linux 花费了更多的时间在进行线程切换, Java 应用造成这种现象的主要原因是启动的线程比较多,且这些线程多数都处于不断的阻寒(例如锁等待、IO 等待状态)和执行状态的变化过程中,这就导致了操作系统要不断地切换执行的线程,产生大量的上下文切换。在这种状况下对 Java 应用而言,最重要的是找出线程要不断切换状态的原因,可采用的方法为通过kill -3 [javapid]jstack -1 [javapid]的方式 dump 出 Java 应用程序的线程信息,查看线程的状态信息以及锁信息,找出等待状态或锁竞争过多的线程。

文件 IO 消耗分析

Linux 在操作文件时,将数据放入文件缓存区,直到内存不够或系统要释放内存给用户进程使用,因此在查看 Linux 内存状况时经常会发现可用(free)的物理内存不多,但cached用了很多,这是 Linux提升文件 IO 速度的一种做法。在这样的做法下,如物理空闲内存够用,通常在 Linux 上只有写文件和第一次读取文件时会产生真正的文件 IO。

在 Linux中,要跟踪线程的文件 IO 的消耗,主要方法是通过 pidstat 来查找。

  • pidstat

输入如 pidstat-d-t-p[pid]1100 类似的命令即可査看线程的 10 消耗状况,必须在 2.6.20 以上版本的内核中执行才有效,执行后的效果如下图所示:

其中KB rd/s 表示每秒读取的KB数,KB wr/s 表示每秒写入的 KB 数。

在没有安装 pidstat 或内核版本为 2.6.20以后的版本的情况下,可通过iostat 来查看,但 iostat 只能查看整个系统的文件1O消耗情况,无法跟踪到进程的文件10消耗状况。

  • iostat

直接输入iostat 命令,可查看各个设备的10历史状况,如下图所示:

在上面的几项指标中,其中 Device 表示设备卷标名或分区名;tps 是每秒的IO请求数,这也是IO消耗情况中值得关注的数字;Blkread/s是指每秒读的块数量,通常块的大小为512字节;BlIk_wrtnis是指每秒写的块数量;Blkread是指总共读取的块数量:BIkwrtn是指总共写入的块数量。

除了上面的方式外,还可通过输入iostat -x xvda 3 5这样的方式来定时采样查看 IO 的消耗状况,当使用上面的命令方式时,其输出信息会比直接输入 iostat 多一些:

其中值得关注的主要有:r/s表示每秒读的请求数:ws表示每秒写的请求数:await表示平均每次I0操作的等待时间,单位为毫秒;avgqu-sz表示等待请求的队列的平均长度;svctm表示平均每次设备执行 1〇 操作的时间:util 表示一秒之中有百分之几用于10 操作。

在使用iostat 查看10的消耗情况时,首先要关注的是CPU中的iowait%所占的百分比,当 iowait占据了主要的百分比时,就表示要关注I0方面的消耗状况了,这时可以再通过 iostat-x这样的方式来详细地查看具体状况。

当文件 IO 消耗过高时,对于Java应用最重要的是找到造成文件I0 消耗高的代码,寻找的最佳方法为通过 pidstat 直接找到文件 10操作多的线程。之后结合jstack 找到对应的Java代码,如没有 pidstat,也可直接根据jstack 得到的线程信息来分析其中文件10操作较多的线程。

Java 应用造成文件I0 消耗严重主要是多个线程需要进行大量内容写入(例如频繁的日志写入)的动作;或磁盘设备本身的处理速度慢:或文件系统慢;或操作的文件本身已经很大造成的。

在下面的例子中,通过往一个文件中不断地增加内容,文件越来越大,造成写速度慢,最终 IOWait值高,代码如下:

网络 IO 消耗分析

对于分布式 Java 应用而言,网络 IO 的消耗非常值得关注,尤其要注意网卡中断是不是均衡地分配到各 CPU 的(可通过 cat /proc/interrupts 查看)。对于网卡中断只分配到一个CPU的现象,Google 采用了修改 kernel 的方法对网卡中断分配不均的问题进行修复,据其测试性能大概能提升 3x 左右,或是采用支持 MSI-x”的网卡来修复。

在 Linux 中可采用 sar 来分析网络10的消耗状况,具体结果如图 5.11 所示。

输入 sar -nFULL12,执行后以1秒为频率,总共输出两次网络10的消耗情况,示例如下上面的信息中输出的主要有三部分,第一部分为网卡上成功接包和发包的信息,其报告的信息中主要有 rxpck/s、txpck/s、rxbyts、txbyts、rxmcsts:第二部分为网卡上失败的接包和发包的信息,其报告的信息中主要有rxerr/s、txer/s、rxdrop/s、txdrop/s;第三部分为sockets上的统计信息,其报告的信息中主要有tolsck、tcpsck、udpsck、rawsck。关于这些数值的具体含义可通过 man sar 米进行了解,对于Java应用而言,使用的主要为tcpsck和udpsck。

如须详细跟踪 tcp/ip 通信过程的信息,则可通过 tcpdump 来进行。

由于没办法分析具体每个线程所消耗的网络1O,因此当网络I〇消耗高时,对于Java应用而言只能对线程进行 dump,查找产生了大量网络10操作的线程。这些线程的特征是读取或写入网络流,在用Java 实现网络通信时,通常要将对象序列化为字节流,进行发送,或读取字节流,并反序列化为对象。这个过程要消耗JVM堆内存,JVMIVM堆的内存大小通常是有限的,因此Java应用一般不会造成网络IO消耗严重。

内存消耗分析

根据之前 JVM 第4章中对于Java 对象内存分配以及回收的介绍,可以看出Java 应用对于内存的消耗主要是在JVM 堆内存上,在正式环境中,多数Java应用都会将-Xms和-Xmx设为相同的值,避免运行期要不断申请内存。

目前的Java应用只有在创建线程和使用DirectByteBuffer时才会操作JVM堆外的内存JVM,因此在内存消耗方面最为值得关注的是JVM内存的消耗状况。对于IVM内存消耗状况分析的方法在深入JVM 中已介绍(jmap、jstat、mat、visualvm 等方法),在此就不再进行阐述,JVM 内存消耗过多会导致GC执行频繁,CPU消耗增加,应用线程的执行速度严重下降,甚至造成OutOfMemoryError,最终导致 Java 进程退出。 对于JVM堆以外的内存方面的消耗,最为值得关注的是swap的消耗以及物理内存的消耗,这两方面的消耗都可基于 os提供的命令来查看。

在 Linux 中可通过 vmstat、sar、top、pidstat 等方式来査看 swap 和物理内存的消耗状况。

  • vmstat

在命令行中输入 vmstat,其中的信息和内存相关的主要是memory下的swpd、frce、buff、cache以及 swap 下的si 和 s0。 其中 swpd是指虚拟内存已使用的部分,单位为kb;fee表示空闲的物理内存,bu表示用于缓冲的内存,cache表示用于作为缓存的内存,swap下的si是指每秒从disk读至内存的数据量,s0是指每秒从内存中写入 disk 的数据量。 swpd 值过高通常是由于物理内存不够用了,os将物理内存中的一部分数据转为放入硬盘上进行存储,以腾出足够的空间给当前运行的程序使用。在目前运行的程序变化后,即从硬盘上重新读取数据到内存中,以便恢复程序的运行,这个过程会产生swap0,因此看swap的消耗情况主要要关注的是swap10的状况,如swapIO 发生得较频繁,那么会严重影响系统的性能。由于 Java 应用是单进程应用,因此只要JVM 的内存设置不是过大,是不会操作到swap 区域的。物理内存消耗过高可能是由于JVM 内存设置过大、创建的Java线程过多或通过 Direct ByteBufcr 往物理内存中放置了过多的对象造成的。

  • sar

通过 sar 的-r 参数可査看内存的消耗状况如图 5.12所示,例如 sar -r2 5:

其中和swap相关的信息主要是kbswpfree、kbswpused、%swpused,kbswpfree 表示swap 空闲的大kbswpused 表示已使用的swap 大小,%swpused 表示使用的 swap 空间比率。小,

其中和物理内存相关的信息主要是kbmemftee、kbmemused、%memused、kbbufers、kbcached,当物理内存有空闲时,linux会使用一些物理内存用于buffer以及cache,以提升系统的运行效率,因此可以认为系统中可用的物理内存为:kbmemfree+kbbufers+kbcached。

sar相比vmstat的好处是可以査询历史状况,以更加准确地分析趋势状况,例如sar-r-/tmp/log/sa/sa12 .

vmstat和 sar 的共同弱点是不能分析进程所占用的内存量。

  • top

通过 top可查看进程所消耗的内存量,不过top中看到的Java进程的消耗内存是包括了JVM 已分配的内存加上 Java应用所耗费的JVM 以外的物理内存,这会导致top中看到Java 进程所消耗的内存大小有可能超过-Xmx 加上-XX:MaxPermSize 设置的内存大小,并且java 程序在启动后也只是占据了-Xms的地址空间,但并没有占据实际的内存,只有在相应的地址空间被使用过后才会被计入消耗的内存中。因此纯粹根据top很难判断出Java进程消耗的内存中有多少是属于JVM的,有多少是属于消耗JVM外的内存。一个小技巧是,对由于内存满而发生过FuGC的应用而言(不是主动调用System.gc的应用),多数情况下(例如由于产生的对象过大导致执行Fu GC并抛出 OutOfMemoryError 的现象就要除外)可以认为其 Java进程中显示出来的内存消耗值即为JVM-Xmx 的值+消耗的JVM 外的内存值。

  • pidstat

通过 pidstat也可查看进程所消耗的内存量,命令格式为:pidstat-r-p[pid] [interval][times],例如pidstat –p 20131100,执行此命令后可查看该进程所占用的物理内存和虚拟内存的大小,示例如图5.13 所示:

从以上的几个工具来看,最佳的内存消耗分析方法是结合top或pidstat,以及IVM 的内存分析工具来共同分析内存的消耗状况。

下面的两个例子分别展示了Java应用对物理内存的消耗和对IVM堆内存的消耗。

程序执行慢原因分析

有些情况是资源消耗不多,但程序执行仍然慢,这种情况多出现于访问量不是非常大的情况下,造成这种现象的原因主要有以下三种:

  1. 锁竞争激烈
  2. 未充分利用硬件资源
  3. 数据量增长

  4. 锁竞争激烈

锁竞争激烈直接就会造成程序执行慢,例如一个典型的例子是数据库连接池,通常数据库连接池提供的连接数都是有限的。假设提供的是10个,那么就意味着同时能够进行数据库操作的就只有10个线程,而如果此时有50个线程要进行数据库操作,那就会造成另外的40个线程处于等待状态,这种情况下对于4核类型的机器而言,CPU的消耗并不会高,但程序的执行仍然会较慢。

  1. 未充分使用硬件资源

例如机器上有双核CPU,但程序中都是单线程串行的操作,并没有充分发挥硬件资源的作用,此时就可进行一定的优化来充分使用硬件资源,提升程序的执行速度。

  1. 数据量增长

数据量增长通常也是造成程序执行慢的典型原因,例如当数据库中单表的数据从100万个上涨到1亿个后,数据库的读写速度将大幅度下降,相应的操作此表的程序的执行速度也就下降了。对于以上这两种状况,要记录程序执行的整个过程的时间消耗或使用JProfiler 等商业工具,从而找到执行耗时比率最大的代码,图5.14为一个基于JProfler 跟踪代码执行速度的截图:

性能调优

调优提提高系统性能,可以从硬件、操作系统,JVM 以及程序四个方面来着手。

JVM 调优

JVM 调优主要是内存管理方面的调优,包括各个代的大小、GC策略等。由于GC动作会挂起应用线程,严重影响性能,这些调优对于应用而言至关重要,根据应用的情况选择不同的内存管理策略有些时候能够大幅度地提升应用的性能,尤其是对于内存消耗较多的应用。下面就来看一些常用的内存管理调优的方法,这些方法都是为了尽量降低 GC所导致的应用暂停时间。

代大小的调优

在不采用 G1(G1 不区分 minor GC和 Ful GC)的情况下,通常 minor GC会远快于Full GC,各个代的大小设置直接决定了 minorGC和Full GC触发的时机,在代大小的调优上,最关键的参数为:Xms -Xmx -Xmn -XX:SurvivorRatio -XX:MaxTenuringThreshold.

-Xms 和-Xmx 通常设置为相同的值,避免运行时要不断地扩展JVM内存空间,这个值决定了JVMHeap 所能使用的最大空间。

-Xmn决定了新生代(New Generation)空间的大小,新生代中Eden、S0和S1三个区域的比率可通过-XX:SurvivorRatio 来控制。

.XX:MaxTenuringThreshold 控制对象在经历多少次 Minor GC后才转入旧生代,通常又将此值称为新生代存活周期,此参数只有在串行GC时有效,其他GC方式时则由SunJDK自行决定。

在第3章“深入理解 JVM”一章中已经介绍了MinorGC和FullGC 触发的时机,在此处就直接举例来看看不同的代大小设置情况下,应用耗费在GC上的时间,从中也可看出在不同的场景下代大小调优的方法。

1.避免新生代大小设置过小

当新生代大小设置过小时,会产生两种比较明显的现象,一是minorGC的次数更加频繁:二是有可能导致 minor gc 对象直接进入旧生代,此时如进入旧生代的对象占据了旧生代剩余空间,则触发 Ful GC。以下的代码模拟了上面的这两种现象:

以-Xms135M-Xmx135M-Xmn20M-XX:+UseSerialGC执行上面的代码,通过jstat 跟踪到的GC状况为: 共触发7次 minor GC,耗时为0.318秒,共触发2次Full GC,耗时为0.09秒,GC总耗时为0.408秒。 其中第一次 Full GC 是代码中主动调用 System.gc 造成的,而第二次 Full GC则是因为 minor GC 时Survivor 区空间不足,导致了对象直接进入了旧生代,这些对象加上旧生代原有的一些对象占满了旧生代空间,于是Ful GC被触发。而根据代码来看,其实只要新生代的空间能够支撑到tmpObjects 中的对象填充完毕,那么下次 minor GC 就可以把tmpObjects 所占据的空间全部回收掉,避免掉这次不必要的Ful GC。按照这样的思路,调大新生代到30MB,重新执行,执行后通过jstat 跟踪到的 GC 状况为:共触发4次 minor GC,耗时为0.303秒,共触发1次Ful! GC,耗时为 0.063 秒,GC 总耗时为0.366 秒。

从上面的结果可以看出,经过这样的调整,有效地减少了FulGC的频率。

除了调大新生代大小外,如果能够调大JVMHeap的大小,那就更好了,但JVM Heap 调大通常意味着单次GC 时间的增加。

当 minor GC 过于频繁,或发现经常出现minor GC 时,Survivor 的一个区域空间满,且 Old Gen 增长超过了 Survivor 区域大小时,就需要考虑新生代大小的调整了。调整时的原则是在不能调大JVM Heap的情况下,尽可能放大新生代空间,尽量让对象在 minorGC阶段被回收,但新生代空间也不可过大:在能够调大JVM Heap的情况下,则可以按照增加的新生代空间大小增加JVM Heap 大小,以保证旧生代空间够用。

  1. 避免新生代设置过大

新生代设置过大会带来两个典型的现象,一是旧生代变小了,有可能导致FulGC频繁执行:二是minorGC的耗时大幅度增加。

仍然用上面的例子,调整为以下参数执行:-Xms135M-Xmx135M-Xmn105M,通过istat观察到其 GC 状况为:

共触发1次 minor GC,耗时为 0.141秒,共触发2次FulGC,耗时为0.152秒,GC总耗时为0.293 秒。

从这个调整和之前把新生代调为 30MB 时对比,此时minor GC下降了,但Ful GC 仍然多了一次。原因在于,当第二次到达 minor GC的触发条件时,JVM 基于悲观原则,判断目前 old区的剩余空间小于可能会从新生代晋升到 old 区的对象的大小,于是执行了FullGC,而从 minor GC 消耗的时间来看,单次 minorGC 的时间也比以前慢了不少。 从上面的分析来看,可见新生代通常不能设置得过大,大多数场景下都应设置得比旧生代小,通常推荐的比例是新生代占JVM Heap 区大小的 33%左右。 3.避免 Survivor 区过小或过大 根据第3章“深入理解JVM”中的讲解,在采用串行GC时,默认情况下 Eden、S0、S1的大小比例为8:1:1,调整为以下参数执行上面示例的代码:-Xms135M-Xmx135M-Xmn20M-XX:SurvivorRatio=10-XX:+UseSerialGC,通过jstat 观察到其 GC 状况为:共触发6次 minor GC,耗时为 0.324秒,共触发1次 Full GC,耗时为 0.055 秒,GC 总耗时为 0.379秒 从上面的调整结果来看,在没有调大新生代空间的情况下,同样避免了第二次Full GC的发生。简单分析一下原因:在上面的场景中,tmpObjects在创建的过程中需要大概16MB的空间,新生代大小设置为 20MB,默认情况下Eden区的大小为16MB,两个Survivor区分别为2MB,当tmpObjects 创建时,会填满Eden space,从而触发 minor GC。而此时这16MB的对象都是有引用的对象,minor GC时只能将其放入 Suryivor区,但Suryivor区只有 2MB的空间,因此将有 14MB 的对象转入旧生代中,而旧生代的空间大小为 115MB,之前已用了101MB左右的空间,当这14MB对象加入时,旧生代空间被占满,于是 Full GC 被触发。在加入了-XX:SurvivorRatio-10参数后,Eden 区的大小调整为 16.7MB,当 tmpObjects 创建完毕时,还不足以触发 minor GC,自然 Full GC 也被避免了。

从上面的分析来看,在无法调整JVM Heap 以及新生代的大小时,合理调整 Survivor 区的大小也能带来一些效果。调大 SurvivorRatio 值意味着Eden区域变大,minorGC 的触发次数会降低,但此时Survivor 区域的空间变小了,如有超过 Survivor 空间大小的对象在 minor GC后仍没有被回收,则会直接进入旧生代:调小 SurvivorRatio则意味着 Eden 区域变小,minorGC的触发次数会增加,Survivor区域变大,意味着可以存储更多在 minorGC 后仍存活的对象,避免其进入旧生代。 4.合理设置新生代存活周期 新生代存活周期的值决定了新生代的对象经过多少次MinorGC后进入旧生代,因此这个值也需要根据应用的状况来做针对性的调优,IVM 参数上这个值对应的为-XX:MaxTenuringThreshold,默认值为15 次。 下面是一段示例代码,该代码的目的是先让旧生代中放置一些对象,然后让某对象在经过了16次minor GC 后仍存活,超过默认的 TenuringThreshold 值从而进入旧生代,示例代码如下:

程序调优

程序调优根据资源的消耗情况及分析,对程序的实现进行一定的调优,下面就来看看常用的一些调优方法。

CPU 消耗严重

CPU 消耗严重的解决方法

1.CPU us 高的解决方法

根据之前的分析,CPUus高的原因主要是执行线程无任何挂起动作,且一直执行,导致CPU没有机会去调度执行其他的线程,造成线程饿死的现象。对于这种情况,常见的一种优化方法是对这种线程的动作增加 Thread.sleep,以释放 CPU的执行权,降低 CPU 的消耗。 按照这样的思想,对5.1.1节“CPU消耗分析”中的例子进行修改,在往集合中增加元素的部分增加sleep,修改如下:

从上结果可见,CPU 的消耗大幅度下降,当然,这种修改方式是以损失单次执行性能为代价的,但由于降低了 CPU的消耗,对于多线程的应用而言,反而提高了总体的平均性能。在实际的 Java 应用中会有很多类似的场景,例如多线程的任务执行管理器,它通常要通过扫描任务集合列表来执行任务。对于这些类似的场景,都可通过增加一定的 sleep 时间来避免消耗过多的 CPU。除了上面的场景外,还有一种经典的场景是状态的扫描,例如某线程要等其他线程改变了值后才可继续执行。对于这种场景,最佳的方式是改为采用 wait/notify 机制。

对于其他类似循环次数太多、正则、计算等造成的 CPUus过高的状况,则要结合业务需求来进行调优。

对于 GC 频繁造成的 CPU us 高的现象,则要通过 JVM 调优或程序调优,降低 GC 的执行次数。

2.CPU sy 高的解决方法

CPU sy 高的原因主要是线程的运行状态要经常切换,对于这种情况,最简单的优化方法是减少线程数。

按照这样的思路,将CPU资源消耗中的例子重新执行,将线程数降低,传入参数100,执行结果如图 5.17 所示:

可见减少线程数是能让sy值下降的,所以不是线程数越多吞吐量就越高,线程数需要设置为合理的值,这要根据应用情况来具体决定,同时使用线程池避免要不断地创建线程。如应用要支撑大量的并发,在减少线程数的情况下最好是增加一个缓冲队列,避免因为线程数的减少造成系统出错率上升。

造成CPUsy高的原因除了启动的线程过多以外,还有一个重要的原因是线程之间锁竞争激烈,造成了线程状态经常要切换,因此尽可能降低线程间的锁竞争也是常见的优化方法。锁竞争降低后,线程的状态切换的次数也就会下降,Sy值会相应下降。但值得注意的是,如线程数过多,调优后有可能会造成us值过高,所以合理地设置线程数非常关键。锁竞争更有可能造成系统资源消耗不多,但系统性能不足的现象,因此关于降低线程之间锁竞争的调优技巧放入了后续的章节中进行讲述。除了以上两种情况外,对于分布式Java应用而言,还有一种典型现象是应用中有较多的网络 IO操作或确实需要一些锁竞争机制(例如数据库连接池),但为了能够支撑高的并发量,在 Java应用中又只能借助启动更多的线程来支撑”。在这样的情况下当并发量增长到一定程度后,可能会造成 CPU sy高的现象,对于这种现象,可采用协程(Corouine”)来支撑更高的并发量,避免并发量上涨后造成CPUsy消耗严重、系统 load 迅速上涨和系统性能下降。

在目前的 Sun JDK 实现中,创建并启动一个 Thread 对象就意味着运行了一个原生线程,当这个线程中有任何的阻塞动作(例如同步文件I0、同步网络10、锁等待、Thread.slecp 等)时,这个线程就会被挂起,但仍然占据着线程的资源。当线程中的阻塞动作完成时,由操作系统来恢复线程的上下文,并调度执行,这是一种标准的遵循目前操作系统的实现方式,这种方式对于Java应用而言,当并发量上涨后,有可能出现的现象是启动的大量线程都处于浪费状态。例如一个线程在等待数据库执行结果的返回,如这个数据库执行操作需要花费2秒,那么就意味着这个线程资源被白白占用了2秒,一方面导致了其他的请求只能是放在缓冲队列中等待执行,性能下降:另一方面是造成系统中线程切换频繁,CPU运行队列过长,协程要改变的就是不浪费相对昂贵的原生线程资源。

采用协程后,能做到当线程等待数据库执行结果时,就立即释放此线程资源给其他请求,等到数据库执行结果返回后才继续执行,在Java中目前主要可用于实现协程的框架为Kilim”。在使用Kim执行一项任务时,并不创建Thread,而是改为创建Task,Task 相对于 Thread 而言就轻量级多了。当此Task 要做阻塞动作时,可通过 Mailbox.get或Task.pause 来阻塞当前 Task,Kilim 会保存 Task之后执行需要的对象信息,并释放 Task执行所占用的线程资源:当 Task 的阻塞动作完成或被唤醒时,此时 Kilim会重新载入 Task 所需的对象信息,恢复 Task的执行,相当于 Kilim 来承担了线程的调度以及上下文切换动作。这种方式相对原生 Thread方式更为轻量,且能够更好地利用CPU,因此可做到仅启动CPU核数的线程数,以及大量的Task 来支撑高并发量,Kilim 带来的是线程使用率的提升,但同时由于要在JVM 堆中保存 Task上下文信息,因此在采用Kilim 的情况下要消耗更多的内存。

下面是一个传统方式和基于Kilim 采用Coroutine方式支撑高并发请求对比的例子。传统方式如下:

在一台linux机器(2核Intel(R)Xeon(R)CPUE5410 @2.33GHz,2GB内存)上执行,传统方式耗时大概为 3077ms,而基于Kilim 采用协程方式的耗时大概为277ms。可见在这种高并发的情况下协程方式对性能提升以及支撑更高的并发量可以起到很大的作用。

目前 Kilim 版本仅为0.7,而且没有商用的实际例子,如打算在实际的系统中使用,还需要慎重。一方面是0.7中基于objec.waitnotify机制实现的Scheduler在高压下会出现bug,可自行基于ThreadPoolExecutor 进行改造;另一方面 Mailbox.get(timout)是基于 Timer 实现的,由于 Timer 在增加 task到队列时和运行task队列是互斥的(即使是 ScheduledThreadPoolExecutor 也同样需要锁整个队列),对于大并发的应用而言这里是个潜在的瓶颈。对于Java应用而言,Timer是一个经常用来实现定时任务的类,但 Timer 的性能在高并发下是一般的,感兴趣的读者可以尝试基于 TimerWheel 算法”^来提升 Timer的性能。现在要在Java应用中使用Kilim 来实现协程方式并没有例子中这么简单,因为协程方式要求所有的操作都不阻塞原生线程,这就要求应用中不能使用目前 Java 里的同步、锁等机制。除了这些之外,还需要解决同步访问数据库、操作文件等问题,这些都必须改为是异步方式或Kiim 中的 Task 暂停的机制。目前 SunJDK7中也有一个支持协程方式的实现,感兴趣的读者可进一步阅读”,另外基于JVM的 Scala的 Actor也可用于在 Java 中使用协程。

除了软件方面对提升CPU使用率做出的努力外,硬件方面的CPU专业化(例如GPU进行图形计算)也很值得关注,这些同样有可能会给 Java 应用带来一些帮助。

文件 IO 消耗严重

从程序角度而言,造成文件I0消耗严重的原因主要是多个线程在写大量的数据到同一文件,导致文件很快变得很大,从而写入速度越来越慢,并造成各线程激烈争抢文件锁,对于这类情况,常用的调优方法有以下几种。

  • 异步写文件

将写文件的同步动作改为异步动作,避免应用由于写文件慢而性能下降太多,例如写日志,可以使用 1og4j提供的 AsyncAppender。

  • 批量读写

频繁的读写操作对IO消耗会很严重,批量操作将大幅度提升I0操作的性能

  • 限流

频繁读写的另外一个调优方式是限流,从而将文件 IO 消耗控制到一个能接受的范围,例如通常在记录日志时会采用如下方式:

1
log.error(errorInfothrowable);

如以上方式不做任何处理,在大量出现异常时,会出现所有的线程都在执行1og.eror(…),此时可采取的一个简单策略为统计一段时间内log.crror 的执行频率。当超过这个频率时,一段时间内不再写log,或塞入一个队列后缓慢地写,简单的一段实现代码示例如下:

限制文件大小操作太大的文件也是造成文件10 效率低的一个原因,因此对于每个输出的文件,都应做大小的限制,在超出最大值后可生成一个新的文件,类似1og4j中 RollingFileAppender 的 maxFileSize 属性的作用。 除了以上这些外,还有就是尽可能采用缓冲区等方式来读取文件内容,避免不断与操作系统交互,具体可参见 Sun 官方的关于 Java 文件 IO 优化的文章!?。

网络 IO 消耗严重

从程序角度而言,造成网络10消耗严重的原因主要是同时需要发送或接收的包太多。对于这类情况,常用的调优方法为进行限流,限流通常是限制发送packet的频率,从而在网络0消耗可接受的情况下来发送 packet。

对于内存消耗严重

在内存消耗方面,最明显的在于消耗了过多的JVMHeap内存,造成GC频繁执行的现象,而物理内存方面的消耗通常来说不会成为Java应用中的主要问题。除了JVM 的调优外,在寻找到内存消耗严重的代码后,可从代码本身进行优化,避免内存资源消耗过多,此处就介绍一些JVMHeap内存消耗严重时常用的程序调优方法。

  1. 释放不必要的引用
  2. 使用对象缓存池
  3. 采用合理的缓存失效算法
  4. 合理使用SoftReferenceWeakReference

对于资源消耗不多,但程序执行慢的情况

对于分布式应用而言,造成这种情况的主要原因通常有锁竞争激烈以及未充分发挥硬件资源两种。

锁竞争激烈

未充分使用硬件资源

主要是 CPU 和内存

This post is licensed under CC BY 4.0 by the author.