Java性能之線程上下文切換究極解析

線程數量

  1. 在併發程序中,並不是啟動更多的線程就能讓程序最大限度地併發執行
  2. 線程數量設置太小,會導致程序不能充分地利用系統資源
  3. 線程數量設置太大,可能帶來資源的過度競爭,導致上下文切換,帶來的額外的系統開銷
Java性能之線程上下文切換究極解析

上下文切換

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、線程暫停)、線程執行優先級
Java性能之線程上下文切換究極解析

監控切換

樣例代碼

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:系統的上下文切換頻率

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

-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.
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. 多線程對鎖資源的競爭會引起上下文切換,鎖競爭導致的線程阻塞越多,上下文切換就越頻繁,系統的性能開銷就越大
  • 在多線程編程中,鎖本身不是性能開銷的根源,鎖競爭才是性能開銷的根源
  1. 鎖優化歸根到底是減少競爭

減少鎖的持有時間

  1. 鎖的持有時間越長,意味著越多的線程在等待該競爭鎖釋放
  2. 如果是synchronized同步鎖資源,不僅帶來了線程間的上下文切換,還有可能會帶來進程間的上下文切換
  3. 優化方法:將一些與鎖無關的代碼移出同步代碼塊,尤其是那些開銷較大的操作以及可能被阻塞的操作

減少鎖粒度

鎖分離

  1. 讀寫鎖實現了鎖分離,由讀鎖和寫鎖兩個鎖實現,可以共享讀,但只有一個寫
  • 讀寫鎖在多線程讀寫時,
    讀讀不互斥,讀寫互斥,寫寫互斥
  • 傳統的獨佔鎖在多線程讀寫時,讀讀互斥,讀寫互斥,寫寫互斥
  1. 讀遠大於寫的多線程場景中,鎖分離避免了高併發讀情況下的資源競爭,從而避免了上下文切換

鎖分段

  1. 在使用鎖來保證集合或者大對象的原子性時,可以將鎖對象進一步分解
  2. Java 1.8之前的ConcurrentHashMap就是用了鎖分段

非阻塞樂觀鎖代替競爭鎖

  1. volatile
  • volatile關鍵字的作用是保證可見性有序性,volatile的讀寫操作不會導致上下文切換開銷較小
  • 由於volatile關鍵字沒有鎖的排它性,因此不能保證操作變量的原子性
  1. 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來實現線程間的通信,例如生產者-消費者模型

public class WaitNotifyTest {
 public static void main(String[] args) {
 Vector 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 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 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. 很多垃圾回收器在回收舊對象時會產生內存碎片,從而需要進行內存整理,該過程需要移動存活的對象
  • 而移動存活的對象意味著這些對象的內存地址會發生改變,因此在移動對象之前需要暫停線程,完成後再喚醒線程
  1. 因此減少GC的頻率能夠有效的減少上下文切換


分享到:


相關文章: