Java性能 -- 线程上下文切换

线程数量

  1. 在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行
  2. 线程数量设置太小,会导致程序不能充分地利用系统资源
  3. 线程数量设置太大,可能带来资源的过度竞争,导致上下文切换,带来的额外的系统开销

上下文切换

  1. 在单处理器时期,操作系统就能处理多线程并发任务,处理器给每个线程分配CPU时间片,线程在CPU时间片内执行任务
    • CPU时间片是CPU分配给每个线程执行的时间段,一般为几十毫秒
  2. 时间片决定了一个线程可以连续占用处理器运行的时长
    • 当一个线程的时间片用完,或者因自身原因被迫暂停运行,此时另一个线程会被操作系统选中来占用处理器
    • 上下文切换(Context Switch):一个线程被暂停剥夺使用权,另一个线程被选中开始或者继续运行的过程
      • 切出:一个线程被剥夺处理器的使用权而被暂停运行
      • 切入:一个线程被选中占用处理器开始运行或者继续运行
      • 切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是上下文
  3. 上下文的内容
    • 寄存器的存储内容:CPU寄存器负责存储已经、正在和将要执行的任务
    • 程序计数器存储的指令内容:程序计数器负责存储CPU正在执行的指令位置、即将执行的下一条指令的位置
  4. 当CPU数量远远不止1个的情况下,操作系统将CPU轮流分配给线程任务,此时的上下文切换会变得更加频繁
    • 并且存在跨CPU的上下文切换,更加昂贵

切换诱因

  1. 在操作系统中,上下文切换的类型可以分为进程间的上下文切换和线程间的上下文切换
  2. 线程状态:NEW、RUNNABLE、RUNNING、BLOCKED、DEAD
    • Java线程状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
  3. 线程上下文切换:RUNNING -> BLOCKED -> RUNNABLE -> 被调度器选中执行
    • 一个线程从RUNNING状态转为BLOCKED状态,称为一个线程的暂停
      • 线程暂停被切出后,操作系统会保存相应的上下文
      • 以便该线程再次进入RUNNABLE状态时能够在之前执行进度的基础上继续执行
    • 一个线程从BLOCKED状态进入RUNNABLE状态,称为一个线程的唤醒
      • 此时线程将获取上次保存的上下文继续执行
  4. 诱因:程序本身触发的自发性上下文切换、系统或虚拟机触发的非自发性上下文切换
    • 自发性上下文切换
      • sleep、wait、yield、join、park、synchronized、lock
    • 非自发性上下文切换
      • 线程被分配的时间片用完JVM垃圾回收STW、线程暂停)、线程执行优先级

监控切换

样例代码

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
public static void main(String[] args) {
new MultiThreadTesterAbstract().start();
new SerialThreadTesterAbstract().start();
// multi thread take 5401ms
// serial take 692ms
}

static abstract class AbstractTheadContextSwitchTester {
static final int COUNT = 100_000_000;
volatile int counter = 0;

void increaseCounter() {
counter++;
}

public abstract void start();
}

static class MultiThreadTesterAbstract extends AbstractTheadContextSwitchTester {

@Override
public void start() {
Stopwatch stopwatch = Stopwatch.createStarted();
Thread[] threads = new Thread[4];
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
while (counter < COUNT) {
synchronized (this) {
if (counter < COUNT) {
increaseCounter();
}
}
}
}
});
threads[i].start();
}
for (int i = 0; i < 4; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("multi thread take {}ms", stopwatch.elapsed(TimeUnit.MILLISECONDS));
}
}

static class SerialThreadTesterAbstract extends AbstractTheadContextSwitchTester {

@Override
public void start() {
Stopwatch stopwatch = Stopwatch.createStarted();
for (int i = 0; i < COUNT; i++) {
increaseCounter();
}
log.info("serial take {}ms", stopwatch.elapsed(TimeUnit.MILLISECONDS));
}
}
  1. 串行的执行速度比并发执行的速度要快,因为线程的上下文切换导致了额外的开销
    • 使用synchronized关键字,导致了资源竞争,从而引起了上下文切换
    • 即使不使用synchronized关键字,并发的执行速度也无法超越串行的执行速度,因为多线程同样存在上下文切换
  2. Redis的设计很好地体现了单线程串行的优势
    • 内存中快速读取值,不用考虑IO瓶颈带来的阻塞问题

监控工具

vmstat

cs:系统的上下文切换频率

1
2
3
4
root@5d15480e8112:/# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 693416 33588 951508 0 0 77 154 116 253 1 1 98 0 0

pidstat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-w  Report task switching activity (kernels 2.6.23 and later only).  The following values may be displayed:
UID
The real user identification number of the task being monitored.
USER
The name of the real user owning the task being monitored.
PID
The identification number of the task being monitored.
cswch/s
Total number of voluntary context switches the task made per second. A voluntary context switch occurs when a task blocks because it requires a
resource that is unavailable.
nvcswch/s
Total number of non voluntary context switches the task made per second. A involuntary context switch takes place when a task executes for the
duration of its time slice and then is forced to relinquish the processor.
Command
The command name of the task.
1
2
3
4
5
6
7
8
9
10
root@5d15480e8112:/# pidstat -w -l -p 1 2 5
Linux 4.9.184-linuxkit (5d15480e8112) 09/16/2019 _x86_64_ (2 CPU)

07:28:03 UID PID cswch/s nvcswch/s Command
07:28:05 0 1 0.00 0.00 /bin/bash
07:28:07 0 1 0.00 0.00 /bin/bash
07:28:09 0 1 0.00 0.00 /bin/bash
07:28:11 0 1 0.00 0.00 /bin/bash
07:28:13 0 1 0.00 0.00 /bin/bash
Average: 0 1 0.00 0.00 /bin/bash

切换的系统开销

  1. 操作系统保存和恢复上下文
  2. 调度器进行线程调度
  3. 处理器高速缓存重新加载
  4. 可能导致整个高速缓存区被冲刷,从而带来时间开销

竞争锁优化

  1. 多线程对锁资源的竞争会引起上下文切换,锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销就越大
    • 在多线程编程中,锁本身不是性能开销的根源,锁竞争才是性能开销的根源
  2. 锁优化归根到底是减少竞争

减少锁的持有时间

  1. 锁的持有时间越长,意味着越多的线程在等待该竞争锁释放
  2. 如果是synchronized同步锁资源,不仅带来了线程间的上下文切换,还有可能会带来进程间的上下文切换
  3. 优化方法:将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作

减少锁粒度

锁分离

  1. 读写锁实现了锁分离,由读锁和写锁两个锁实现,可以共享读,但只有一个写
    • 读写锁在多线程读写时,读读不互斥,读写互斥,写写互斥
    • 传统的独占锁在多线程读写时,读读互斥,读写互斥,写写互斥
  2. 读远大于写的多线程场景中,锁分离避免了高并发读情况下的资源竞争,从而避免了上下文切换

锁分段

  1. 在使用锁来保证集合或者大对象的原子性时,可以将锁对象进一步分解
  2. Java 1.8之前的ConcurrentHashMap就是用了锁分段

非阻塞乐观锁代替竞争锁

  1. volatile
    • volatile关键字的作用是保证可见性有序性,volatile的读写操作不会导致上下文切换开销较小
    • 由于volatile关键字没有锁的排它性,因此不能保证操作变量的原子性
  2. CAS
    • CAS是一个原子if-then-act操作
    • CAS是一个无锁算法实现,保障了对一个共享变量读写操作的一致性
    • CAS不会导致上下文切换,Java的Atomic包就使用了CAS算法来更新数据,而不需要额外加锁

synchronized锁优化

  1. 在JDK 1.6中,JVM将synchronized同步锁分为偏向锁、轻量级锁、自旋锁、重量级锁
  2. JIT编译器在动态编译同步代码块时,也会通过锁消除、锁粗化的方式来优化synchronized同步锁

wait/notify优化

可以通过Object对象的wait、notify、notifyAll来实现线程间的通信,例如生产者-消费者模型

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
public class WaitNotifyTest {
public static void main(String[] args) {
Vector<Integer> pool = new Vector<>();
Producer producer = new Producer(pool, 10);
Consumer consumer = new Consumer(pool);
new Thread(producer).start();
new Thread(consumer).start();
}
}

@AllArgsConstructor
class Producer implements Runnable {
private final Vector<Integer> pool;
private Integer size;

@Override
public void run() {
for (; ; ) {
try {
produce((int) System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

private void produce(int i) throws InterruptedException {
while (pool.size() == size) {
synchronized (pool) {
pool.wait();
}
}
synchronized (pool) {
pool.add(i);
pool.notifyAll();
}
}
}

@AllArgsConstructor
class Consumer implements Runnable {
private final Vector<Integer> pool;

@Override
public void run() {
for (; ; ) {
try {
consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

private void consume() throws InterruptedException {
synchronized (pool) {
while (pool.isEmpty()) {
pool.wait();
}
}
synchronized (pool) {
pool.remove(0);
pool.notifyAll();
}
}
}
  1. wait/notify的使用导致了较多的上下文切换
  2. 消费者第一次申请到锁,却发现没有内容可消费,执行wait,这会导致线程挂起,进入阻塞状态,这是一次上下文切换
  3. 当生产者获得锁并执行notifyAll之后,会唤醒处于阻塞状态的消费者线程,又会发生一次上下文切换
  4. 被唤醒的线程在继续运行时,需要再次申请相应对象的内部锁,此时可能需要与其他新来的活跃线程竞争,导致上下文切换
  5. 如果多个消费者线程同时被阻塞,用notifyAll将唤醒所有阻塞线程,但此时依然没有内容可消费
    • 因此过早地唤醒,也可能导致线程再次进入阻塞状态,从而引起不必要的上下文切换
  6. 优化方法
    • 可以考虑使用notify代替notifyAll,减少上下文切换
    • 生产者执行完notify/notifyAll之后,尽快释放内部锁,避免被唤醒的线程再次等待该内部锁
    • 为了避免长时间等待,使用wait(long),但线程无法区分其返回是由于等待超时还是被通知线程唤醒,增加上下文切换
    • 建议使用Lock+Condition代替synchronized+wait/notify/notifyAll,来实现等待通知

合理的线程池大小

  1. 线程池的线程数量不宜过大
  2. 一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换

协程:非阻塞等待

  1. 协程比线程更加轻量,相比于由操作系统内核管理的进程线程,协程完全由程序本身所控制,即在用户态执行
  2. 协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升

减少GC频率

  1. GC会导致上下文切换
  2. 很多垃圾回收器在回收旧对象时会产生内存碎片,从而需要进行内存整理,该过程需要移动存活的对象
    • 而移动存活的对象意味着这些对象的内存地址会发生改变,因此在移动对象之前需要暂停线程,完成后再唤醒线程
  3. 因此减少GC的频率能够有效的减少上下文切换
0%