本文章系列未来计划持续更新,把我在学习/实习/工作中遇到的相关实际案例记录在这里

系列目录:

  1. 综述:本文章
  2. Java GC 导致的卡顿:https://alrisha.cn/2024/04/bd9408a6.html

这是一道经典的面试场景题,但也可以说是实际开发过程中最经常遇到的问题之一。实际上,这个问题问的是当服务器出现不可用情况时,应当如何快速排查、定位问题并解决问题。

可能的卡顿原因

对于卡顿原因,一般来说可以从以下几个大方考虑:

  • CPU : 当 CPU 占用过高时,服务器自然会发生卡顿。

    1. CPU 密集型任务 :如果运行的服务是 CPU 密集型的任务,那么 CPU 资源占用是不可避免的,此时为了确保服务器自身的可用性,可以对这个 CPU 密集型任务做一定的 CPU 资源隔离(限制核数、限制抢占的时间片等)
    2. 并发竞争 :如果发生比较严重的多线程竞争行为,线程频繁切换上下文也会导致 CPU 占用过高
    3. 持续占用 :忙等待、死循环、JVM Full GC 等操作,会导致线程长时间占用并浪费 CPU 资源,具体也体现为 CPU 占用过高
    4. 恶意软件 :(小概率)
    5. 硬件问题 :(如过热降频,小概率)
  • 内存 : 内存占用过高也会产生卡顿

    1. 内存泄漏 :这是最常见的原因。当程序的代码中存在错误,使得它无法释放不再使用的内存时,就会发生内存泄漏。随着时间的推移,这些未释放的内存会越来越多,最终可能耗尽所有的内存。
    2. 缓存占用 :有些程序会使用内存作为缓存,以提高数据访问的速度。如果缓存的大小没有得到有效的控制,可能会占用大量的内存。
    3. 大数据量处理 :如果程序需要处理大量的数据,例如大型数据库操作、大文件读写等,可能会占用大量的内存。
    4. 多进程或多线程 :每个进程或线程都会占用一定的内存。如果启动了大量的进程或线程,可能会占用大量的内存。
    5. 恶意软件 :(小概率)
  • 网络 : 网络问题也会使得服务器出现卡顿

    1. 网络带宽不足 :如果服务器的网络带宽不足,无法满足应用的需求,那么可能会导致服务器响应变慢,甚至出现卡顿。
    2. 网络延迟高 :如果服务器与客户端之间的网络延迟(latency)过高,那么客户端可能会感觉到服务器响应慢,这也可能被误认为是服务器卡顿。
    3. 网络丢包 :如果网络中出现丢包,那么 TCP 协议会尝试重新发送丢失的数据包,这会增加网络延迟,降低网络吞吐量,可能导致服务器卡顿。
    4. 网络攻击 :例如 DDoS 攻击(分布式拒绝服务攻击)可能会消耗大量的网络带宽,导致正常的网络请求无法得到响应,从而使服务器卡顿。
    5. 网络设备故障 :例如路由器、交换机等网络设备的故障,也可能导致网络连接不稳定,从而影响服务器的性能(请注意,在网络问题中,设备故障 往往不是 小概率事件)。
  • 其它 :

    1. 应用业务链路上的问题也会导致服务器卡顿,例如数据库慢查询、全表扫描/索引未命中等
    2. 硬件设备的问题,如磁盘 I/O 瓶颈、磁盘空间不足,但这些情况有时无法通过软件手段解决

案例:Java 线程阻塞导致的高 CPU 占用

示例代码如下:

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
import java.util.concurrent.CountDownLatch;

public class App {
public static void main(String[] args) {
// 创建一个 CountDownLatch 实例,计数器的初始值为 1
CountDownLatch latch = new CountDownLatch(1);

// 启动一个工作线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " started.");
// 死循环
int n = 1;
while (n < 10) {
n = (n + 1) % 10;
}
System.out.println(Thread.currentThread().getName() + " finished.");
// 线程完成后,计数器减 1
latch.countDown();
}).start();

