計算機程式的思維邏輯 (83) – 併發總結

swiftma發表於2019-03-03

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (83) – 併發總結

65節82節,我們用了18篇文章討論併發,本節進行簡要總結。

多執行緒開發有兩個核心問題,一個是競爭,另一個是協作。競爭會出現執行緒安全問題,所以,本節首先總結執行緒安全的機制,然後是協作的機制。管理競爭和協作是複雜的,所以Java提供了更高層次的服務,比如併發容器類和非同步任務執行服務,我們也會進行總結。本節綱要如下:

  • 執行緒安全的機制
  • 執行緒的協作機制
  • 容器類
  • 任務執行服務

執行緒安全的機制

執行緒表示一條單獨的執行流,每個執行緒有自己的執行計數器,有自己的棧,但可以共享記憶體,共享記憶體是實現執行緒協作的基礎,但共享記憶體有兩個問題,競態條件和記憶體可見性,之前章節探討了解決這些問題的多種思路:

  • 使用synchronized
  • 使用顯式鎖
  • 使用volatile
  • 使用原子變數和CAS
  • 寫時複製
  • 使用ThreadLocal

synchronized

synchronized簡單易用,它只是一個關鍵字,大部分情況下,放到類的方法宣告上就可以了,既可以解決競態條件問題,也可以解決記憶體可見性問題。

需要理解的是,它保護的是物件,而不是程式碼,只有對同一個物件的synchronized方法呼叫,synchronized才能保證它們被順序呼叫。對於例項方法,這個物件是this,對於靜態方法,這個物件是類物件,對於程式碼塊,需要指定哪個物件。

另外,需要注意,它不能嘗試獲取鎖,也不響應中斷,還可能會死鎖。不過,相比顯式鎖,synchronized簡單易用,JVM也可以不斷優化它的實現,應該被優先使用。

顯式鎖

顯式鎖是相對於synchronized隱式鎖而言的,它可以實現synchronzied同樣的功能,但需要程式設計師自己建立鎖,呼叫鎖相關的介面,主要介面是Lock,主要實現類是ReentrantLock。

相比synchronized,顯式鎖支援以非阻塞方式獲取鎖、可以響應中斷、可以限時、可以指定公平性、可以解決死鎖問題,這使得它靈活的多。

在讀多寫少、讀操作可以完全並行的場景中,可以使用讀寫鎖以提高併發度,讀寫鎖的介面是ReadWriteLock,實現類是ReentrantReadWriteLock。

volatile

synchronized和顯式鎖都是鎖,使用鎖可以實現安全,但使用鎖是有成本的,獲取不到鎖的執行緒還需要等待,會有執行緒的上下文切換開銷等。保證安全不一定需要鎖。如果共享的物件只有一個,操作也只是進行最簡單的get/set操作,set也不依賴於之前的值,那就不存在競態條件問題,而只有記憶體可見性問題,這時,在變數的宣告上加上volatile就可以了。

原子變數和CAS

使用volatile,set的新值不能依賴於舊值,但很多時候,set的新值與原來的值有關,這時,也不一定需要鎖,如果需要同步的程式碼比較簡單,可以考慮原子變數,它們包含了一些以原子方式實現組合操作的方法,對於併發環境中的計數、產生序列號等需求,考慮使用原子變數而非鎖。

原子變數的基礎是CAS,比較並設定,一般的計算機系統都在硬體層次上直接支援CAS指令。通過迴圈CAS的方式實現原子更新是一種重要的思維,相比synchronized,它是樂觀的,而synchronized是悲觀的,它是非阻塞式的,而synchronized是阻塞式的。CAS是Java併發包的基礎,基於它可以實現高效的、樂觀、非阻塞式資料結構和演算法,它也是併發包中鎖、同步工具和各種容器的基礎。

寫時複製

之所以會有執行緒安全的問題,是因為多個執行緒併發讀寫同一個物件,如果每個執行緒讀寫的物件都是不同的,或者,如果共享訪問的物件是隻讀的,不能修改,那也就不存線上程安全問題了。

我們在介紹容器類CopyOnWriteArrayList和CopyOnWriteArraySet時介紹了寫時複製技術,寫時複製就是將共享訪問的物件變為只讀的,寫的時候,再使用鎖,保證只有一個執行緒寫,寫的執行緒不是直接修改原物件,而是新建立一個物件,對該物件修改完畢後,再原子性地修改共享訪問的變數,讓它指向新的物件。

ThreadLocal

ThreadLocal就是讓每個執行緒,對同一個變數,都有自己的獨有拷貝,每個執行緒實際訪問的物件都是自己的,自然也就不存線上程安全問題了。

執行緒的協作機制

多執行緒之間的核心問題,除了競爭,就是協作。我們在67節68節介紹了多種協作場景,比如生產者/消費者協作模式、主從協作模式、同時開始、集合點等。之前章節探討了協作的多種機制:

  • wait/notify
  • 顯式條件
  • 執行緒的中斷
  • 協作工具類
  • 阻塞佇列
  • Future/FutureTask

wait/notify

wait/notify與synchronized配合一起使用,是執行緒的基本協作機制,每個物件都有一把鎖和兩個等待佇列,一個是鎖等待佇列,放的是等待獲取鎖的執行緒,另一個是條件等待佇列,放的是等待條件的執行緒,wait將自己加入條件等待佇列,notify從條件等待佇列上移除一個執行緒並喚醒,notifyAll移除所有執行緒並喚醒。

需要注意的是,wait/notify方法只能在synchronized程式碼塊內被呼叫,呼叫wait時,執行緒會釋放物件鎖,被notify/notifyAll喚醒後,要重新競爭物件鎖,獲取到鎖後才會從wait呼叫中返回,返回後,不代表其等待的條件就一定成立了,需要重新檢查其等待的條件。

wait/notify方法看上去很簡單,但往往難以理解wait等的到底是什麼,而notify通知的又是什麼,只能有一個條件等待佇列,這也是wait/notify機制的侷限性,這使得對於等待條件的分析變得複雜,67節和68節通過多個例子演示了其用法,這裡就不贅述了。

顯式條件

顯式條件與顯式鎖配合使用,與wait/notify相比,可以支援多個條件佇列,程式碼更為易讀,效率更高,使用時注意不要將signal/signalAll誤寫為notify/notifyAll。

中斷

Java中取消/關閉一個執行緒的方式是中斷,中斷並不是強迫終止一個執行緒,它是一種協作機制,是給執行緒傳遞一個取消訊號,但是由執行緒來決定如何以及何時退出,執行緒在不同狀態和IO操作時對中斷有不同的反應,作為執行緒的實現者,應該提供明確的取消/關閉方法,並用文件清楚描述其行為,作為執行緒的呼叫者,應該使用其取消/關閉方法,而不是貿然呼叫interrupt。

協作工具類

除了基本的顯式鎖和條件,針對常見的協作場景,Java併發包提供了多個用於協作的工具類。

訊號量類Semaphore用於限制對資源的併發訪問數。

倒數計時門栓CountDownLatch主要用於不同角色執行緒間的同步,比如在”裁判”-“運動員”模式中,”裁判”執行緒讓多個”運動員”執行緒同時開始,也可以用於協調主從執行緒,讓主執行緒等待多個從執行緒的結果。

迴圈柵欄CyclicBarrier用於同一角色執行緒間的協調一致,所有執行緒在到達柵欄後都需要等待其他執行緒,等所有執行緒都到達後再一起通過,它是迴圈的,可以用作重複的同步。

阻塞佇列

對於最常見的生產者/消費者協作模式,可以使用阻塞佇列,阻塞佇列封裝了鎖和條件,生產者執行緒和消費者執行緒只需要呼叫佇列的入隊/出隊方法就可以了,不需要考慮同步和協作問題。

阻塞佇列有普通的先進先出佇列,包括基於陣列的ArrayBlockingQueue和基於連結串列的LinkedBlockingQueue/LinkedBlockingDeque,也有基於堆的優先順序阻塞佇列PriorityBlockingQueue,還有可用於定時任務的延時阻塞佇列DelayQueue,以及用於特殊場景的阻塞佇列SynchronousQueue和LinkedTransferQueue。

Future/FutureTask

在常見的主從協作模式中,主執行緒往往是讓子執行緒非同步執行一項任務,獲取其結果,手工建立子執行緒的寫法往往比較麻煩,常見的模式是使用非同步任務執行服務,不再手工建立執行緒,而只是提交任務,提交後馬上得到一個結果,但這個結果不是最終結果,而是一個Future,Future是一個介面,主要實現類是FutureTask。

Future封裝了主執行緒和執行執行緒關於執行狀態和結果的同步,對於主執行緒而言,它只需要通過Future就可以查詢非同步任務的狀態、獲取最終結果、取消任務等,不需要再考慮同步和協作問題。

容器類

執行緒安全的容器有兩類,一類是同步容器,另一類是併發容器。在理解synchronized一節,我們介紹了同步容器。關於併發容器,我們介紹了:

  • 寫時拷貝的List和Set
  • ConcurrentHashMap
  • 基於SkipList的Map和Set
  • 各種佇列

同步容器

Collections類中有一些靜態方法,可以基於普通容器返回執行緒安全的同步容器,比如:

public static <T> Collection<T> synchronizedCollection(Collection<T> c)
public static <T> List<T> synchronizedList(List<T> list)
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
複製程式碼

它們是給所有容器方法都加上synchronized來實現安全的。同步容器的效能比較低,另外,還需要注意一些問題,比如複合操作和迭代,需要呼叫方手工使用synchronized同步,並注意不要同步錯物件。

而併發容器是專為併發而設計的,執行緒安全、併發度更高、效能更高、迭代不會丟擲ConcurrentModificationException、很多容器以原子方式支援一些複合操作。

寫時拷貝的List和Set

CopyOnWriteArrayList基於陣列實現了List介面,CopyOnWriteArraySet基於CopyOnWriteArrayList實現了Set介面,它們採用了寫時拷貝,適用於讀遠多於寫,集合不太大的場合。不適用於陣列很大,且修改頻繁的場景。它們是以優化讀操作為目標的,讀不需要同步,效能很高,但在優化讀的同時就犧牲了寫的效能。

ConcurrentHashMap

HashMap不是執行緒安全的,在併發更新的情況下,HashMap的連結串列結構可能形成環,出現死迴圈,佔滿CPU。ConcurrentHashMap是併發版的HashMap,通過分段鎖和其他技術實現了高併發,讀操作完全並行,寫操作支援一定程度的並行,以原子方式支援一些複合操作,迭代不用加鎖,不會丟擲ConcurrentModificationException。

基於SkipList的Map和Set

ConcurrentHashMap不能排序,容器類中可以排序的Map和Set是TreeMap和TreeSet,但它們不是執行緒安全的。Java併發包中與TreeMap/TreeSet對應的併發版本是ConcurrentSkipListMap和ConcurrentSkipListSet。ConcurrentSkipListMap是基於SkipList實現的,SkipList稱為跳躍表或跳錶,是一種資料結構,主要操作複雜度為O(log(N)),併發版本採用跳錶而不是樹,是因為跳錶更易於實現高效併發演算法。

ConcurrentSkipListMap沒有使用鎖,所有操作都是無阻塞的,所有操作都可以並行,包括寫。與ConcurrentHashMap類似,迭代器不會丟擲ConcurrentModificationException,是弱一致的,也直接支援一些原子複合操作。

各種佇列

各種阻塞佇列主要用於協作,非阻塞佇列適用於多個執行緒併發使用一個佇列的場合,有兩個非阻塞佇列,ConcurrentLinkedQueue和ConcurrentLinkedDeque,ConcurrentLinkedQueue實現了Queue介面,表示一個先進先出的佇列,ConcurrentLinkedDeque實現了Deque介面,表示一個雙端佇列。它們都是基於連結串列實現的,都沒有限制大小,是無界的,這兩個類最基礎的實現原理是迴圈CAS,沒有使用鎖。

任務執行服務

