Java 程序员的 Linux 性能调优宝典:三大经典场景深度剖析

本文整理了Linux系统性能问题排查的通用方法论和实践,将针对以下三个经典场景展开探讨:

I/O性能瓶颈CPU飙升偶发CPU飙升

同时考虑到笔者文章的受众面大部分都是Java开发人员,所以复现问题故障的例子也都采用Java进行编码部署复现,对应的示例也都会在案例排查的最后展开说明。

一、应用程序延迟升高

第一个案例是用户反馈系统延迟升高,网卡打开缓慢。从开发者的角度一定要明白,所有表现为卡顿、延迟的原因很大概率是系统资源吃紧,只有在资源分配不足的情况下,才会导致程序运行阻塞,无法及时处理用户的请求。

关于服务器的阈值指标,按照业界通用的经验,对应CPU和内存的负载要求的合理上限为:

CPU使用率控制在75%左右内存使用率控制在80%以内虚拟内存尽可能保持在0%负载不超过CPU核心数的75%

笔者一般会先通过top查看操作系统的CPU利用率,这里笔者因为是个人服务器原因则采用更强大、更直观的htop查看个人服务器的资源使用情况,对应的安装指令如下:

复制
sudo apt update sudo apt install htop1.2.

而本次htop输出的指标如下:

服务器为6核,对应的CPU使用率分别是3.9、0、0、2.7、0.7、1.4,按照业界的通用标准,当前服务器各核心CPU使用率较低,但需结合系统负载综合判断Mem代表了内存使用率,内存一般情况下是用于存储程序代码和数据,分为物理内存和虚拟内存,物理内存显示内存接近8G仅用了1G不到,使用率不到80%,说明资源冗余Swp显示交换空间即虚拟内存的使用情况,可以看到也仅仅用了32M,并没有大量的内存数据被置换到交换空间,结合第2点来看,内存资源充足Tasks显示进程数和线程数一共有35个进程,这35个进程对应100个线程处理,Kthr显示指标为0说明有0个内核线程,而Running为1说明有一个用户进程在运行而系统平均负载近1分钟为4.96,按照业界标准CPU核心数*0.75作为系统负载的运算阈值,如果超过这个值则说明系统处于高负载状态,很明显我们的6核服务器系统负载偏高了

综合来看,服务器系统负载偏高但各CPU核心使用率较低,结合内存使用情况,问题可能出现在I/O资源等待上,此时我们就要从I/O资源角度进一步排查问题:

我们从I/O资源排查入手,通过vmstat 1执行每秒一次的监控指标输出,以笔者的服务器为例,可以看到如下几个指标:

r:按照文档解释为The number of runnable processes (running or waiting for run time)即正在运行或等待运行的进程数,如果大于CPU核心数则说明CPU处于过载状态,而当前服务器这个值为0,说明队列处理状态良好b::按照文档解释为The number of processes blocked waiting for I/O to complete即等待I/O完成的进程数,从参数b可以看出有大量进程等待I/O,说明当前服务器存在I/O瓶颈。swpd:the amount of swap memory used即交换空间也就是虚拟内存的使用,而当前服务器已被使用30468说明存在缓存置换,由此参数结合buff(缓存中尚未写入磁盘的内容)和cache(从磁盘加载出来的缓存数据)来看,当前内存资源持续升高,存在读写虚拟内存的情况,存在I/O性能瓶颈。从bo来看有大量任务进行每秒写块针对CPU一个板块输出的us(用户代码执行时间)、sy(内核执行时间)、id(空闲时间)、wa(等待I/O的时间),其中wa即等待I/O的时间持续处于一个高数值的状态,更进一步明确CPU在空转,等待I/O完成,而I/O资源处于吃紧的状态

考虑为I/O资源瓶颈,我们优先从网络I/O角度排查问题,这里笔者采用nload进行网络资源诊断,如果没有下载的可以自行通过yum或者apt的方式自行下载,这里笔者也贴出ubuntu服务器的下载指令:

复制
# ubuntu下载安装nload sudo apt install nload -y1.2.

键入nload实时输出网络带宽的使用情况,可以看到:

输入流量(incoming)即下载流量,当前网速基本控制在1KB,仅在最大网速的20%左右,一般认为只有当前网速无限接近于最大网速才可认为带宽使用率接近饱和输出流量(outgoing)即上传流量,同理当前也仅仅使用8%,也未达到饱和的阈值

所以I/O资源吃紧的问题原因并非网络I/O,我们需要进一步从服务器磁盘I/O角度进一步排查:

所以从这些指标来看,存在大量的线程在等待I/O资源的分配而进入阻塞,所以笔者基于iostat -x 1使每一秒都输出更详细的信息,可以看到sdd盘对应的磁盘忙碌百分比util基本跑满100%,已基本接近饱和,此时基本是确认有大量线程在进行I/O读写任务了,且查看I/O读写指标:

每秒读写的吞吐量w/s为175每秒写入wkB/s的172704KBSSD盘util即I/O资源利用率为100%,已经远超业界阈值60%,说明存在I/O性能瓶颈,需要补充说明的是当CPU100%时进程调度会因为操作系统优先级设置的原因并不会导致进程阻塞,但是I/O设备则不同,它不能区分优先级进行I/O中断响应,所以这个数值高的情况下就会使得大量I/O请求阻塞写请求的平均等待时间w_await为5151ms

换算下来172704KB/175每秒写入的速率为987KB每秒,由此可确定因为磁盘性能读写性能瓶颈导致大量I/O读写任务阻塞,进而导致服务器卡顿,用户体验差:

所以,对于系统延迟严重的情况,整体排查思路为:

通过top指令查看CPU使用率,若正常进入步骤2基于vmstat查看内存使用率和I/O资源情况基于nload查看网络I/O使用情况基于iostat查看网络I/O和磁盘I/O情况最终确认问题

本例子的最后笔者也给出本次故障问题的示例代码:

