业务复杂度催生了了企业软件的复杂度,可观测性越来越多的得到重视,系统异常一定不是毫无征兆的,及时监测并干预可以保证系统稳定性。
这里,我们就来分析下 Tomcat 的关键性能监控指标,以及指标异常的典型特征,从而总结出常见异常分析思路,这些指标就可以得到量化,成为性能分析检查表或仪表盘。
一、Tomcat 性能分析 60s
这里选用 Linux 操作系统或 JDK 集成的 CLI 工具,一方面是保证监测手段可执行,在项目没有完备的监控系统或碍于严格的线上运维工具要求也可以完成监测任务,另一方面避免监控系统的可视化应用占用过多系统资源,在系统性能达到瓶颈时无响应。
通过这份清单可以快速完成对 Tomcat 运行状况的监测,也就是性能分析里常用的 60s 法则,快速找到性能瓶颈。监测内容涵盖 JVM、CPU、IO、内存、网络等。
Tomcat 监控指标包括吞吐量、响应时间、错误数、线程池、CPU 以及 JVM。
| 工具名称 | 工具能力 | 常用指令 |
|---------|----------------------------|-------------------------------------------|------|
| jps | 显示所有 JVM 进程,包括主类名和进程号 | jps |
| jstat | 收集 JVM 运行数据,如垃圾回收信息 | jstat -gc/gcutil/gccause |
| jmap | 生成内存转储快照文件(heapdump) | jmap -dump:format=b,file=./dumpfile.hprof |
| jstack | 生成 JVM 线程快照(threaddump) | jstack |
| cat | 查看 tomcat 进程状态 | cat /proc//status |
| top | 查看 tomcat 进程 CPU 和内存资源占用情况 | top -H -p |
| netstat | 查看 tomcat 网络连接 | netstat -na | grep |
| ifstat | 查看 tomcat 网络负载 | ifstat |
| vmstat | 查看 IO 资源占用 | vmstat |
二、JVM 调优举例
和 Web 应用程序一样,Tomcat 作为一个 Java 程序也跑在 JVM 中,对于 JVM 调优主要是对 JVM 垃圾收集的优化。观察到 Tomcat 进程的 CPU 使用率高,并且在 GC 日志中发现 GC 次数频繁、GC 停顿时间长,就表明需要对 GC 进行优化了。
下面以目前仍占主流的 Java 8 和 G1 作为举例对象。
1、开启 GC 日志打印
系统运维强调事故现场有迹可循,打印 GC 日志对于 JVM 调优来说就是必不可少的。常用的日志参数有这些,建议应用上线启用:
ruby
# 开启 GC 日志细节打印
-XX:+PrintGCDetails
# 打印 GC 时间戳
-XX:+PrintGCDateStamps
# GC 日志输出的文件路径,通过时间戳区分
-Xloggc:/opt/app/gc-%t.log
# 开启日志文件分割
-XX:+UseGCLogFileRotation
# 最多分割 20 个文件,超过之后循环从头文件开始写入
-XX:NumberOfGCLogFiles=20
# 单个文件大小上限 10M,超过就触发分割
-XX:GCLogFileSize=10M
2、观察 CPU 使用率
top 命令可以观察到 CPU 使用率已经超过了 100%:
sql
top - 04:06:14 up 1 day, 2:11, 2 users, load average: 2.43, 1.73, 1.45
Tasks: 135 total, 2 running, 133 sleeping, 0 stopped, 0 zombie
%Cpu(s): 34.5 us, 1.0 sy, 0.0 ni, 64.4 id, 0.0 wa, 0.0 hi, 0.1 si, 0.0 st
KiB Mem : 3879588 total, 221376 free, 3313652 used, 344560 buff/cache
KiB Swap: 8257532 total, 8257532 free, 0 used. 286964 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
6022 root 20 0 3635792 184024 13416 S 140.0 4.7 1:38.76 java
3、关注 FGC 与老年代占用率
连续运行 Full GC 就可能导致间歇性内存不足错误(OutOfMemoryErrors)、响应时间下降或 CPU 负载过高,甚至使应用程序完全无法响应。
使用 jstat 工具观察垃圾收集统计数据:
yaml
jstat -gcutil 6022
得到了如下结果:
yaml
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 0.00 97.78 92.71 88.85 1183 7.185 1131 53.537 60.722
触发了大量 FGC 并且 YGC 与 FGC 数量大致相当,老年代占用率逼近 100%,总 GC 时间超过了 60s。可以给出两种优化思路:
- 堆内存大小调整,减少 YGC 次数
- 观察内存回收效率,考虑内存泄漏
最后,结合业务特点再进行选择优化方向。
三、CPU 调优举例
CPU 作为重要的系统资源往往容易成为性能瓶颈,常见的分析思路就是抓出 CPU 占用率异常高的线程优化掉,或者整体占用率高但是没有某个线程占用率特别高,就要考虑大量线程频繁切换消耗的原因了。
1、观察 CPU 使用率
top 查看 CPU 占用率排行,这里很容易发现第一个 java 进程几乎耗尽了 CPU 资源:
sql
top - 17:16:53 up 1 day, 15:21, 2 users, load average: 9.84, 9.23, 9.32
Tasks: 134 total, 1 running, 133 sleeping, 0 stopped, 0 zombie
%Cpu(s): 46.4 us, 32.1 sy, 0.0 ni, 21.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 3879588 total, 470052 free, 3180356 used, 229180 buff/cache
KiB Swap: 8257532 total, 8136444 free, 121088 used. 476584 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
16208 root 20 0 4820976 222448 13392 S 369.4 5.7 281:09.76 java
1 root 20 0 128812 3904 2304 S 0.0 0.1 0:06.56 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:00.08 kthreadd
进一步查看这个进程中各线程的 CPU 占用率:
arduino
top - 15:04:14 up 1 day, 13:09, 2 users, load average: 7.80, 3.39, 1.47
Threads: 577 total, 129 running, 448 sleeping, 0 stopped, 0 zombie
%Cpu(s): 45.6 us, 32.8 sy, 0.0 ni, 21.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 3879588 total, 504768 free, 3246524 used, 128296 buff/cache
KiB Swap: 8257532 total, 8131324 free, 126208 used. 451976 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
14472 root 20 0 4811332 207504 13356 R 98.7 5.3 2:09.29 scheduling-1
14548 root 20 0 4811332 207504 13356 S 12.6 5.3 0:00.97 pool-1-thread-7
15017 root 20 0 4811332 207504 13356 R 11.0 5.3 0:00.62 pool-1-thread-5
14757 root 20 0 4811332 207504 13356 R 9.6 5.3 0:01.25 pool-1-thread-2
15024 root 20 0 4811332 207504 13356 R 9.3 5.3 0:00.46 pool-1-thread-5
14499 root 20 0 4811332 207504 13356 R 7.3 5.3 0:01.38 pool-1-thread-2
14572 root 20 0 4811332 207504 13356 R 7.0 5.3 0:00.65 pool-1-thread-1
14846 root 20 0 4811332 207504 13356 S 7.0 5.3 0:01.38 pool-1-thread-3
14536 root 20 0 4811332 207504 13356 S 5.6 5.3 0:00.94 pool-1-thread-6
14620 root 20 0 4811332 207504 13356 S 5.6 5.3 0:00.68 pool-1-thread-1
14784 root 20 0 4811332 207504 13356 R 5.6 5.3 0:01.07 pool-1-thread-3
除了第一个线程占用了大量 CPU 外,其余线程似乎占用率都不高,那 369.4% 都耗费在哪里呢,很有可能因为大量线程切换消耗掉了。
2、分析 thread dump
jstack 打印线程转储日志,也就是 thread dump,逐个分析线程状态。
首先查看占用 CPU 最高的线程 scheduling-1:
php
"scheduling-1" #29 prio=5 os_prio=0 tid=0x00007f903cefc800 nid=0x3f78 runnable [0x00007f8fe1832000]
java.lang.Thread.State: RUNNABLE
at sun.misc.Unsafe.unpark(Native Method)
at java.util.concurrent.locks.LockSupport.unpark(LockSupport.java:141)
at java.util.concurrent.SynchronousQueue$TransferStack$SNode.tryMatch(SynchronousQueue.java:265)
at java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:383)
at java.util.concurrent.SynchronousQueue.offer(SynchronousQueue.java:913)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1371)
at java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:112)
at com.example.demo.controller.HighCpuController.lambda$lockContention$0(HighCpuController.java:35)
at com.example.demo.controller.HighCpuController$$Lambda$577/1498344437.accept(Unknown Source)
at java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:110)
at java.util.stream.IntPipeline$Head.forEach(IntPipeline.java:581)
at com.example.demo.controller.HighCpuController.lockContention(HighCpuController.java:35)
at sun.reflect.GeneratedMethodAccessor4.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:84)
at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
at java.util.concurrent.FutureTask.runAndReset(FutureTask.java:308)
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.access$301(ScheduledThreadPoolExecutor.java:180)
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:294)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:750)
可以查到 HighCpuController 类中提交了周期性线程任务,AbstractExecutorService#submit 不断向线程池中提交任务从而消耗了大量 CPU,因此优化方向就具体化为对 HighCpuController 第 35 行附近代码的斟酌。
3、线程切换验证
佐证上面的猜测,可以进一步查看其他相同命名前缀(pool-1-thread)的线程状态,比如 pool-1-thread-3:
php
"pool-1-thread-3" #32 prio=5 os_prio=0 tid=0x00007f8fb4006000 nid=0x3f7b runnable [0x00007f8fe176f000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000c0c00000> (a java.util.concurrent.SynchronousQueue$TransferStack)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
at java.util.concurrent.SynchronousQueue$TransferStack.awaitFulfill(SynchronousQueue.java:460)
at java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:362)
at java.util.concurrent.SynchronousQueue.poll(SynchronousQueue.java:941)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:750)
线程有时间限制的等待在 ThreadPoolExecutor#getTask 方法上,尝试从线程池队列中拿到任务但是队列为空,于是通过 LockSupport#parkNanos 调用进入到了 TIMED_WAITING (parking) 状态。
统计下这种状态的线程数量,共有 536 个:
bash
grep -o 'pool-1-thread' threaddump.log | wc -l
进一步的,还可以通过 vmstat 验证:
css
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
217 0 126208 462160 44 127672 2 3 4 4 35 92 3 0 97 0 0
23 0 126208 461996 44 127672 0 0 0 0 51544 118594 7 30 63 0 0
9 0 126208 461872 44 127672 0 0 0 0 50658 120680 9 30 61 0 0
3 0 126208 461872 44 127672 0 0 0 0 51316 116810 7 30 63 0 0
4 0 126208 461872 44 127672 0 0 0 0 48867 115963 9 29 62 0 0
17 0 126208 461856 44 127676 0 0 0 0 50131 123902 8 30 62 0 0
发现 in 和 cs 的值异常高,表明 CPU 资源大量耗费在了线程切换上,再一次确认上面定位到的代码行就是重点分析对象了。比如使用 Integer 最大值作为线程数上限的 Executors#newCachedThreadPool 来创建线程池。
四、内存调优举例
内存作为重要的系统资源也往往容易成为性能瓶颈,如果发现系统频繁的触发 FGC,并且老年代占用率一直居高不下,那就可以大胆猜测发生了内存泄漏,需要作内存优化了。
1、开启 heap dump 打印
系统运维强调事故现场有迹可循,打印 OOM 时的转储日志对于分析系统崩溃原因来说就是必不可少的。常用的 heap dump 参数有这些,建议应用上线启用:
ruby
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./heapdump.hprof
触发一些 OOM 就会打印如下日志,并生成 hprof 文件:
makefile
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid25615.hprof ...
2、关注 FGC 与老年代占用率
与 JVM 调优思路类似,关注 FGC 与老年代占用率。必要时主动触发 heap dump 打印:
ini
jmap -dump:format=b,file=./dumpfile.hprof 25615
3、分析内存溢出点
结合 OOM 日志以及 heap dump 中分析内存溢出点,这要深入代码实现,并借助 MAT(Eclipse MemoryAnalyzer)等工具辅助完成。
比如解决 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 思路是考虑存在申请超大数组的地方;解决 java.lang.OutOfMemoryError: Java heap space 思路是内存泄漏导致 GC 一直无法回收内存,或者 JVM 参数配置不合适需要调整;解决 java.lang.OutOfMemoryError: GC overhead limit exceeded 思路是内存泄漏导致 GC 回收效率低。
五、IO 与线程池调优举例
1、选择合适的 IO 模型
Tomcat 支持的 IO 模型有 NIO、NIO.2、APR,默认为 NIO。
Tomcat 如果运行在 Windows 上并且访问量比较大,建议选择 NIO.2,因为 Windows 实现了异步 IO;如果运行在 Linux 上,则还是选择 NIO,因为 Linux 并没有真正实现异步 IO,只是通过 epoll 模拟了异步 IO。
如果 Web 应用需要 TLS 加密传输并且对性能要求很高,可以考虑使用 APR,它通过 OpenSSL 实现了高性能加解密。
2、线程池参数配置
线程池调优为了使 Tomcat 能够合理响应请求又不会给系统造成过大负担,经常调整的参数有这些。
maxThreads 是线程池最大线程数,默认值 200。maxThreads 设置过大将会导致线程资源空闲,以及浪费切换线程开销,设置过小就会导致 Tomcat 吞吐量过低。
一般设置原则是先根据系统业务特点设置一个极小值,高并发低耗时设置少量线程数,低并发高耗时设置较多线程数,高并发高耗时设置多线程数。再进行压测,当错误数显著增加或者响应时间大幅增加意味着达到系统极限,再逐阶加大线程数,直至 TPS 出现下降拐点后,就可以认为这是最佳线程数了。
minSpareThreads 是线程池最小线程数,默认值 25,线程空闲后将会被回收直至最小线程数。根据系统波谷分析最小线程使用量来调整它,设置过大也会导致线程资源浪费,设置过小就会造成频繁创建与销毁线程的开销。
3、分析 tcp 连接数
CPU 与内存占用没有明显增长,但是系统响应缓慢,优化思路转向线程状态与连接数。
统计 ESTABLISHED 连接数,共有 2000 个:
perl
netstat -na | grep 9999 | grep ESTAB | wc -l
线程打满考虑网络拥塞或者下游服务响应缓慢等异常。
六、网络调优举例
1、查找网络异常
从 Tomcat 日志中容易发现 Socket 异常,Connection timed out: connect,Connection reset,Too many open files 等,这些都表明需要从网络层面优化 Tomcat。
比如分析 Connection timed out: connect 异常是发生在所有网络请求中,还是某些服务上。结合 CPU 与内存占用分析上并没有明显增长,那就要考虑下游服务异常或者网络连接参数需要调整了。
2、网络连接参数配置
maxConnections 是指 Tomcat 在给定时间内接受和处理的最大连接数,NIO 默认值是 10000。达到此数量后,Tomcat 将继续根据 acceptCount 值接受但不处理新的连接。
acceptCount 是可在请求队列中排队的连接数,也就是超过 maxThreads 值后在队列中等待处理,默认值 100。当所有可能的请求处理线程都在使用时,接收到的连接请求的最大队列长度。队列已满时收到的任何请求都将被拒绝。同样需要根据压测来调整,在系统吞吐量以及系统负载之间平衡。
对于上面的例子就可以通过调大 maxConnections 来解决。
七、Tomcat 常见性能异常分析思路
1、异常数据来源
在建设完备的可观测系统之前,原始日志仍然是分析问题的首选。
Tomcat 作为运行在 JVM 上的进程,JVM 的异常分析同样适用于 Tomcat,比如 JVM 崩溃日志 hs_err,部分 OOM 发生时的 heap dump,以及线程转储日志 thread dump,他们是 JVM 调优的重要分析来源。
其次,运行在 Tomcat 上的组件日志也可以提供分析思路,比如网络 IO 异常、内存溢出异常等都可以从组件日志中捕获。
2、分析手段
上文提到的基本都是借助 JDK 或 Linux 自带的命令行工具完成的,一方面保证工具的可靠性,部署环境如何变化都能及时准确的获取系统运行现状,为性能分析拿到第一手资料;另一方面是稳定性,在可视化分析工具在系统资源达到瓶颈不可用时也能持续工作。这些基本工具使用,可以纳入研发和运维同学的武器库。
当然,分析手段远不止于此,随着 DevOps 深入各行各业的 arthas,云原生运维场景下的 datadog,以及 AI 加持的 gceasy 等产品都有着用武之地。
3、最后的最后
Tomcat 性能分析的本质还是对计算机操作系统的全方位掌握,能够从 CPU、内存、磁盘、网络等系统资源上找出性能瓶颈,并给出解决思路,也就是用性能工程的角度看待性能瓶颈。