Java服务监控分析及案例 - CPU篇

CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

而每一次的上下文切换过程都会给系统带来一定的资源和时间的开销,这也是我们需要关注上下文切换的原因。

在Linux中,按照特权等级会将进程运行空间分为用户态和内核态,而只有内核态可以访问所有资源(包括内存,寄存器等硬件设备)。

当切换上下文时,应用需要从用户态陷入到内核态去访问寄存器和程序计数器,从而保存当前上下文并读取接下来执行程序的上下文,这个过程会带来一定的资源消耗。

上下文切换类型

对于上下文切换,根据任务的不同分为如下几种类型:

  • 进程上下文切换
  • 线程上下文切换
  • 中断上下文切换
类型 是否切换地址空间 是否清空/恢复CPU缓存 是否切换栈和寄存器 开销级别
进程上下文切换 最大
线程上下文切换 中等
中断上下文切换 部分寄存器 最小

(同一进程中的)线程上下文切换会比进程上下文切换的开销和耗时更小,这也是多线程替代多进程的优势。

如何查看上下文切换情况

  • vmstat

通过vmstat查看系统整体的上下文切换情况,对于上下文切换,我们可以只关注图中标注的参数:

procs.r: 就绪队列的长度,其中存在的是Runnable状态的进程,等待CPU分配时间片运行。
procs.b: 不可中断状态的进程数
system.in: 每秒中断次数
system.cs: 每秒上下文切换次数

  • pidstat

查看进程上下文切换的情况

1
2
3
4
5
6
7
# 每隔5秒输出1组数据
$ pidstat -w 5
Linux 4.15.0 (ubuntu) 09/23/18 _x86_64_ (2 CPU)

08:18:26 UID PID cswch/s nvcswch/s Command
08:18:31 0 1 0.20 0.00 systemd
08:18:31 0 8 5.40 0.00 rcu_sched

查看线程上下文切换的情况

1
2
3
4
5
6
7
8
9
10
11
$ pidstat -w 1 -p 1 -t
Linux 6.1.55-0-virt (single-thread-example) 12/05/24 _x86_64_ (2 CPU)

03:04:50 UID TGID TID cswch/s nvcswch/s Command
03:04:51 0 1 - 0.00 0.00 java
03:04:51 0 - 1 0.00 0.00 |__java
03:04:51 0 - 7 0.98 0.00 |__java
03:04:51 0 - 8 0.98 0.00 |__VM Thread
03:04:51 0 - 9 0.00 0.00 |__Reference Handl
03:04:51 0 - 10 0.00 0.00 |__Finalizer
......

cswch 表示每秒自愿上下文切换(voluntary context switches)的次数。

  • 对于进程而言,指的是自愿上下文切换,是指进程无法获取所需资源,导致的上下文切换。 I/O、内存等系统资源不足时,就会发生自愿上下文切换。
  • 对于线程而言,指的是自发性上下文切换,具体含义会在后续章节中介绍。

nvcswch 表示每秒非自愿上下文切换(non voluntary context switches)的次数。

  • 对于进程而言,指的是非自愿上下文切换,则是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换。 大量进程都在争抢 CPU 时,就容易发生非自愿上下文切换。
  • 对于线程而言,指的是非自发性上下文切换,具体含义会在后续章节中介绍。

: 为了区分进程和线程的上下文切换,对于线程而言,命名为自发性/非自发性上下文切换,但实际上含义与自愿/非自愿上下文切换相同。

Java应用中哪些操作会触发上下文切换

在Java应用中,线程上下文切换是由于线程从RUNNABLE状态转为Non-Runnable状态,或是从Non-Runnable状态转为Runnable状态触发的。

而导致状态切换的操作分为两种:自发性上下文切换非自发性上下文切换

自发性上下文切换就是由于上图中的方法调用导致的,例如:sleep, wait, join, notify等,当然也包括锁的获取和释放,例如: lock和synchronized

非自发性上下文切换则是由于调度器的原因而被迫切出,例如:被分配的时间片用完,垃圾回收相关的stop-the-world事件。

多线程一定比单线程执行快吗?

多线程被视为提高程序性能的一种手段。通过并行处理,多线程可以充分利用多核处理器的能力,从而提高程序的响应速度和吞吐量。

然而,这并不意味着在所有情况下多线程都能比单线程快。我们可以通过一个简单的Java代码案例来分析这一现象。

案例

对于单线程/多线程实现案例,都使用相同的resource quota配置:

1
2
3
4
5
6
7
resources:
requests:
cpu: "500m"
memory: "500Mi"
limits:
cpu: "1"
memory: "1Gi"

单线程实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SingleThreadExample {
public static void main(String[] args) throws InterruptedException {
while (true) {
long start = System.currentTimeMillis();
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i;
}
long end = System.currentTimeMillis();
System.out.println("Single-threaded sum: " + sum + " in " + (end - start) + "ms");
Thread.sleep(1000L);
}
}
}