复制
/** * 启动磁盘I/O操作以模拟高I/O负载 * 通过创建多个I/O任务线程来模拟高磁盘I/O负载 */ private static void startDiskIOOperations() { log.info("开始高I/O磁盘操作..."); log.info("在另一个终端中运行 iostat -x 1 来监控磁盘利用率。"); // 创建固定线程数的线程池 executor = Executors.newFixedThreadPool(NUM_THREADS); // 提交多个任务以连续写入磁盘 for (int i = 0; i < NUM_THREADS; i++) { executor.submit(new IOTask(i)); } log.info("磁盘I/O操作已启动,使用 {} 个线程", NUM_THREADS); } /** * 执行连续写入操作以模拟高I/O的任务 * 该类负责执行磁盘I/O操作,通过不断写入和清空文件来模拟高I/O负载 */ static class IOTask implements Runnable { private final int taskId; public IOTask(int taskId) { this.taskId = taskId; } @Override public void run() { // 每个线程写入自己的临时文件 String filename = "/tmp/disk_io_test_" + taskId + ".tmp"; try (FileOutputStream fos = new FileOutputStream(filename)) { log.info("线程-{} 正在写入 {}", taskId, filename); // 连续将数据写入文件并在每次写入后清空文件 while (!Thread.currentThread().isInterrupted()) { performDiskIOOperation(fos, taskId); ThreadUtil.sleep(500); } } catch (IOException e) { log.error("线程-{} 发生错误: {}", taskId, e.getMessage()); } } }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.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.

二、系统操作卡顿

第二个例子也同样是用户反馈系统操作卡顿感严重,整体点击响应非常慢,我们还是考虑资源吃紧,优先使用top指令查看资源使用情况,从top指令来看:

输出us查看各个核心的CPU使用率跑满系统平均负载基本超过70%(6*0.7)已经超过4.2

这就是经典的计算密集型任务跑满所有线程的经典例子:

一般针对CPU跑满的问题,笔者一般会通过mpstat -P ALL 1查看CPU时间片是否分配均衡,是否出现偏斜导致CPU过热的情况,例如所有运算任务都往一个CPU核心上跑,经过笔者每秒1次的输出持续观察来看,整体资源吃紧,但并没有出现资源分配偏斜的情况,同时内存资源使用率也不高,也没有大量的iowait等待:

结合第一步top指令定位到的进程是Java进程,笔者索性通过Arthas直接定位故障代码,首先通过thread锁定问题线程,可以看到pool-前缀的线程基本都是跑满单个CPU,所以我们就可以通过thread id查看线程的栈帧:

最终锁定到了这段代码段,即一个密集的循环运算的线程:

对应的笔者也贴出故障代码段示例,来总结一下系统使用卡顿的排查思路:

基本top查看用户态和内核态CPU使用率用户态使用率偏高,通过mpstat查看CPU使用是否偏斜,是否保证CPU亲和力如果CPU使用没有出现偏斜,则直接通过问题定位到Java进程,结合Arthas快速定位问题线程进行诊断
复制
/** * 模拟CPU使用率过高的情况 * 通过创建多个CPU密集型任务线程来模拟高CPU使用率 */ public static void startHighCPUUsage() { log.info("开始模拟高CPU使用率..."); // 创建CPU密集型任务的线程池 ExecutorService cpuExecutor = Executors.newFixedThreadPool(NUM_THREADS); // 提交多个CPU密集型任务 for (int i = 0; i < NUM_THREADS; i++) { cpuExecutor.submit(new CPUIntensiveTask(i)); } log.info("高CPU使用率模拟已启动,使用 {} 个线程", NUM_THREADS); } /** * CPU密集型任务,用于模拟高CPU使用率 * 该类通过执行复杂的数学计算来占用CPU资源,从而模拟高CPU使用率场景 */ static class CPUIntensiveTask implements Runnable { private final int taskId; public CPUIntensiveTask(int taskId) { this.taskId = taskId; } @Override public void run() { log.info("CPU密集型任务-{} 已启动", taskId); // 执行CPU密集型计算以提高CPU使用率 while (!Thread.currentThread().isInterrupted()) { // 执行一些复杂的数学计算 double result = 0; for (int i = 0; i < 1000000; i++) { result += Math.sqrt(Math.log(i + 1) * Math.cos(i) * Math.sin(i)); } log.debug("CPU任务-{} 完成一轮计算,结果: {}", taskId, result); } log.info("CPU密集型任务-{} 已结束", taskId); } }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.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.

三、持续的偶发性系统卡顿问题排查

此类问题比较棘手,系统偶发卡顿意味着是瞬时、频繁的资源吃紧,我们还是直接使用top指令无法明确立刻捕捉到进程,可能刚刚一看到飙升的进程就消失了。

同理使用mpstat、vmstat指令无法准确定位到超短期飙升问题的进程,而基于iostat也没有看到明显的I/O资源吃紧,所以我们可以采用perf指令解决问题,以笔者的Ubuntu服务器为例,对应的安装步骤:

复制
# 安装perf工具 sudo apt install linux-tools-generic sudo apt install linux-tools-common1.2.3.

在笔者完成安装并启动之后,系统抛出WARNING: perf not found for kernel xxxx的异常,对应的解决方案是要主动安装linux-tools-generic并定位到linux-tools目录下找到自己的generic版本完成符号链接,以笔者本次安装为例就是6.8.0-79:

复制
sudo ln -s /usr/lib/linux-tools/6.8.0-79-generic/perf /usr/bin/perf1.

完成上述安装之后,我们就可以通过将频率降低设置为99并将监控结果导出到tmp目录下的perf.data中:

复制
sudo perf record -F 99 -a -g -o /tmp/perf.data sleep 101.

可能很多读者好奇为什么需要将频率设置为99Hz,这样做的目的是为了避免与系统定时器中断频率(通常为100Hz)同步,从而避免锁步采样导致的偏差。

锁步采样是指采样频率与系统定时器中断频率相同或成倍数关系时,采样点会固定在相同的时间位置上,导致采样结果不能准确反映系统整体的性能状况。

使用99Hz这样的素数频率可以减少与系统周期性活动同步的概率,从而获得更全面、更准确的性能数据。

举个简单的例子,若我们试图确定道路是否出现拥堵,且通过24h一次的抽检查,那么当前样本就可能与交通保持一个平行的同步状态,例如:

交通车流情况在每天8点-12点拥堵,而我们的程序也是恰好在每天9点采集,那么它就会认为交通情况异常拥堵若每天14点进行一次采集那么就避开了交通阻塞的高峰期则会得到一个相反的、也是不正确的结论

为了规避相同周期频率导致的lockstep即锁同步采样,我们可以适当降低频率避免与交通周期时间同步,保证一天的数据能够在一个周期内被完整地采集到,而本例最好的做法就是将定时间隔改为23h,这样一来每个23天的样本周期就会得到一天中所有时间的数据就能做到全面地了解到交通情况:

等待片刻后perf指令就会将结果输出到perf.data目录下:

复制
[ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.701 MB /tmp/perf.data (586 samples) ]1.2.

此时,通过sudo perf report查看报告,可以看到一个pid为1115751的Java进程对应线程CPU使用率飙升到86,此时我们就可以基于这条信息到指定的进程上查看该线程是否存在密集的运算:

最后我们也给出本示例的问题代码:

复制
/** * 模拟CPU瞬间飙高然后降低的情况 * 实现每10秒一次的CPU使用率飙高和降低循环(仅使用单核) */ public static void startCPUSpikeAndDrop() { log.info("开始模拟CPU瞬间飙高然后降低..."); // 创建用于CPU飙高的线程池(仅使用单核) ExecutorService spikeExecutor = Executors.newFixedThreadPool(1); // 提交单个CPU密集型任务来制造飙高 spikeExecutor.submit(new CPUSpikeTask(0)); log.info("CPU瞬间飙高已启动,使用 {} 个线程", 1); // 每隔10秒切换CPU飙高状态,实现周期性飙高和降低 Thread spikeController = new Thread(() -> { boolean isSpiking = true; ExecutorService currentExecutor = spikeExecutor; while (!Thread.currentThread().isInterrupted()) { try { // 等待10秒 Thread.sleep(10000); if (isSpiking) { // 停止当前的CPU飙高任务 currentExecutor.shutdownNow(); log.info("CPU使用率已降低"); } else { // 启动新的CPU飙高任务 currentExecutor = Executors.newFixedThreadPool(1); currentExecutor.submit(new CPUSpikeTask(0)); log.info("CPU使用率已飙高"); } // 切换状态 isSpiking = !isSpiking; } catch (InterruptedException e) { log.error("CPU飙高控制线程被中断", e); break; } } }); spikeController.setDaemon(true); spikeController.start(); } /** * CPU瞬间飙高任务,用于模拟CPU瞬间飙高然后降低的情况 * 该类通过执行密集的数学计算来模拟CPU使用率的瞬时飙高,并在指定时间后自动停止 */ static class CPUSpikeTask implements Runnable { private final int taskId; public CPUSpikeTask(int taskId) { this.taskId = taskId; } @Override public void run() { log.info("CPU瞬间飙高任务-{} 已启动", taskId); // 执行空循环以提高CPU使用率 while (!Thread.currentThread().isInterrupted()) { // 空循环消耗CPU } log.info("CPU瞬间飙高任务-{} 已结束", taskId); } }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.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.

四、小结

本文针对应用延迟、系统卡顿、偶发频繁卡顿三种常见的系统故障给出通用普适的排查思路,整体来说此类问题归根结底都是系统资源吃紧,需要找到饱和的资源结合代码推测根源并制定修复策略,以本文为例,通用的排查思路都为:

基于top查看CPU、内存、负载若CPU未饱和则通过vmstat查看I/O资源使用情况明确I/O瓶颈通过nload和iostat查询是网络I/O还是磁盘I/O若上述排查都无果,且CPU负载偶发飙高,可通过perf并调整频率监控系统定位系统中异常运行的资源结合上述推断结果查看是否是异常消耗,如果是则优化代码,反之结合情况增加硬件资源

THE END