高並發編程學習(1)——並發基礎

  • 2019 年 11 月 29 日
  • 筆記

為更良好的閱讀體驗,請訪問原文:傳送門

一、前言


當我們使用電腦時,可以同時做許多事情,例如一邊打遊戲一邊聽音樂。這是因為作業系統支援並發任務,從而使得這些工作得以同時進行。

  • 那麼提出一個問題:如果我們要實現一個程式能一邊聽音樂一邊玩遊戲怎麼實現呢?
public class Tester {        public static void main(String[] args) {          System.out.println("開始....");          playGame();          playMusic();          System.out.println("結束....");      }        private static void playGame() {          for (int i = 0; i < 50; i++) {              System.out.println("玩遊戲" + i);          }      }        private static void playMusic() {          for (int i = 0; i < 50; i++) {              System.out.println("播放音樂" + i);          }      }  }

我們使用了循環來模擬過程,因為播放音樂和打遊戲都是連續的,但是結果卻不盡人意,因為函數體總是要執行完之後才能返回。那麼到底怎麼解決這個問題?

並行與並發

並行性和並發性是既相似又有區別的兩個概念。

並行性是指兩個或多個事件在同一時刻發生。而並發性是指兩個或多個事件在同一時間間隔內發生。

在多道程式環境下,並發性是指在一段時間內宏觀上有多個程式在同時運行,但在單處理機環境下(一個處理器),每一時刻卻僅能有一道程式執行,故微觀上這些程式只能是分時地交替執行。例如,在 1 秒鐘時間內,0 – 15 ms 程式 A 運行;15 – 30 ms 程式 B 運行;30 – 45 ms 程式 C 運行;45 – 60 ms 程式 D 運行,因此可以說,在 1 秒鐘時間間隔內,宏觀上有四道程式在同時運行,但微觀上,程式 A、B、C、D 是分時地交替執行的。

如果在電腦系統中有多個處理機,這些可以並發執行的程式就可以被分配到多個處理機上,實現並發執行,即利用每個處理機愛處理一個可並發執行的程式。這樣,多個程式便可以同時執行。以此就能提高系統中的資源利用率,增加系統的吞吐量。

進程和執行緒

進程是指一個記憶體中運行的應用程式。一個應用程式可以同時啟動多個進程,那麼上面的問題就有了解決的思路:我們啟動兩個進程,一個用來打遊戲,一個用來播放音樂。這當然是一種解決方案,但是想像一下,如果一個應用程式需要執行的任務非常多,例如 LOL 遊戲吧,光是需要播放的音樂就有非常多,人物本身的語音,技能的音效,遊戲的背景音樂,塔攻擊的聲音等等等,還不用說電競筆電身,就光播放音樂就需要創建許多許多的進程,而進程本身是一種非常消耗資源的東西,這樣的設計顯然是不合理的。更何況大多數的作業系統都不需要一個進程訪問其他進程的記憶體空間,也就是說,進程之間的通訊很不方便,此時我們就得引入「執行緒」這門技術,來解決這個問題。

執行緒是指進程中的一個執行任務(控制單元),一個進程可以同時並發運行多個執行緒。我們可以打開任務管理器,觀察到幾乎所有的進程都擁有著許多的「執行緒」(在 WINDOWS 中執行緒是默認隱藏的,需要在「查看」裡面點擊「選擇列」,有一個執行緒數的勾選項,找到並勾選就可以了)。

進程和執行緒的區別

進程:有獨立的記憶體空間,進程中的數據存放空間(堆空間和棧空間)是獨立的,至少有一個執行緒。

執行緒:堆空間是共享的,棧空間是獨立的,執行緒消耗的資源也比進程小,相互之間可以影響的,又稱為輕型進程或進程元。

因為一個進程中的多個執行緒是並發運行的,那麼從微觀角度上考慮也是有先後順序的,那麼哪個執行緒執行完全取決於 CPU 調度器(JVM 來調度),程式設計師是控制不了的。我們可以把多執行緒並發性看作是多個執行緒在瞬間搶 CPU 資源,誰搶到資源誰就運行,這也造就了多執行緒的隨機性。下面我們將看到更生動的例子。

Java 程式的進程(Java 的一個程式運行在系統中)里至少包含主執行緒和垃圾回收執行緒(後台執行緒),你可以簡單的這樣認為,但實際上有四個執行緒(了解就好):

  • [1] main——main 執行緒,用戶程式入口
  • [2] Reference Handler——清除 Reference 的執行緒
  • [3] Finalizer——調用對象 finalize 方法的執行緒
  • [4] Signal Dispatcher——分發處理髮送給 JVM 訊號的執行緒

多執行緒和單執行緒的區別和聯繫?

  1. 單核 CPU 中,將 CPU 分為很小的時間片,在每一時刻只能有一個執行緒在執行,是一種微觀上輪流佔用 CPU 的機制。
  2. 多執行緒會存在執行緒上下文切換,會導致程式執行速度變慢,即採用一個擁有兩個執行緒的進程執行所需要的時間比一個執行緒的進程執行兩次所需要的時間要多一些。

結論:即採用多執行緒不會提高程式的執行速度,反而會降低速度,但是對於用戶來說,可以減少用戶的響應時間。

多執行緒的優勢

儘管面臨很多挑戰,多執行緒有一些優點仍然使得它一直被使用,而這些優點我們應該了解。

優勢一:資源利用率更好

想像一下,一個應用程式需要從本地文件系統中讀取和處理文件的情景。比方說,從磁碟讀取一個文件需要 5 秒,處理一個文件需要 2 秒。處理兩個文件則需要:

1| 5秒讀取文件A  2| 2秒處理文件A  3| 5秒讀取文件B  4| 2秒處理文件B  5| ---------------------  6| 總共需要14秒

從磁碟中讀取文件的時候,大部分的 CPU 時間用於等待磁碟去讀取數據。在這段時間裡,CPU 非常的空閑。它可以做一些別的事情。通過改變操作的順序,就能夠更好的使用 CPU 資源。看下面的順序:

1| 5秒讀取文件A  2| 5秒讀取文件B + 2秒處理文件A  3| 2秒處理文件B  4| ---------------------  5| 總共需要12秒

CPU 等待第一個文件被讀取完。然後開始讀取第二個文件。當第二文件在被讀取的時候,CPU 會去處理第一個文件。記住,在等待磁碟讀取文件的時候,CPU 大部分時間是空閑的。

總的說來,CPU 能夠在等待 IO 的時候做一些其他的事情。這個不一定就是磁碟 IO。它也可以是網路的 IO,或者用戶輸入。通常情況下,網路和磁碟的 IO 比 CPU 和記憶體的 IO 慢的多。

優勢二:程式設計在某些情況下更簡單

在單執行緒應用程式中,如果你想編寫程式手動處理上面所提到的讀取和處理的順序,你必須記錄每個文件讀取和處理的狀態。相反,你可以啟動兩個執行緒,每個執行緒處理一個文件的讀取和操作。執行緒會在等待磁碟讀取文件的過程中被阻塞。在等待的時候,其他的執行緒能夠使用 CPU 去處理已經讀取完的文件。其結果就是,磁碟總是在繁忙地讀取不同的文件到記憶體中。這會帶來磁碟和 CPU 利用率的提升。而且每個執行緒只需要記錄一個文件,因此這種方式也很容易編程實現。

優勢三:程式響應更快

有時我們會編寫一些較為複雜的程式碼(這裡的複雜不是說複雜的演算法,而是複雜的業務邏輯),例如,一筆訂單的創建,它包括插入訂單數據、生成訂單趕快找、發送郵件通知賣家和記錄貨品銷售數量等。用戶從單擊「訂購」按鈕開始,就要等待這些操作全部完成才能看到訂購成功的結果。但是這麼多業務操作,如何能夠讓其更快地完成呢?

在上面的場景中,可以使用多執行緒技術,即將數據一致性不強的操作派發給其他執行緒處理(也可以使用消息隊列),如生成訂單快照、發送郵件等。這樣做的好處是響應用戶請求的執行緒能夠儘可能快地處理完成,縮短了響應時間,提升了用戶體驗。

其他優勢

多執行緒還有一些優勢也顯而易見:

  • 進程之前不能共享記憶體,而執行緒之間共享記憶體(堆記憶體)則很簡單。
  • 系統創建進程時需要為該進程重新分配系統資源,創建執行緒則代價小很多,因此實現多任務並發時,多執行緒效率更高.
  • Java 語言本身內置多執行緒功能的支援,而不是單純地作為底層系統的調度方式,從而簡化了多執行緒編程.

上下文切換

即使是單核處理器也支援多執行緒執行程式碼,CPU 通過給每個執行緒分配 CPU 時間片來實現這個機制。時間片是 CPU 分配給各個執行緒的時間,因為時間片非常短,所以 CPU 通過不停地切換執行緒執行,讓我們感覺多個執行緒是同時執行的,時間片一般是幾十毫秒(ms)。

CPU 通過時間片分配演算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務。但是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務的時候,可以再載入這個任務的狀態。所以任務從保存到再載入的過程就是一次上下文切換。

這就像我們同時讀兩本書,當我們在讀一本英文的技術書時,發現某個單詞不認識,於是打開中英文字典,但是在放下英文技術書之前,大腦必須先記住這本書獨到了多少頁的多少行,等查完單詞之後,能夠繼續讀這本書。這樣的切換是會影響讀書效率的,同樣上下文切換也會影響多執行緒的執行速度。

二、創建執行緒的兩種方式


繼承 Thread 類

public class Tester {        // 播放音樂的執行緒類      static class PlayMusicThread extends Thread {            // 播放時間,用循環來模擬播放的過程          private int playTime = 50;            public void run() {              for (int i = 0; i < playTime; i++) {                  System.out.println("播放音樂" + i);              }          }      }        // 方式1:繼承 Thread 類      public static void main(String[] args) {          // 主執行緒:運行遊戲          for (int i = 0; i < 50; i++) {              System.out.println("打遊戲" + i);              if (i == 10) {                  // 創建播放音樂執行緒                  PlayMusicThread musicThread = new PlayMusicThread();                  musicThread.start();              }          }      }  }

運行結果發現打遊戲和播放音樂交替出現,說明已經成功了。

實現 Runnable 介面

public class Tester {        // 播放音樂的執行緒類      static class PlayMusicThread implements Runnable {            // 播放時間,用循環來模擬播放的過程          private int playTime = 50;            public void run() {              for (int i = 0; i < playTime; i++) {                  System.out.println("播放音樂" + i);              }          }      }        // 方式2:實現 Runnable 方法      public static void main(String[] args) {          // 主執行緒:運行遊戲          for (int i = 0; i < 50; i++) {              System.out.println("打遊戲" + i);              if (i == 10) {                  // 創建播放音樂執行緒                  Thread musicThread = new Thread(new PlayMusicThread());                  musicThread.start();              }          }      }  }

也能完成效果。

以上就是傳統的兩種創建執行緒的方式,事實上還有第三種,我們後邊再講。

多執行緒一定快嗎?

先來一段程式碼,通過並行和串列來分別執行累加操作,分析:下面的程式碼並發執行一定比串列執行快嗎?

import org.springframework.util.StopWatch;    // 比較並行和串列執行累加操作的速度  public class Tester {        // 執行次數      private static final long COUNT = 100000000;      private static final StopWatch TIMER = new StopWatch();        public static void main(String[] args) throws InterruptedException {          concurrency();          serial();          // 列印比較測試結果          System.out.println(TIMER.prettyPrint());      }        private static void serial() {          TIMER.start("串列執行" + COUNT + "條數據");            int a = 0;          for (long i = 0; i < COUNT; i++) {              a += 5;          }          // 串列執行          int b = 0;          for (long i = 0; i < COUNT; i++) {              b--;          }            TIMER.stop();      }        private static void concurrency() throws InterruptedException {          TIMER.start("並行執行" + COUNT + "條數據");            // 通過匿名內部類來創建執行緒          Thread thread = new Thread(() -> {              int a = 0;              for (long i = 0; i < COUNT; i++) {                  a += 5;              }          });          thread.start();            // 並行執行          int b = 0;          for (long i = 0; i < COUNT; i++) {              b--;          }          // 等待執行緒結束          thread.join();          TIMER.stop();      }  }

大家可以自己測試一下,每一台機器 CPU 不同測試結果可能也會不同,之前在 WINDOWS 本兒上測試的時候,多執行緒的優勢從 1 千萬數據的時候才開始體現出來,但是現在換了 MAC,1 億條數據時間也差不多,到 10 億的時候明顯串列就比並行快了… 總之,為什麼並發執行的速度會比串列慢呢?就是因為執行緒有創建和上下文切換的開銷。

繼承 Thread 類還是實現 Runnable 介面?

想像一個這樣的例子:給出一共 50 個蘋果,讓三個同學一起來吃,並且給蘋果編上號碼,讓他們吃的時候順便要說出蘋果的編號:

運行結果可以看到,使用繼承方式實現,每一個執行緒都吃了 50 個蘋果。這樣的結果顯而易見:是因為顯式地創建了三個不同的 Person 對象,而每個對象在堆空間中有獨立的區域來保存定義好的 50 個蘋果。

而使用實現方式則滿足要求,這是因為三個執行緒共享了同一個 Apple 對象,而對象中的 num 數量是一定的。

所以可以簡單總結出繼承方式和實現方式的區別:

繼承方式:

  1. Java 中類是單繼承的,如果繼承了 Thread 了,該類就不能再有其他的直接父類了;
  2. 從操作上分析,繼承方式更簡單,獲取執行緒名字也簡單..(操作上,更簡單)
  3. 從多執行緒共享同一個資源上分析,繼承方式不能做到…

實現方式:

  1. Java 中類可以實現多個介面,此時該類還可以繼承其他類,並且還可以實現其他介面(設計上,更優雅)..
  2. 從操作上分析,實現方式稍微複雜點,獲取執行緒名字也比較複雜,需要使用 Thread.currentThread() 來獲取當前執行緒的引用..
  3. 從多執行緒共享同一個資源上分析,實現方式可以做到..

在這裡,三個同學完成搶蘋果的例子,使用實現方式才是更合理的方式。

對於這兩種方式哪種好並沒有一個確定的答案,它們都能滿足要求。就我個人意見,我更傾向於實現 Runnable 介面這種方法。因為執行緒池可以有效的管理實現了 Runnable 介面的執行緒,如果執行緒池滿了,新的執行緒就會排隊等候執行,直到執行緒池空閑出來為止。而如果執行緒是通過實現 Thread 子類實現的,這將會複雜一些。

有時我們要同時融合實現 Runnable 介面和 Thread 子類兩種方式。例如,實現了 Thread 子類的實例可以執行多個實現了 Runnable 介面的執行緒。一個典型的應用就是執行緒池。

常見錯誤:調用 run() 方法而非 start() 方法

創建並運行一個執行緒所犯的常見錯誤是調用執行緒的 run() 方法而非 start() 方法,如下所示:

1| Thread newThread = new Thread(MyRunnable());  2| newThread.run();  //should be start();

起初你並不會感覺到有什麼不妥,因為 run() 方法的確如你所願的被調用了。但是,事實上,run() 方法並非是由剛創建的新執行緒所執行的,而是被創建新執行緒的當前執行緒所執行了。也就是被執行上面兩行程式碼的執行緒所執行的。想要讓創建的新執行緒執行 run() 方法,必須調用新執行緒的 start() 方法。

三、執行緒的安全問題


吃蘋果遊戲的不安全問題

我們來考慮一下上面吃蘋果的例子,會有什麼問題?

儘管,Java 並不保證執行緒的順序執行,具有隨機性,但吃蘋果比賽的案例運行多次也並沒有發現什麼太大的問題。這並不是因為程式沒有問題,而只是問題出現的不夠明顯,為了讓問題更加明顯,我們使用 Thread.sleep() 方法(經常用來模擬網路延遲)來讓執行緒休息 10 ms,讓其他執行緒去搶資源。(注意:在程式中並不是使用 Thread.sleep(10)之後,程式才出現問題,而是使用之後,問題更明顯.)

為什麼會出現這樣的錯誤呢?

先來分析第一種錯誤:為什麼會吃重複的蘋果呢?就拿 B 和 C 都吃了編號為 47 的蘋果為例吧:

  • A 執行緒拿到了編號為 48 的蘋果,列印輸出然後讓 num 減 1,睡眠 10 ms,此時 num 為 47。
  • 這時 B 和 C 同時都拿到了編號為 47 的蘋果,列印輸出,在其中一個執行緒作出了減一操作的時候,A 執行緒從睡眠中醒過來,拿到了編號為 46 的蘋果,然後輸出。在這期間並沒有任何操作不允許 B 和 C 執行緒不能拿到同一個編號的蘋果,之前沒有明顯的錯誤僅僅可能只是因為運行速度太快了。

再來分析第二種錯誤:照理來說只應該存在 1-50 編號的蘋果,可是 0 和-1 是怎麼出現的呢?

