2.8 Java并發
2.8.1 線程安全性
當多個線程訪問某個類時,這個類始終能表現出正確的行為,那么就稱這個類是線程安全的。多線程中訪問共享可變對象必須加以正確的同步手段。
2.8.2 對象共享和組合
對象共享
關于對象共享,需要明白Java內存模型:在Java中,所有實例域、靜態域和數組元素存儲在堆內存中,而堆內存在線程之間共享。關于對象共享一般可以如下方式進行:
- 在靜態初始化函數中初始化對象引用,靜態初始化函數由JVM在類的初始化階段執行,而JVM內部存在同步機制,使用這種方式初始化對象是線程安全的。
- 將對象引用保存到final域中,final的語義是引用不可以換指,需要注意的是它不能保證對象本身不可變。
- 將對象引用保存到volatile域中或者AtomicReference中,或者一個由鎖保護的域中(原子性和可見性)。
需要注意的是,要特別防止對象的逸出:不要在構造函數中逸出this引用,不要在構造函數中啟動線程(可以在構造函數中創建線程)。
對象組合
當我們需要在多個線程之間共享對象時,則需要設計線程安全類:找出構成對象狀態的所有變量,找出約束狀態變量的不變性條件,建立對象狀態的并發訪問管理策略(Java同步容器類通過同步所有公共方法來保證線程安全性)。
需要特別注意的是,當我們在擴展線程安全類時,要特別注意的事:要和原有的線程安全類使用同一個鎖。具體如下所示:
public class ListHelper<T> {
public List<T> mList = new ArrayList<T>;
...
public synchronized boolean putIfAbsent(T t) {
boolean absent = !mList.contains(t);
if(absent) {
mList.add(t);
}
return absent;
}
}
public class ListHelper<T> {
public List<T> mList = new ArrayList<T>;
...
public boolean putIfAbsent(T t) {
synchronized(mList) {
boolean absent = !mList.contains(t);
if(absent) {
mList.add(t);
}
return absent;
}
}
}
前者并不是線程安全的,后者才是正確的做法。
2.8.3 基礎構建模塊
同步容器類
同步容器實現線程安全性的方式是將狀態封裝起來,對每個公有方法進行同步。
這里我們簡單介紹一下Iterator中的fail-fast機制:Iterator在遍歷容器的過程,如果底層的容器發生了結構變化(add或者remove了元素),則會拋出ConcurrentModificationException。需要注意的是:當我們在for-each循環中刪除或者添加元素時,會拋出該異常。這是因為Iterator是工作在一個獨立的線程中,并且擁有一個mutex鎖。Iterator被創建之后會建立一個指向原來對象的單鏈索引表,當原來的對象數量發生變化時,這個索引表的內容不會同步改變,所以當索引指針往后移動的時候就找不到要迭代的對象,所以按照fail-fast原則Iterator會馬上拋出ConcurrentModificationException異常。所以Iterator在工作的時候是不允許被迭代的對象被改變的。但你可以使用Iterator的remove來刪除對象,該方法會在刪除當前迭代對象的同時維護索引的一致性。
并發容器
同步容器將所有對容器狀態的訪問都串行化,以實現線程安全性,但是這種方法嚴重降低并發性。而并發容器是針對多個線程并發訪問設計的,增加了對一些常見復合操作的支持,如putIfAbsent,replace等。常用的并發容器有:CopyOnWriteArrayList和ConcurrentHashMap,前者主要是用于同步的List,如ArrayList和LinkedList;后者主要是用于替代同步且基于散列的Map,如HashMap。
隊列
隊列是一種非常有用的工具,一般有以下三種常用的隊列:
- BlockingQueue與生產者-消費者模式(有界隊列是很好的資源管理工具),一般常用于實現線程池等。
- SynchronousQueue:它不是一種真正的隊列,它不會為隊列中元素維護存儲空間。與其他隊列不同的是,它維護一組線程,這些線程在等待著將元素加入或者移出隊列。使用該種隊列時,必須有足夠的消費者,OKHttp就是使用該種隊列實現的線程池,newCachedThreadPool也使用了該種隊列。吞吐量要高于其他隊列。
- Deque(雙端隊列)與工作密取,工作密取非常適用于既是消費者又是生產者的情況。
同步工具類
- 閉鎖(CountDownLatch):可以延遲線程的執行直到閉鎖到達終止狀態。一般用于當條件充分之后才繼續進行之后的場景:如確保某個計算所需要的資源都初始化后才繼續執行,或者某個服務在其依賴的所有其他服務都啟動了之后才啟動,或者某個操作的所有參與者(如多玩家游戲中的所有玩家)都就緒后再繼續執行。需要注意的是閉鎖不可以重用。
- FutureTask:一般用作異步任務,可以使用get方法獲取任務的結果(get是阻塞的)。
- 信號量:用來控制同時訪問某個資源的操作數量,或者同時執行某個制定操作的數量。一般可以用于以下場景:二值信號量可以用作互斥鎖(mutex),實現資源池(如數據庫連接池),將任何一種容器變為有界容器。
- 柵欄:阻塞一組線程直到某個事件發生,其和閉鎖之間的區別:所有線程必須同時到達柵欄位置才可以繼續執行,而且柵欄可以重用。閉鎖一般用于等待時間,而柵欄用于等待線程。一般常用的有兩種柵欄:CyclicBarrier可以讓一定數量的線程反復地在柵欄位置匯集,一般常用在并行迭代算法;而Exchanger是一種兩方柵欄,各方在柵欄位置交換數據,一般常用在兩方執行不對稱的操作(例如讀和寫)。
2.8.4 任務執行、取消和關閉
大多數并發程序都是圍繞“任務”來構造的,而理想情況下,任務之間是相互獨立的,清晰的任務邊界是程序并發的關鍵。大多數服務器程序使用一種自然的任務邊界方式:以獨立的客戶請求為邊界。
任務抽象
Runnable是最基本的任務抽象形式,Runnable的問題是無法返回結果或者拋出異常。
Callable是一種更好的抽象形式,它可以返回一個值,或者拋出異常。
Future表示一個任務的生命周期,并提供了相應的方法來獲取任務的狀態(完成或者取消),以及獲取任務的結果或者取消任務(netty中的Future使用上更方便)。
任務執行策略
Executor框架(線程池)將任務提交和執行策略解耦,同時可以支持多種不同類型的執行策略。而一般任務執行策略需要考慮如下幾個問題:
- 在什么線程中執行任務
- 任務按照什么順序執行(FIFO、LIFO或者優先級)
- 由多少個任務可以并發執行
- 在隊列中有多少個任務在等待執行
- 如果系統由于過載而需要拒絕一個任務,那么應該選擇哪一個任務,以及如何通知上層應用有任務被拒絕
- 任務執行前后,應該進行哪些動作
Executor框架提供了四個工廠方法來創建一個線程池:newFixedThreadPool、newCachedThreadPool、newSingleThreadPool和newScheduledThreadPool。
任務的取消與關閉
任務的提交和啟動很簡單,但是任務的取消就困難的多。針對任務本身:Future是可以cancel的,而Runnable和Callable是無法cancel的。而針對任務執行的容器:這要分為在使用線程和線程池兩種情況:
- 線程:Java沒有提供任何機制來安全地終止線程,但它提供了中斷,這是一種協作機制,能夠使一個線程請求終止另一個線程的當前工作。
- 線程池:線程池需要提供生命周期方法來關閉自己以及其中的線程,也可以使用毒藥對象的方式。
這里我們詳述一下中斷,Thread.interrupt方法并不是中斷目標線程,而是通知目標線程應該中斷了,而具體是中斷還是繼續運行,應該由目標線程自己處理。具體來說,對一個線程調用interrupt方法時:
- 如果線程處于被阻塞狀態(例如處于sleep, wait, join等狀態),那么線程將立即退出被阻塞狀態,并拋出一個InterruptedException異常,僅此而已。
- 如果線程處于正常活動狀態,那么會將該線程的中斷標志設置為true,僅此而已。被設置中斷標志的線程將繼續正常運行,不受影響。
因此interrupt方法并不能真正的中斷線程,需要被調用的線程自己進行配合才行。即一個線程如果有被中斷的需求,那么就可以這樣做:
- 在正常運行任務時,經常檢查本線程的中斷標志位,如果被設置了中斷標志就自動停止線程。
Thread thread = new Thread(() -> {
while (!Thread.interrupted()) {
// do more work.
}
});
thread.start();
...
thread.interrupt();
- 在調用阻塞方法時正確處理InterruptedException異常:傳遞該異常或者恢復中斷(當無法傳遞該異常時,在catch子句中調用Thread.currentThread().interrupt(),因為拋出該異常會清除中斷標志位)。一定不要catch后什么都不做。
Thread thread = new Thread(() -> {
//do more work
try {
sleep();
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
});
thread.start();
...
thread.interrupt();
非正常的線程終止
導致線程非正常終止的最主要原因是RuntimeException,一般不建議catch RuntimeException,但是守護線程可以catch RuntimeException。而JVM提供了一個鉤子:UncaughtExceptionHandler可以用來獲取線程終結的情況。
2.8.5 線程池的使用
線程池的理想大小主要取決于被提交任務的類型:一般計算密集型的任務時,線程池的理想大小是cpu+1;而IO密集型的更應該更大一些。
上文提到Executor框架提供了四種線程池的基本實現,Executor框架也支持各種定制方案:
- 線程的創建和銷毀,線程池的基本大小(core size)、最大大小(max size)以及存活時間等因素共同影響線程的創建與銷毀。
- 管理隊列任務,
- 飽和策略,當使用有界隊列時,有界隊列填滿時,飽和策略開始發揮作用。
- 線程工廠
ThreadPoolExecutor是可擴展的,它提供了幾個方法可以進行擴展:beforeExecute、afterExecute和terminated。
2.8.6 原子變量和非阻塞同步機制
獨占鎖是一種悲觀技術,它假設最壞的情況,并且只有在確保其他線程不會造成干擾的情況下才能繼續執行。而樂觀鎖通過借助沖突檢測機制來判斷更新過程中是否存在來自其他線程的干擾,如果存在,則操作失敗,并且可以重試。
樂觀鎖:比較并交換(CAS),原子的進行比較并設置,其具體含義是:如果V的值是A,則將V的值更新為B,否則不修改并返回V的值(Java中原子量就是這樣工作的)。
原子量:相當于一種泛化的volatile變量,提供原子性和可見性的保證。