Java多線程調優上

初識上下文切換

我們首先得明白,上下文切換到底是什麼。

其實在單個處理器的時期,操作系統就能處理多線程併發任務。處理器給每個線程分配 CPU 時間片(Time Slice),線程在分配獲得的時間片內執行任務。

CPU 時間片是 CPU 分配給每個線程執行的時間段,一般爲幾十毫秒。在這麼短的時間內線程互相切換,我們根本感覺不到,所以看上去就好像是同時進行的一樣。

時間片決定了一個線程可以連續佔用處理器運行的時長。當一個線程的時間片用完了,或者因自身原因被迫暫停運行了,這個時候,另外一個線程(可以是同一個線程或者其它進程的線程)就會被操作系統選中,來佔用處理器。這種一個線程被暫停剝奪使用權,另外一個線程被選中開始或者繼續運行的過程就叫做上下文切換(Context Switch)。

具體來說,一個線程被剝奪處理器的使用權而被暫停運行,就是“切出”;一個線程被選中佔用處理器開始或者繼續運行,就是“切入”。在這種切出切入的過程中,操作系統需要保存和恢復相應的進度信息,這個進度信息就是“上下文”了。

那上下文都包括哪些內容呢?具體來說,它包括了寄存器的存儲內容以及程序計數器存儲的指令內容。CPU 寄存器負責存儲已經、正在和將要執行的任務,程序計數器負責存儲 CPU 正在執行的指令位置以及即將執行的下一條指令的位置。

在當前 CPU 數量遠遠不止一個的情況下,操作系統將 CPU 輪流分配給線程任務,此時的上下文切換就變得更加頻繁了,並且存在跨 CPU 上下文切換,比起單核上下文切換,跨核切換更加昂貴。

多線程上下文切換誘因
在操作系統中,上下文切換的類型還可以分爲進程間的上下文切換和線程間的上下文切換。而在多線程編程中,我們主要面對的就是線程間的上下文切換導致的性能問題,下面我們就重點看看究竟是什麼原因導致了多線程的上下文切換。開始之前,先看下系統線程的生命週期狀態。

結合圖示可知,線程主要有“新建”(NEW)、“就緒”(RUNNABLE)、“運行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五種狀態。到了 Java 層面它們都被映射爲了 NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINADTED 等 6 種狀態。

在這個運行過程中,線程由 RUNNABLE 轉爲非 RUNNABLE 的過程就是線程上下文切換。

一個線程的狀態由 RUNNING 轉爲 BLOCKED ,再由 BLOCKED 轉爲 RUNNABLE ,然後再被調度器選中執行,這就是一個上下文切換的過程。

當一個線程從 RUNNING 狀態轉爲 BLOCKED 狀態時,我們稱爲一個線程的暫停,線程暫停被切出之後,操作系統會保存相應的上下文,以便這個線程稍後再次進入 RUNNABLE 狀態時能夠在之前執行進度的基礎上繼續執行。

當一個線程從 BLOCKED 狀態進入到 RUNNABLE 狀態時,我們稱爲一個線程的喚醒,此時線程將獲取上次保存的上下文繼續完成執行。

通過線程的運行狀態以及狀態間的相互切換,我們可以瞭解到,多線程的上下文切換實際上就是由多線程兩個運行狀態的互相切換導致的。

那麼在線程運行時,線程狀態由 RUNNING 轉爲 BLOCKED 或者由 BLOCKED 轉爲 RUNNABLE,這又是什麼誘發的呢?

我們可以分兩種情況來分析,一種是程序本身觸發的切換,這種我們稱爲自發性上下文切換,另一種是由系統或者虛擬機誘發的非自發性上下文切換。

自發性上下文切換指線程由 Java 程序調用導致切出,在多線程編程中,執行調用以下方法或關鍵字,常常就會引發自發性上下文切換。
sleep()
wait()
yield()
join()
park()
synchronized
lock
非自發性上下文切換指線程由於調度器的原因被迫切出。常見的有:線程被分配的時間片用完,虛擬機垃圾回收導致或者執行優先級的問題導致。

這裏重點說下“虛擬機垃圾回收爲什麼會導致上下文切換”。在 Java 虛擬機中,對象的內存都是由虛擬機中的堆分配的,在程序運行過程中,新的對象將不斷被創建,如果舊的對象使用後不進行回收,堆內存將很快被耗盡。Java 虛擬機提供了一種回收機制,對創建後不再使用的對象進行回收,從而保證堆內存的可持續性分配。而這種垃圾回收機制的使用有可能會導致 stop-the-world 事件的發生,這其實就是一種線程暫停行爲。