  • 當 num = 1 的時候,A,B,C 三個執行緒同時進入了 try 語句進行睡眠。
  • C 執行緒先醒過來,輸出了編號為 1 的蘋果,然後讓 num 減一,當 C 執行緒醒過來的時候發現 num 為 0 了。
  • A 執行緒醒過來一看,0 都沒有了,只有 -1 了。

歸根結底是因為沒有任何操作來限制執行緒來獲取相同的資源並對他們進行操作,這就造成了執行緒安全性問題。

如果我們把列印和減一的操作分成兩個步驟,會更加明顯:

ABC 三個執行緒同時列印了 50 的蘋果,然後同時做出減一操作。

像這樣的原子操作,是不允許分步驟進行的,必須保證同步進行,不然可能會引發不可設想的後果。

要解決上述多執行緒並發訪問一個資源的安全性問題,就需要引入執行緒同步的概念。

執行緒同步

多個執行執行緒共享一個資源的情景,是最常見的並發編程情景之一。為了解決訪問共享資源錯誤或數據不一致的問題,人們引入了臨界區的概念:用以訪問共享資源的程式碼塊,這個程式碼塊在同一時間內只允許一個執行緒執行。

為了幫助編程人員實現這個臨界區,Java(以及大多數程式語言)提供了同步機制,當一個執行緒試圖訪問一個臨界區時,它將使用一種同步機制來查看是不是已經有其他執行緒進入臨界區。如果沒有其他執行緒進入臨界區,他就可以進入臨界區。如果已經有執行緒進入了臨界區,它就被同步機制掛起,直到進入的執行緒離開這個臨界區。如果在等待進入臨界區的執行緒不止一個,JVM 會選擇其中的一個,其餘的將繼續等待。

synchronized 關鍵字

如果一個對象已用 synchronized 關鍵字聲明,那麼只有一個執行執行緒被允許訪問它。使用 synchronized 的好處顯而易見:保證了多執行緒並發訪問時的同步操作,避免執行緒的安全性問題。但是壞處是:使用 synchronized 的方法/程式碼塊的性能比不用要低一些。所以好的做法是:盡量減小 synchronized 的作用域。

我們還是先來解決吃蘋果的問題,考慮一下 synchronized 關鍵字應該加在哪裡呢?

發現如果還再把 synchronized 關鍵字加在 if 裡面的話,0 和 -1 又會出來了。這其實是因為當 ABC 同是進入到 if 語句中,等待臨界區釋放的時,拿到 1 編號的執行緒已經又把 num 減一操作了,而此時最後一個等待臨界區的進程拿到的就會是 -1 了。

同步鎖 Lock

Lock 機制提供了比 synchronized 程式碼塊和 synchronized 方法更廣泛的鎖定操作,同步程式碼塊/ 同步方法具有的功能 Lock 都有,除此之外更強大,更體現面向對象。在並發包的類族中,Lock 是 JUC 包的頂層介面,它的實現邏輯並未用到 synchronized,而是利用了 volatile 的可見性。

使用 Lock 最典型的程式碼如下:

class X {        private final ReentrantLock lock = new ReentrantLock();        public void m() {          lock.lock();          try {              // ..... method body          } finally {              lock.unlock();          }      }  }

執行緒安全問題

執行緒安全問題只在多執行緒環境下才會出現,單執行緒串列執行不存在此類問題。保證高並發場景下的執行緒安全,可以從以下四個維度考量:

維度一:數據單執行緒可見

單執行緒總是安全的。通過限制數據僅在單執行緒內可見,可以避免數據被其他執行緒篡改。最典型的就是執行緒局部變數,它存儲在獨立虛擬機棧幀的局部變數表中,與其他執行緒毫無瓜葛。TreadLocal 就是採用這種方式來實現執行緒安全的。

維度二:只讀對象

只讀對象總是安全的。它的特性是允許複製、拒絕寫入。最典型的只讀對象有 String、Integer 等。一個對象想要拒絕任何寫入,必須要滿足以下條件:

  • 使用 final 關鍵字修飾類,避免被繼承;
  • 使用 private final 關鍵字避免屬性被中途修改;
  • 沒有任何更新方法;
  • 返回值不能為可變對象。

維度三:執行緒安全類

某些執行緒安全類的內部有非常明確的執行緒安全機制。比如 StringBuffer 就是一個執行緒安全類,它採用 synchronized 關鍵字來修飾相關方法。

維度四:同步與鎖機制

如果想要對某個對象進行並發更新操作,但又不屬於上述三類,需要開發工程師在程式碼中實現安全的同步機制。雖然這個機制支援的並發場景很有價值,但非常複雜且容易出現問題。

處理執行緒安全的核心理念

要麼只讀,要麼加鎖。

合理利用好 JDK 提供的並發包,往往能化腐朽為神奇。Java 並發包(java.util.concurrent,JUC)中大多數類注釋都寫有:@author Doug Lea。如果說 Java 是一本史書,那麼 Doug Lea 絕對是開疆拓土的偉大人物。Doug Lea 在當大學老師時,專攻並發編程和並發數據結構設計,主導設計了 JUC 並發包,提高了 Java 並發編程的易用性,大大推進了 Java 的商用進程。