Java編程的邏輯 (83) - 並發總結


本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營鏈接http://item.jd.com/12299018.html


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一節,我們介紹了同步容器。關於並發容器,我們介紹了:

同步容器

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是TreeMapTreeSet,但它們不是線程安全的。Java並發包中與TreeMap/TreeSet對應的並發版本是ConcurrentSkipListMap和ConcurrentSkipListSet。ConcurrentSkipListMap是基於SkipList實現的,SkipList稱為跳躍表或跳表,是一種數據結構,主要操作復雜度為O(log(N)),並發版本采用跳表而不是樹,是因為跳表更易於實現高效並發算法。

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

各種隊列

各種阻塞隊列主要用於協作,非阻塞隊列適用於多個線程並發使用一個隊列的場合,有兩個非阻塞隊列,ConcurrentLinkedQueue和ConcurrentLinkedDeque,ConcurrentLinkedQueue實現了Queue接口,表示一個先進先出的隊列,ConcurrentLinkedDeque實現了Deque接口,表示一個雙端隊列。它們都是基於鏈表實現的,都沒有限制大小,是無界的,這兩個類最基礎的實現原理是循環CAS,沒有使用鎖。

任務執行服務

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

基本概念

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

任務執行服務主要涉及以下接口:

  • 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中的一些動態特性,比如反射、注解、動態代理等,它們到底是什么呢?

---------------------

並發相關原創文章

(65) 線程的基本概念 

(66) 理解synchronized

(67) 線程的基本協作機制 (上)

(68) 線程的基本協作機制 (下) 

(69) 線程的中斷

(70) 原子變量和CAS

(71) 顯式鎖

(72) 顯式條件

(73) 並發容器 - 寫時拷貝的List和Set 

(74) 並發容器 - ConcurrentHashMap 

(75) 並發容器 - 基於SkipList的Map和Set

(76) 並發容器 - 各種隊列

(77) 異步任務執行服務

(78) 線程池

(79) 方便的CompletionService 

(80) 定時任務的那些坑

(81) 並發同步協作工具 

(82) 理解ThreadLocal

----------------

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM