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

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


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

多線程開發有兩個核心問題,一個是競爭,另外一個是協做。競爭會出現線程安全問題,因此,本節首先總結線程安全的機制,而後是協做的機制。管理競爭和協做是複雜的,因此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編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索