發現上下文切換
我們總說上下文切換會帶來系統開銷,那它帶來的性能問題是不是真有這麼糟糕呢?我們又該怎麼去監測到上下文切換?上下文切換到底開銷在哪些環節?接下來我將給出一段代碼,來對比串聯執行和併發執行的速度,然後一一解答這些問題。

public class DemoApplication {
public static void main(String[] args) {
//運行多線程
MultiThreadTester test1 = new MultiThreadTester();
test1.Start();
//運行單線程
SerialTester test2 = new SerialTester();
test2.Start();
}

   static class MultiThreadTester extends ThreadContextSwitchTester {
          @Override
          public void Start() {
                 long start = System.currentTimeMillis();
                 MyRunnable myRunnable1 = new MyRunnable();
                 Thread[] threads = new Thread[4];
                 //創建多個線程
                 for (int i = 0; i < 4; i++) {
                       threads[i] = new Thread(myRunnable1);
                       threads[i].start();
                 }
                 for (int i = 0; i < 4; i++) {
                       try {
                              //等待一起運行完
                              threads[i].join();
                       } catch (InterruptedException e) {
                              // TODO Auto-generated catch block
                              e.printStackTrace();
                       }
                 }
                 long end = System.currentTimeMillis();
                 System.out.println("multi thread exce time: " + (end - start) + "s");
                 System.out.println("counter: " + counter);
          }
          // 創建一個實現Runnable的類
          class MyRunnable implements Runnable {
                 public void run() {
                       while (counter < 100000000) {
                              synchronized (this) {
                                     if(counter < 100000000) {
                                            increaseCounter();
                                     }

                              }
                       }
                 }
          }
   }

  //創建一個單線程
   static class SerialTester extends ThreadContextSwitchTester{
          @Override
          public void Start() {
                 long start = System.currentTimeMillis();
                 for (long i = 0; i < count; i++) {
                       increaseCounter();
                 }
                 long end = System.currentTimeMillis();
                 System.out.println("serial exec time: " + (end - start) + "s");
                 System.out.println("counter: " + counter);
          }
   }

   //父類
   static abstract class ThreadContextSwitchTester {
          public static final int count = 100000000;
          public volatile int counter = 0;
          public int getCount() {
                 return this.counter;
          }
          public void increaseCounter() {

                 this.counter += 1;
          }
          public abstract void Start();
   }

}

執行之後,看一下兩者的時間測試結果:

通過數據對比我們可以看到:串聯的執行速度比並發的執行速度要快。這就是因爲線程的上下文切換導致了額外的開銷,使用 Synchronized 鎖關鍵字,導致了資源競爭,從而引起了上下文切換,但即使不使用 Synchronized 鎖關鍵字,併發的執行速度也無法超越串聯的執行速度,這是因爲多線程同樣存在着上下文切換。Redis、NodeJS 的設計就很好地體現了單線程串行的優勢。

在 Linux 系統下,可以使用 Linux 內核提供的 vmstat 命令,來監視 Java 程序運行過程中系統的上下文切換頻率,cs 如下圖所示:

如果是監視某個應用的上下文切換,就可以使用 pidstat 命令監控指定進程的 Context Switch 上下文切換。

由於 Windows 沒有像 vmstat 這樣的工具,在 Windows 下,我們可以使用 Process Explorer,來查看程序執行時,線程間上下文切換的次數。
至於系統開銷具體發生在切換過程中的哪些具體環節,總結如下:
操作系統保存和恢復上下文;
調度器進行線程調度;
處理器高速緩存重新加載;
上下文切換也可能導致整個高速緩存區被沖刷,從而帶來時間開銷。

總結
上下文切換就是一個工作的線程被另外一個線程暫停,另外一個線程佔用了處理器開始執行任務的過程。系統和 Java 程序自發性以及非自發性的調用操作,就會導致上下文切換,從而帶來系統開銷。

線程越多,系統的運行速度不一定越快。那麼我們平時在併發量比較大的情況下,什麼時候用單線程,什麼時候用多線程呢?

一般在單個邏輯比較簡單,而且速度相對來非常快的情況下,我們可以使用單線程。例如,我們前面講到的 Redis,從內存中快速讀取值,不用考慮 I/O 瓶頸帶來的阻塞問題。而在邏輯相對來說很複雜的場景,等待時間相對較長又或者是需要大量計算的場景,我建議使用多線程來提高系統的整體性能。例如,NIO 時期的文件讀寫操作、圖像處理以及大數據分析等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章