Java併發思考-導讀&總結篇

登高且賦發表於2017-12-02

Java併發思考
該篇作為本文集的導讀目錄,將現有9篇關於Java併發的文章的核心內容整理並列出。這9篇文章大體上將Java併發的體系簡述了一遍,對我個人是一次知識上的梳理,對大家也希望是一種閱讀上的一種幫助。
祝各位閱讀愉快。

1.《多執行緒安全性:每個人都在談,但是不是每個人都談地清

多執行緒安全性的定義可能眾說紛紜,但是其最核心的一點就是正確性,也就是程式的行為結果和預期一致。

當多個執行緒訪問某個類時,不管執行環境採用何種執行緒排程演算法或者這些執行緒如何交替執行,且不需要在主程式中新增任何額外的協同機制,這個類都能表現出正確的行為,那麼這個類就是執行緒安全的。

要編寫多執行緒安全的程式碼,最關鍵的一點就是需要對於共享的可變的狀態進行訪問控制. 多執行緒安全要求在一個原子性操作中更新所有相關狀態的變數。每個共享可變的變數,都應該只有一個鎖來保護。如果由多個變數協同完成操作,則這些變數應該由同一個鎖來保護。

2.《物件共享:Java併發環境中的煩心事

併發的意義在於多執行緒協作完成某項任務,而執行緒的協作就不可避免地需要共享資料。

多執行緒安全不光要求實現了原子性,還要求實現記憶體可見性(Memory Visibility)。也就是在同步的過程中,不僅要防止某個執行緒正在使用的狀態被另一個執行緒修改,還要保證一個執行緒修改了物件狀態之後,其他執行緒能獲得更新之後的狀態。

在沒有同步機制的情況下,在多執行緒的環境中,每個程式單獨使用儲存在自己的執行緒環境中的變數拷貝。正因如此,當多執行緒共享一個可變狀態時,該狀態就會有多份拷貝,當一個執行緒環境中的變數拷貝被修改了,並不會立刻就去更新其他執行緒中的變數拷貝。

加鎖機制可以確保可見性、原子性和不可重排序性,但是Volatile變數只能確保可見性不可重排序性

3.《從Java記憶體模型角度理解安全初始化

JVM只會在執行結果和嚴格序列執行結果相同的情況下進行如上的優化操作,如對程式碼的執行順序重新排序。

為了進一步提高效率,多核處理器已經廣泛被使用。在多核理器架構中,每個處理器都擁有自己的快取,並且會定期地與主記憶體進行協調。這樣的架構就需要解決快取一致性(Cache Coherence)的問題。但是這些框架中只提供了最小保證,即允許不同處理器在任意時刻從同一儲存位置上看到不同的值。

正因此存在上面所述的硬體能力和執行緒安全需求的差異,才導致需要在程式碼中使用同步機制來保證多執行緒安全。

Java記憶體模式為我們遮蔽了各個框架在記憶體模型上的差異。想要保證執行操作B的執行緒看到執行操作A的結果,而無論兩個操作是否在同一執行緒,則操作A和操作B之間必須滿足Happens-Before關係,否者JVM將可以對他們的執行順序任意安排。

靜態初始化或靜態程式碼塊因為由JVM的機制保護,不需要額外的同步機制,就可以保證其一定在呼叫類的方法(包括構造器)之前執行完畢。該特性和JVM的延遲載入機制結合,形成了一種完備的延遲初始化技術-延遲初始化佔位類模式

4.《從任務到執行緒:Java結構化併發應用程式

併發設計的本質,就是要把程式的邏輯分解為多個任務,這些任務獨立而又協作的完成程式的功能。而其中最關鍵的地方就是如何將邏輯上的任務分配到實際的執行緒中去執行。換而言之,任務是目的,而執行緒是載體,執行緒的實現要以任務為目標。

java.util.concurrent提供了Executor框架來幫助我們管理執行緒資源,規劃執行緒的執行。Executor的本質就是管理和排程執行緒池。使用執行緒池任務池的優勢在於:

  1. 通過複用現有執行緒而不是建立新的執行緒,降低建立執行緒時的開銷;
  2. 複用現有執行緒,可以直接執行任務,避免因建立執行緒而讓任務等待,提高響應速度。

Executor可以建立的執行緒池共有四種:

  1. newFixedThreadPool
  2. newCachedThreadPool
  3. newScheduledThreadPool
  4. newSingleThreadExecutor

Java1.5開始提供了Executor的擴充套件介面ExecutorService,其提供了兩種方法關閉方法:

  • shutdown: 平緩的關閉過程,即不再接受新的任務,等到已提交的任務執行完畢後關閉程式池;
  • shutdownNow: 立刻關閉所有任務,無論是否再執行;

Java中設計了另一種介面Callable來作Runnable的升級版。Callable支援任務有返回值,並支援異常的丟擲。

Future類表示任務生命週期狀態,提供方法查詢任務狀態外,還提供get方法獲得任務的返回值,如果任務沒有執行完就會被擁塞。

當遇到一次性提交一組任務的情況,這個時候可以使用CompletionService,CompletionService可以理解為Executor和BlockingQueue的組合:當一組任務被提交後,CompletionService將按照任務完成的順序將任務的Future物件放入佇列中。

5.《關閉執行緒的正確方法:“優雅”的中斷

Java中沒有提供安全的機制來終止執行緒。雖然有Thread.stop/suspend等方法,但是這些方法存在缺陷,不能保證執行緒中共享資料的一致性,所以應該避免直接呼叫。更為妥當的方式是使用Java中提供了中斷機制,來讓多執行緒之間相互協作,由一個程式來安全地終止另一個程式。

中斷是取消執行緒的最合理的方式。
呼叫Interrupt方法並不是意味著要立刻停止目標執行緒,而只是傳遞請求中斷的訊息。所以對於中斷操作的正確理解為:正在執行的執行緒收到中斷請求之後,在下一個合適的時刻中斷自己。

Future用來管理任務的生命週期,自然也可以來取消任務,呼叫Future.cancel方法就是用中斷請求結束任務並退出.

一些的方法的擁塞是不能響應中斷請求的,這類操作以I/O操作居多,但是可以讓其丟擲類似的異常,來停止任務。

6.《駕馭Java執行緒池:定製與擴充套件

ThreadPoolExecutor提供了Executor的基本實現,除了提供*四種常見的方法來獲得特定配置的程式池,還可以進行各種定製,以獲得靈活穩定的執行緒池。

以下是ThreadPoolExecutor的建構函式

public ThreadPoolExecutor(
    int corePoolSize,//基本大小
    int maximumPoolSize, //最大大小
    long keepAliveTime, //執行緒保活時間
    TimeUnit unit, //保活時間單位                 
    BlockingQueue<Runnable> workQueue,//任務佇列
    ThreadFactory threadFactory,//任務工廠
    RejectedExecutionHandler handler) {...}//飽和策略

ThreadPoolExecutor使用擁塞佇列BlockingQueue來儲存等待的任務,任務佇列共分為三種:無界佇列,有解佇列和同步佇列。

ThreadPoolExecutor通過引數RejectedExecutionHandler來設定飽和策略,JDK中提供的實現共有四種:

  • 中止策略(Abort Policy):預設的策略,佇列滿時,會丟擲異常RejectedExecutionException,呼叫者在捕獲異常之後自行判斷如何處理該任務;
  • 拋棄策略(Discard Policy):佇列滿時,程式池拋棄新任務,並不通知呼叫者;
  • 拋棄最久策略(Discard-oldest Policy):佇列滿時,程式池將拋棄佇列中被提交最久的任務;
  • 呼叫者執行策略(Caller-Runs Policy):該策略不會拋棄任務,也不會丟擲異常,而是將任務退還給呼叫者,也就是說當佇列滿時,新任務將在呼叫ThreadPoolExecutor的執行緒中執行。

當執行緒池需要建立新的執行緒時,就會通過執行緒工廠來建立Thread物件。預設情況下,執行緒池的執行緒工廠會建立簡單的新執行緒,如果需要使用者可以為執行緒池定製執行緒工廠。

ThreadPoolExecutor提供了可擴充套件的方法:

  • beforeExecute: 在任務被執行之前被呼叫;
  • afterExecute: 無論任務執行成功和還是丟擲異常,都在返回後執行;如果任務執行中出現Error或是beforeExecute丟擲異常,則afterExecutor不會被執行。
  • terminated: 程式池完成之後被呼叫,可以用於釋放程式池在生命週期內分配的各種資源和日誌等工作。

7.《探祕Java併發模組:容器與工具類

同步容器類的代表就是VectorHashTable,這是早期JDK中提供的類。此外Collections.synchronizedXXX等工廠方法也可以把普通的容器(如HashMap)封裝成同步容器。這些同步容器類的共同點就是:使用同步Synchronized)方法來封裝容器的操作方法,以保證容器多執行緒安全,但這樣也使得容器的每次操作都會對整個容器上鎖,所以同一時刻只能有一個執行緒訪問容器。

同步容器類不能保證容器複合操作的原子性,使用其迭代器時也不能保證多執行緒安全。

從Java 5開始,JDK中提供了併發容器類來改進同步容器類的不足。Java 5 中提供了ConcurrentHashMap來代替同步的HashMap,提供了CopyOnWriteArrayList來代替同步都是List。

ConcurrentHashMap分段鎖來保護容器中的元素。如果訪問的元素不是由同一個鎖來保護,則允許併發被訪問。這樣做雖然增加了維護和管理的開銷,但是提高併發性。不過,ConcurrentHashMap中也存在對整個容器加鎖的情況,比如容器要擴容,需要重新計算所有元素的雜湊值, 就需要獲得全部的分段鎖。

CopyOnWriteArrayList用於代替同步的List,其為“寫時複製(Copy-on-Write)”容器,本質為事實不可變物件,一旦需要修改,就會建立一個新的容器副本併發布。容器的迭代器會保留一個指向底層基礎陣列的引用,這個陣列是不變的,且其當前位置位於迭代器的起始位置。

Java 5 還新增了兩種容器型別:QueueBlockingQueue

  • 佇列Queue,其實現有ConcurrentLinkedQueue(併發的先進先出佇列)和PriorityQueue(非併發的優先順序佇列);Queue上的操作不會被擁塞,如果佇列為空 ,會立刻返回null,如果佇列已滿,則會立刻返回失敗;
  • 擁塞佇列BlockingQueue,是Queue的一種擴充套件,其上的操作是可擁塞的:如果佇列為空,則獲取元素的操作將被擁塞直到佇列中有可用元素,同理如果佇列已滿,則放入元素的操作也會被用塞到佇列有可用的空間。

Java中還提供了同步工具類,這些同步工具類可以根據自身的狀態來協調執行緒的控制流,上面提到的擁塞佇列就是一種同步工具類,除此之外還有閉鎖(Latch)訊號量(Semaphore)柵欄(Barrier)

8.《Java高階上鎖機制:顯式鎖 ReentrantLock

ReentrantLock,它和同步(Synchronized)方法的內建鎖不同,這是一種顯式鎖。顯式鎖作為一種高階的上鎖工作, 是同步方法的一種補充和擴充套件,用來實現同步程式碼塊無法完成的功能,比如提供響應中斷的獲得鎖操作,提供支援超時的獲得鎖操作等等。

public interface Lock {
    void lock(); //獲取鎖
    void lockInterruptibly() throws InterruptedException; //可中斷的獲取鎖操作
    boolean tryLock(); //嘗試獲取鎖,不會被擁塞,如果失敗立刻返回
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //在一定時間內嘗試獲得鎖,如果超時則失敗
    void unlock(); // 釋放鎖
    Condition newCondition();
}

顯式鎖需要在手動呼叫lock方法來獲得鎖,並在使用後在finally程式碼塊中呼叫unlock方法釋放鎖,以保證無論操作是否成功都能釋放掉鎖。

ReentrantLock的建構函式中提供兩種鎖的型別:

  • 公平鎖:執行緒將按照它們請求鎖的順序來獲得鎖;
  • 非公平鎖:允許插隊,如果一個執行緒請求非公平鎖的那個時刻,鎖的狀態正好為可用,則該執行緒將跳過所有等待中的執行緒獲得該鎖。

非公平鎖線上程間競爭鎖資源激烈的情況下,效能更高,是顯式鎖所使用預設模式。

無論是顯式鎖還是內建鎖,都是互斥鎖,也就是同一時刻只能有一個執行緒得到鎖。互斥鎖是保守的加鎖策略,可以避免“寫-寫”衝突、“寫-讀”衝突”和”讀-讀”衝突。但是有時候不需要這麼嚴格 ,同時多個任務讀取資料是被允許,這有助於提升效率,不需要避免“讀-讀”操作。為此,Java 5.0 中出現了讀-寫鎖ReadWriteLock

建議只有在一些內建鎖無法滿足的情況下,再將顯式鎖ReentrantLock作為高階工具使用,比如要使用輪詢鎖、定時鎖、可中斷鎖或者是公平鎖。除此之外,還應該優先使用synchronized方法。

9.《嘗試Java加鎖新思路:原子變數和非阻塞同步演算法

無論是內建鎖還是顯式鎖,都是一種獨佔鎖,也是悲觀鎖。所謂悲觀鎖,就是以悲觀的角度出發,認為如果不上鎖,一定會有其他執行緒修改資料,破壞一致性,影響多執行緒安全,所以必須通過加鎖讓執行緒獨佔資源。

與悲觀鎖相對,還有更高效的方法——樂觀鎖,這種鎖需要藉助衝突檢查機制來判斷在更新的過程中是否存在來氣其他執行緒的干擾,如果沒有干擾,則操作成功,如果存在干擾則操作失敗,並且可以重試或採取其他策略。

大部分處理器框架是通過實現比較並交換(Compare and Swap,CAS)指令來實現樂觀鎖。CAS指令包含三個運算元:需要讀寫的記憶體位置V,進行比較的值A和擬寫入新值B。當且僅當V處的值等於A時,才說明V處的值沒有被修改過,指令才會使用原子方式更新其為B值,否者將不會執行任何操作。無論操作是否執行, CAS都會返回V處原有的值。

CAS的方法在效能上有很大優勢:在競爭程度不是很大的情況下,基於CAS的操作,在效能上遠遠超過基於鎖的方法;在沒有競爭的情況下,CAS的效能更高。

但是CAS的缺點是:將競爭的問題交給呼叫者來處理,但是悲觀鎖自身就能處理競爭。

Java中也引入CAS。對於int、long和物件的引用,Java都支援CAS操作,也就是原子變數類,JVM會把對於原子變數類的操作編譯為底層硬體提供的最有效的方法:如果硬體支援CAS,則編譯為CAS指令,如果不支援,則編譯為上鎖的操作。常見的原子變數有AtomicIntegerAtomicLongAtomicBooleanAtomicReference

原子變數可以被視為一種更好volatile變數。但是原子變數沒有定義hashCode和equals方法,所以每個例項都是不同的,不適合作為雜湊容器的key。

在中低程度的競爭之下,原子變數能提供更高的可伸縮性,而在高強度的競爭下,鎖能夠有效地避免競爭。

如果某種演算法中,一個執行緒的失敗或者掛起不會導致其他執行緒也失敗和掛起,這該種演算法是非阻塞的演算法

如果在演算法中僅僅使用CAS用於協調執行緒間的操作,並且能夠正確的實現,那麼該演算法既是一種無阻塞演算法,也是一種無鎖演算法。在非擁塞演算法中,不會出現死鎖的優先順序反轉的問題。

建立非阻塞演算法的關鍵在於將原子修改的範圍縮小到單個變數上,同時保證資料一致性。非阻塞演算法的特點:某項操作的完成具有不確定性,如不成功必須重新執行


相關文章