// 启动三个等待线程
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
// 等待工作线程完成
latch.await();
System.out.println(Thread.currentThread().getName() + " started after all work threads finished.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}

在该示例代码中,三个等待线程将永远不会被唤醒,因为工作线程一直在死循环中。这会导致 CPU 占用过高,从而使得服务器出现卡顿。

定位问题

当服务器卡顿时,首先通过 top 指令查看高 CPU 占用的进程

1
2
3
4
5
6
7
8
9
10
11
12
top - 16:10:59 up  2:15,  0 users,  load average: 1.10, 1.03, 1.01
任务: 66 total, 1 running, 65 sleeping, 0 stopped, 0 zombie
%Cpu(s): 5.1 us, 0.3 sy, 0.0 ni, 94.6 id, 0.1 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 15828.4 total, 12199.9 free, 2143.8 used, 1484.7 buff/cache
MiB Swap: 4096.0 total, 4096.0 free, 0.0 used. 13357.7 avail Mem

进程号 USER PR NI VIRT RES SHR %CPU %MEM TIME+ COMMAND
10136 alrisha 20 0 7270604 71140 19584 S 99.7 0.4 72:29.96 java
1187 alrisha 20 0 22.2g 627804 49520 S 2.7 3.9 2:06.34 node
1303 alrisha 20 0 817108 74448 38352 S 0.3 0.5 0:12.21 node
1 root 20 0 166272 11476 8128 S 0.0 0.1 0:00.58 systemd
...

此时,进程 10136 占用的最多的 CPU 资源。

接下来,通过 top -H -p 10136 查看该进程的线程情况。其中,-H 参数表示显示线程信息,-p 参数表示指定进程号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
top - 16:13:13 up  2:17,  0 users,  load average: 1.02, 1.02, 1.00
Threads: 23 total, 1 running, 22 sleeping, 0 stopped, 0 zombie
%Cpu(s): 5.0 us, 0.2 sy, 0.0 ni, 94.5 id, 0.0 wa, 0.0 hi, 0.2 si, 0.0 st
MiB Mem : 15828.4 total, 12196.8 free, 2146.5 used, 1485.1 buff/cache
MiB Swap: 4096.0 total, 4096.0 free, 0.0 used. 13355.1 avail Mem

进程号 USER PR NI VIRT RES SHR %CPU %MEM TIME+ COMMAND
10155 alrisha 20 0 7270604 71140 19584 R 99.9 0.4 74:41.01 Thread-0
10148 alrisha 20 0 7270604 71140 19584 S 0.3 0.4 0:00.35 Monitor Deflati
10136 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.00 java
10137 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.02 java
10138 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.00 GC Thread#0
10139 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.00 G1 Main Marker
10140 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.00 G1 Conc#0
10141 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.00 G1 Refine#0
10142 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.55 G1 Service
10143 alrisha 20 0 7270604 71140 19584 S 0.0 0.4 0:00.09 VM Thread
...

注意此时 top 显示的 进程号 实际上代表的是 线程 ID ,因此可以看到 Thread-0 占用了大量的 CPU 资源,其线程号为 10155,16 进制表示为 0x27ab

接下来,通过 jstack 命令查看线程 10155 的堆栈,然后找到线程 10155 有关的前 10 行堆栈信息。

1
jstack 10136 | grep -A 10 0x27ab

如果希望把全部堆栈信息输入一个 log 文件,可以使用 jstack 10136 > jstack.log 命令。

终端输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
alrisha@Aquarius:~$ jstack 10136 | grep -A 10 0x27ab
"Thread-0" #22 prio=5 os_prio=0 cpu=4957330.50ms elapsed=4954.81s tid=0x00007ff5a01be050 nid=0x27ab runnable [0x00007ff522dfb000]
java.lang.Thread.State: RUNNABLE
at edu.bupt.App.lambda$main$0(App.java:20)
at edu.bupt.App$$Lambda$1/0x00007ff524000a08.run(Unknown Source)
at java.lang.Thread.run(java.base@17.0.10/Thread.java:840)

"Thread-1" #23 prio=5 os_prio=0 cpu=0.33ms elapsed=4954.81s tid=0x00007ff5a01bf1d0 nid=0x27ac waiting on condition [0x00007ff522cfb000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@17.0.10/Native Method)
- parking to wait for <0x000000071801b318> (a java.util.concurrent.CountDownLatch$Sync)
at java.util.concurrent.locks.LockSupport.park(java.base@17.0.10/LockSupport.java:211)
alrisha@Aquarius:~$

可以看到,在示例代码的第 20 行,Thread-0 正在执行一个死循环。至此,定位完毕。