多线程实现

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
public class MultipleThreadExample {

public static void main(String[] args) throws InterruptedException {
while (true) {
AtomicInteger sum = new AtomicInteger(0);
int numberOfThreads = 5;
int range = 1000000; // 总范围
int chunkSize = range / numberOfThreads; // 每个线程处理的范围大小

long start = System.currentTimeMillis();

// 使用变量创建线程池
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);

// 提交任务到线程池
for (int i = 0; i < numberOfThreads; i++) {
final int startRange = i * chunkSize;
final int endRange = (i + 1) * chunkSize;

executor.submit(() -> {
for (int j = startRange; j < endRange; j++) {
sum.getAndAdd(j);
}
});
}

// 关闭线程池并等待所有任务完成
executor.shutdown();
while (!executor.isTerminated()) {
// 等待所有任务完成
}

long end = System.currentTimeMillis();
System.out.println("Multi-threaded sum: " + sum.get() + " in " + (end - start) + "ms");

Thread.sleep(1000L);
}
}
}

通过将代码部署到本地colima Kubernetes集群中进行测试。这里代码中加入while(true)循环是为了防止单次执行完毕后Pod直接转为Complete状态 (Complete状态无法采集一定时间的上下文切换数据)。

单线程实现:

多线程实现:

从上述截图可以明显看到多线程实现的耗时比单线程更长,多线程实现的上下文切换次数比单线程实现的更多。并且多线程实现在刚启动时耗时比单线程实现高出10倍以上,这是因为创建线程池和线程都会带来额外的开销。

所以说并不是所有情况下多线程实现都比单线程快,尤其是这种计算密集型的任务。

这就如同下图所示,工作人员同时处理多个任务时反而会陷入焦头烂额的状态,远不如一个一个任务执行时候来的轻松,效率高。

多少的上下文切换次数合理

这个数值其实取决于系统本身的 CPU 性能。如果系统的上下文切换次数比较稳定,那么从数百到一万以内,都应该算是正常的。但当上下文切换次数超过一万次,或者切换次数出现数量级的增长时,就很可能已经出现了性能问题。

合理设置线程池大小

日常工作中,常常会思考到底将线程池大小设置多大比较合理,如果将线程数设置过大,线程会频繁的争用有限的CPU时间片,导致更多的非自发性上下文切换。但如果线程数设置过小,则无法充分利用计算机资源。

实际上,设置线程池大小需要根据任务的类型来合理分配,而任务类型通常分为:

  • CPU密集型
  • I/O密集型
CPU密集型

CPU密集型型的任务主要的瓶颈在于计算能力,设置过多的线程并不会提升CPU的计算能力,反而增加了创建线程,销毁线程以及上下文切换的开销。

下面就通过一个案例说明如何配置线程池大小能够让应用处理CPU密集型任务更快。

案例

此处我们可以复用多线程一定比单线程执行快吗?的案例进行说明。

对于多线程实现的耗时计算,我除去了第一次构建线程池的长耗时进行平均值计算。

场景 平均耗时(ms) 平均cswch 平均nvcswch
单线程 28.5 1 0.2
线程池 (n=2) 38.5 4.48 6.97
线程池 (n=3) 54.75 6.53 4.98
线程池 (n=4) 63.25 7.87 6.27
线程池 (n=5) 73.25 8.68 9.48

根据表格所示的结果,对于CPU密集型的任务,增加线程数来并行处理任务并没有带来性能提升,反而会因为过多的上下文切换以及线程调度的开销导致耗时变长。

所以对于CPU密集型任务,如果想要性能最大化,我们要做的是增加CPU核心数并且设置和CPU核心数相等的线程数来处理任务。

I/O密集型

I/O密集型任务的主要时间花费在等待外部设备或者服务的响应,而在等在响应的这段时间内线程处于空闲状态,这个时候设置更多的线程数反而能带来性能提升的效果,

尽管线程切换(上下文切换)有一定的开销,但在I/O密集型任务中,这种开销相较于I/O操作的时间消耗是非常小的,可以忽略不计。因此,我们可以通过增加线程数来充分利用CPU和资源,而不会因为上下文切换显著降低性能。

下面就通过一个案例说明如何配置线程池大小能够让应用处理I/O密集型任务更快。

案例
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
public class IOIntensiveExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
while (true) {
// 并发 HTTP 请求任务
ExecutorService httpExecutor = Executors.newFixedThreadPool(5);
String[] urls = {
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/posts/2",
"https://jsonplaceholder.typicode.com/posts/3",
"https://jsonplaceholder.typicode.com/posts/4",
"https://jsonplaceholder.typicode.com/posts/5"
};

long start = System.currentTimeMillis();

// 使用 Future 来获取结果
for (String url : urls) {
Future<String> response = httpExecutor.submit(() -> fetchHttpData(url));
}

// 关闭线程池
httpExecutor.shutdown();
while (!httpExecutor.isTerminated()) {}

long end = System.currentTimeMillis();
System.out.println("Multi-threaded execution time: " + (end - start) + "ms");

Thread.sleep(1000L);
}
}

// 发起 HTTP 请求的任务
private static String fetchHttpData(String urlString) {
StringBuilder result = new StringBuilder();
try {
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");

try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
result.append(line);
}
}
} catch (IOException e) {
System.err.println("Failed to fetch HTTP data: " + e.getMessage());
}
return result.toString();
}
}

同样将上述Java代码部署到本地colima Kubernetes集群中进行监控和测试。

场景 平均耗时(ms) 平均cswch 平均nvcswch
单线程 290.5 5.26 3.19
线程池 (n=2) 218 4.49 20.54
线程池 (n=3) 300.5 5.68 23.31
线程池 (n=4) 321.5 8.15 15.92
线程池 (n=5) 364.5 9.24 17.75

根据以上表格所示,对于I/O密集型的任务,通常将线程数设置为CPU核心数的2倍可以得到更好的性能。