關於任務執行服務,我們介紹了:

  • 任務執行服務的基本概念
  • 主要實現方式 – 執行緒池
  • 方便處理結果的CompletionService
  • 定時任務

基本概念

任務執行服務大大簡化了執行非同步任務所需的開發,它引入了一個”執行服務”的概念,將”任務的提交”和”任務的執行”相分離,”執行服務”封裝了任務執行的細節,對於任務提交者而言,它可以關注於任務本身,如提交任務、獲取結果、取消任務,而不需要關注任務執行的細節,如執行緒建立、任務排程、執行緒關閉等。

任務執行服務主要涉及以下介面:

  • Runnable和Callable:表示要執行的非同步任務
  • Executor和ExecutorService:表示執行服務
  • Future:表示非同步任務的結果

使用者只需要通過ExecutorService提交任務,通過Future操作任務和結果即可,不需要關注執行緒建立和協調的細節。

執行緒池

任務執行服務的主要實現機制是執行緒池,實現類是ThreadPoolExecutor,執行緒池主要由兩個概念組成,一個是任務佇列,另一個是工作者執行緒。任務佇列是一個阻塞佇列,儲存待執行的任務。工作者執行緒主體就是一個迴圈,迴圈從佇列中接受任務並執行。ThreadPoolExecutor有一些重要的引數,理解這些引數對於合理使用執行緒池非常重要,78節對這些引數進行了詳細介紹,這裡就不贅述了。

ThreadPoolExecutor實現了生產者/消費者模式,工作者執行緒就是消費者,任務提交者就是生產者,執行緒池自己維護任務佇列。當我們碰到類似生產者/消費者問題時,應該優先考慮直接使用執行緒池,而非重新發明輪子,自己管理和維護消費者執行緒及任務佇列。

CompletionService

在非同步任務程式中,一種場景是,主執行緒提交多個非同步任務,然後希望有任務完成就處理結果,並且按任務完成順序逐個處理,對於這種場景,Java併發包提供了一個方便的方法,使用CompletionService,這是一個介面,它的實現類是ExecutorCompletionService,它通過一個額外的結果佇列,方便了對於多個非同步任務結果的處理。

定時任務

非同步任務中,常見的任務是定時任務。在Java中,有兩種方式實現定時任務:

  • 使用java.util包中的Timer和TimerTask
  • 使用Java併發包中的ScheduledExecutorService

Timer有一些需要特別注意的事項:

  • 一個Timer物件背後只有一個Timer執行緒,這意味著,定時任務不能耗時太長,更不能是無限迴圈
  • 在執行任何一個任務的run方法時,一旦run丟擲異常,Timer執行緒就會退出,從而所有定時任務都會被取消

ScheduledExecutorService的主要實現類是ScheduledThreadPoolExecutor,它沒有Timer的問題:

  • 它的背後是執行緒池,可以有多個執行緒執行任務
  • 任務執行執行緒會捕獲任務執行過程中的所有異常,一個定時任務的異常不會影響其他定時任務

所以,實踐中建議使用ScheduledExecutorService。

小結

針對多執行緒開發的兩個核心問題,競爭和協作,本節總結了執行緒安全和協作的多種機制,針對高層服務,本節總結了併發容器和任務執行服務,它們讓我們在更高的層次上訪問共享的資料結構,執行任務,而避免陷入執行緒管理的細節。到此為止,關於併發我們就告一段落了。

與之前章節一樣,我們的探討都是基於Java 7的,不過Java 7引入了一個Fork/Join框架,我們沒有討論。Java 8在併發方面也有一些更新,比如:

  • 引入了CompletableFuture,增強了原來的Future,以便於實現組合式非同步程式設計
  • ConcurrentHashMap增加了一些新的方法,內部實現也進行了優化
  • 引入了流的概念,基於Fork/Join框架,可以非常方便的對大量資料進行並行操作

關於這些內容,我們在探討Java 8的時候再繼續討論。

從下一節開始,我們來探討Java中的一些動態特性,比如反射、註解、動態代理等,它們到底是什麼呢?


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (83) – 併發總結

相關文章