Java並發編程實踐
最近閱讀了《Java並發編程實踐》這本書,總結了一下幾個相關的知識點。
執行緒安全
當多個執行緒訪問某個類時,不管運行時環境採用何種調度方式或者這些執行緒將如何交替執行,並且在主調程式碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼就稱這個類是執行緒安全的。可以通過原子性、一致性、不可變對象、執行緒安全的對象和加鎖保護同時被多個執行緒訪問的可變狀態變數來解決執行緒安全的問題。
可見性
在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多執行緒程式中,要想對記憶體操作的執行順序進行判斷,幾乎無法得出正確的結論。加鎖的含義不僅僅局限於互斥行為,還包括記憶體可見性。為了確保所有執行緒都能看到共享變數的最新值,所有執行讀寫操作的執行緒都必須持有同一把鎖。volatile
變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile
類型的變數時總會返回最新寫入的值。volatile
變數是一種比synchronized
關鍵字更輕量級的同步機制。加鎖機制即可以確保可見性又可以確保原子性,而volatile
變數只能確保可見性。
發布逸出
當從對象的構造函數中發布對象時,只是發布了一個尚未構造完成的對象。即使發布對象的語句位於構造函數的最後一行也是如此。如果this
引用在構造函數中逸出,那麼這種現象就被認為是不正確構造。常見的逸出有,在構造函數中創建並啟動一個執行緒、內部私有可變狀態逸出等。
要安全地發布一個對象,對象的引用以及對象的狀態必須同時對其他執行緒可見。一個正確構造的對象可以通過一下方式來安全地發布:
- 在靜態初始化函數中初始化一個對象引用
- 將對象的引用保存到
volatile
類型的域或者AtomicReference
對象中 - 將對象的引用保存到某個正確構造對象的final類型域中
- 將對象的引用保存到一個由鎖保護的域中
對象的發布需求取決於它的可變性:
- 不可變對象可以通過任意機制來發布
- 事實不可變對象必須通過安全方式來發布
- 可變對象必須通過安全方式發布,並且必須是執行緒安全的或者由某個鎖保護起來
千萬不要在A執行緒中創建對象,在B執行緒中使用該對象。在對象初始化的時候,首先會去申請一個記憶體空間,然後給對象中的屬性賦默認值(如:int
類型的變數默認值為0
等),再通過構造函數或者程式碼塊對屬性進行賦值,最後地址空間指向的對象才算是創建完成了(當然還有很多其他的步驟,這裡只是簡單說明一下)。這樣很有可能出現B執行緒獲取到的對象是不完整的,因為Java執行緒模型的和對象的可見性的原因。
執行緒中斷
調用Thread.interrupt()
並不意味著立即停止目標執行緒正在進行的工作,而只是傳遞了請求中斷的消息。
對中斷操作的正確理解是:它並不是真正地中斷一個正在運行的執行緒,而只是發出中斷請求,然後由執行緒在下一個合適的時刻中斷自己。(這些時刻也被稱為取消點)。有些方法,例如:Object.wait()
、Thread.sleep()
和Thread.join()
等,將嚴格地處理這種請求,當它們收到中斷請求或者在開始執行時發現某個已被設置好的中斷狀態時,將拋出一個異常。
在使用靜態的interrupted
時應該小心,因為它會清除當前執行緒的中斷狀態。如果在調用interrupted
時返回了true
,那麼除非你想屏蔽這個中斷,否則必須對它進行處理—可以拋出InterruptedException
,或者通過再次調用interrupt()
來恢復中斷狀態。Future.cancel()
方法可以取消執行緒。
通常,中斷是實現取消的最合理方式。
未捕獲的異常
在運行時間較長的應用程式中,通常會為所有執行緒的未捕獲異常指定同一個異常處理器(實現Thread.UncaughtExceptionHandler
介面),並且該處理器至少會將異常資訊記錄到日誌中。
如果你希望在任務由於發生異常和失敗時獲得通知,並且執行一些特定於任務的居處操作,那麼可以將任務封裝在能捕獲異常的Runnable
或Callable
中,或者改寫ThreadPoolExecutor.afterExecute()
方法。
只有通過execute()
提交的任務,才能將它拋出的異常交給未捕獲異常處理器,而通過submit
提交的任務的異常都被封裝在Future.get()
的ExecutionException
中重新拋出。
JVM關閉
關閉鉤子是指通過Runtime.addShutdownHook
註冊的但尚未開始的執行緒。JVM並不能保證關閉鉤子的調用順序。在關閉應用程式執行緒時,如果有(守護或非守護)執行緒仍然在運行,那麼這些執行緒接下來將與關閉進程並發執行。當所有的關閉鉤子都執行結束時,如果runFinalizersOnExit
為true
,那麼JVM將運行終結器,然後再停止。
關閉鉤子應該是執行緒安全的。它們在訪問共享數據時必須使用同步機制,並且小心地避免發生死鎖,這與其他並發程式碼的要求相同。而且,關閉鉤子不應該對應用程式的狀態或者JVM的關閉原因做出任何假設,因此在編寫關閉鉤子的程式碼時必須考慮周全。
關閉ExecutorService
ExecutorService
提供了兩種關閉方法:
ExecutorService.shutdown()
:正常關閉ExecutorService.shutdownNow()
:強行關閉
這兩種關閉方式的差別在於各自的安全性和響應性:強行關閉的速度更快,但風險也更大,因為任務很可能在執行到一半時被結束;而正常關閉雖然速度慢,但卻更安全,因為ExecutorService
會一直等到隊列中的所有任務都執行完成後才關閉。在其他擁有執行緒的服務中也應該考慮提供類似的關閉方式以供選擇。
\(\circ\) 正常關閉
try{
// 正常關閉
executorService.shutdown();
// 等待指定時間直到結束,超時會拋出InterruptedException異常
executorService.awaitTermination(timeout, unit);
}catch(InterruptedException ex){
// do something
}
\(\circ\) 強行關閉
try{
// 強行關閉
List<Runnable> unfinishedTasks = executorService.shutdownNow();
// 處理未完成的任務
handle(unfinishedTasks);
// 等待指定時間直到結束,超時會拋出InterruptedException異常
executorService.awaitTermination(timeout, unit);
}catch(InterruptedException ex){
// do something
}
資源釋放
調用的方法 | 鎖 | CPU |
---|---|---|
Thread.sleep() |
不釋放 | 釋放 |
Thread.join() |
不釋放 | 釋放 |
Thread.yield() |
不釋放 | 釋放 |
Object.wait() |
釋放 | 釋放 |
Condition.await() |
釋放 | 釋放 |
ThreadPoolExecutor
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize 執行緒池核心執行緒大小
在創建了執行緒池後,默認情況下,執行緒池中並沒有任何執行緒,而是等待有任務到來才創建執行緒去執行任務,(除非調用了prestartAllCoreThreads()
或者prestartCoreThread()
方法,從這2個方法的名字就可以看出,是預創建執行緒的意思,即在沒有任務到來之前就創建corePoolSize
個執行緒或者一個執行緒)。
默認情況下,在創建了執行緒池後,執行緒池中的執行緒數為0
,當有任務來之後,就會創建一個執行緒去執行任務,當執行緒池中的執行緒數目達到corePoolSize
後,就會把到達的任務放到快取隊列當中。核心執行緒在allowCoreThreadTimeout
被設置為true
時會超時退出,默認情況下不會退出。
- maximumPoolSize 執行緒池最大執行緒數
當執行緒數大於或等於核心執行緒,且任務隊列已滿時,執行緒池會創建新的執行緒,直到執行緒數量達到maximumPoolSize
。如果執行緒數已等於maximumPoolSize
,且任務隊列已滿,則已超出執行緒池的處理能力,執行緒池會拒絕處理任務而拋出異常。
- keepAliveTime 空閑執行緒存活時間
當執行緒空閑時間達到keepAliveTime
,該執行緒會退出,直到執行緒數量等於corePoolSize
。如果allowCoreThreadTimeout
設置為true
,則所有執行緒均會退出直到執行緒數量為0
。
- unit 空間執行緒存活時間單位
keepAliveTime
的計量單位
- workQueue 工作隊列
新任務被提交後,會先進入到此工作隊列中,任務調度時再從隊列中取出任務。JDK中提供了四種工作隊列:
\(\circ\) ArrayBlockingQueue
基於數組的有界阻塞隊列,按FIFO排序。新任務進來後,會放到該隊列的隊尾,有界的數組可以防止資源耗盡問題。當執行緒池中執行緒數量達到corePoolSize
後,再有新任務進來,則會將任務放入該隊列的隊尾,等待被調度。如果隊列已經是滿的,則創建一個新執行緒,如果執行緒數量已經達到maximumPoolSize
,則會執行拒絕策略。
\(\circ\) LinkedBlockingQuene
基於鏈表的無界阻塞隊列(其實最大容量為Interger.MAX
),按照FIFO排序。由於該隊列的近似無界性,當執行緒池中執行緒數量達到corePoolSize
後,再有新任務進來,會一直存入該隊列,而不會去創建新執行緒直到maximumPoolSize
,因此使用該工作隊列時,參數maximumPoolSize
其實是不起作用的。
\(\circ\) SynchronousQuene
一個不快取任務的阻塞隊列,生產者放入一個任務必須等到消費者取出這個任務。也就是說新任務進來時,不會快取,而是直接被調度執行該任務,如果沒有可用執行緒,則創建新執行緒,如果執行緒數量達到maximumPoolSize
,則執行拒絕策略。
\(\circ\) PriorityBlockingQueue
具有優先順序的無界阻塞隊列,優先順序通過參數Comparator
實現。
- threadFactory 執行緒工廠
創建一個新執行緒時使用的工廠,可以用來設定執行緒名、是否為daemon執行緒、Thread.UncaughtExceptionHandler
等等。
- handler 拒絕策略
當工作隊列中的任務已到達最大限制,並且執行緒池中的執行緒數量也達到最大限制,這時如果有新任務提交進來,該如何處理呢。這裡的拒絕策略,就是解決這個問題的,JDK中提供了4中拒絕策略:
\(\circ\) CallerRunsPolicy
該策略下,在調用者執行緒中直接執行被拒絕任務的run方法,除非執行緒池已經shutdown
,則直接拋棄任務。
\(\circ\) AbortPolicy
該策略下,直接丟棄任務,並拋出RejectedExecutionException
異常。
\(\circ\) DiscardPolicy
該策略下,直接丟棄任務,什麼都不做。
\(\circ\) DiscardOldestPolicy
該策略下,拋棄進入隊列最早的那個任務,然後嘗試把這次拒絕的任務放入隊列
條件隊列
條件隊列使得一組執行緒(稱之為等待執行緒集合)能夠通過某種方式來等待特定的條件變成真。傳統隊列的元素是一個個數據,而與之不同的是,條件隊列中的元素是一個個正在等待相關條件的執行緒。
正如每個Java對象都可以作為一個鎖,每個對象同樣可以作為一個條件隊列,並且Object
中wait
、notify
和notifyAll
方法就構成了內部條件隊列的API。對象的內置鎖與其內部條件隊列是相互關聯的,要調用對象X中條件隊列的任何一個方法,必須持有對象X上的鎖。這就是因為「等待由狀態構成的條件」與「維護狀態一致性」這兩種機制必須被緊密地綁定在一起:只有能對狀態進行檢查時,才能在某個條件上等待,並且只有能修改狀態時,才能從條件等待中釋放一個執行緒。
當使用條件等待時(例如Object.wait
和Condition.await
)
- 通常都有一個條件謂詞,包括一些對象狀態的測試,執行緒在執行前必須首先通過這些測試
- 在調用
wait
之前測試條件謂詞,並且從wait
中返回是再次進行測試 - 在一個循環中調用
wait
- 確保使用與條件隊列相關的鎖來保護構成條件謂詞的各個狀態變數
- 當調用
wait
、notify
或notifyAll
等方法時,一定要持有與條件隊列相關的鎖 - 在檢查條件謂詞之後以及開始執行相應的操作之前,不要釋放鎖
降低鎖競爭程度的幾種方式
- 減少鎖的持有時間
- 降低鎖的請求頻率
- 使用帶有協調機制的獨佔鎖,這些機制允許更高的並發性
CAS操作
CAS包含3個操作數:需要讀寫的記憶體位置V、進行比較的值A和擬寫入的新值B。當且僅當V的值等於A時,CAS才會通過原子方式用新值B來更新V的值,否則不會執行任何操作。無論位置V的值是否等於A,都將返回V原有的值。
CAS的主要缺點是:它將使調用者處理競爭問題(通過重試、回退、放棄),而在鎖中能自動處理競爭問題,同時CAS還會出現ABA的問題。
Java記憶體模型(JMM)
在共享記憶體的多處理器體系架構中,每個處理器都擁有自己的快取,並且定期地與主記憶體進行協調。在不同的處理器架構中提供了不同級別的快取一致性(Cache Coherence),其中一部分只提供最小的保證,即允許不同的處理器在任意時刻從同一個存儲位置上看到不同的值。作業系統、編譯器以及運行時(有時甚至包括應用程式)需要彌合這種硬體能力與執行緒安全需求之間的差異。
Java記憶體模型是通過各種操作來定義的,包括對變數的讀寫操作,監視器的加鎖和釋放操作,以及執行緒啟動和合併操作。JMM為程式中所有的操作定義了一個偏序關係,稱之為Happens-Before。如果兩個操作之間缺乏Happens-Before關係,那麼JVM可以對它們任意的重排序。
當一個變數被多個執行緒讀取並且至少被一個執行緒寫入時,如果在讀操作和寫操作之間沒有依照Happens-Before來排序,那麼就會產生數據競爭的問題。在正確同步的程式中不存在數據競爭,並會表現出串列一致性,這意味著程式中的所有操作都會按照一種固定的和全局的順序執行。
Happens-Before的規則包括:
- 程式順序規則。如果程式中操作A在操作B之前,那麼在執行緒中A操作將在B操作之前執行。
- 監視器鎖規則。在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前執行。
- volatile變數規則。 對volatile變數的寫入操作必須在對該變數的讀操作之前執行。
- 執行緒啟動規則。在執行緒上對
Thread.start()
的調用必須在該執行緒中執行任何操作之前執行。 - 執行緒結束規則。執行緒中的任何操作都必須在其他執行緒檢測到該執行緒已經結束之前執行,或者從
Thread.join()
中成功返回,或者在調用Thread.isAlive()
時返回false
。 - 中斷規則。當一個執行緒在另一個執行緒上調用
interrupt
時,必須在被中斷執行緒檢測到interrupt
調用之前執行(通過拋出InterruptedException
,或者調用isInterrupted
和interrupted
)。 - 終結器規則。對象的構造函數必須啟動在該對象的終結器之前執行完成。
- 傳遞性。如果操作A在操作B之前執行,並且操作B在操作C之前執行,那麼操作A必須在操作C之前執行。