最近閱讀了《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
會一直等到佇列中的所有任務都執行完成後才關閉。在其他擁有執行緒的服務中也應該考慮提供類似的關閉方式以供選擇。
- 正常關閉
try{
// 正常關閉
executorService.shutdown();
// 等待指定時間直到結束,超時會丟擲InterruptedException異常
executorService.awaitTermination(timeout, unit);
}catch(InterruptedException ex){
// do something
}
- 強行關閉
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中提供了四種工作佇列:
-
ArrayBlockingQueue
基於陣列的有界阻塞佇列,按FIFO排序。新任務進來後,會放到該佇列的隊尾,有界的陣列可以防止資源耗盡問題。當執行緒池中執行緒數量達到corePoolSize
後,再有新任務進來,則會將任務放入該佇列的隊尾,等待被排程。如果佇列已經是滿的,則建立一個新執行緒,如果執行緒數量已經達到maximumPoolSize
,則會執行拒絕策略。 -
LinkedBlockingQuene
基於連結串列的無界阻塞佇列(其實最大容量為Interger.MAX
),按照FIFO排序。由於該佇列的近似無界性,當執行緒池中執行緒數量達到corePoolSize
後,再有新任務進來,會一直存入該佇列,而不會去建立新執行緒直到maximumPoolSize
,因此使用該工作佇列時,引數maximumPoolSize
其實是不起作用的。 -
SynchronousQuene
一個不快取任務的阻塞佇列,生產者放入一個任務必須等到消費者取出這個任務。也就是說新任務進來時,不會快取,而是直接被排程執行該任務,如果沒有可用執行緒,則建立新執行緒,如果執行緒數量達到maximumPoolSize
,則執行拒絕策略。 -
PriorityBlockingQueue
具有優先順序的無界阻塞佇列,優先順序通過引數Comparator
實現。
- threadFactory 執行緒工廠
建立一個新執行緒時使用的工廠,可以用來設定執行緒名、是否為daemon執行緒、Thread.UncaughtExceptionHandler
等等。
- handler 拒絕策略
當工作佇列中的任務已到達最大限制,並且執行緒池中的執行緒數量也達到最大限制,這時如果有新任務提交進來,該如何處理呢。這裡的拒絕策略,就是解決這個問題的,JDK中提供了4中拒絕策略:
-
CallerRunsPolicy
該策略下,在呼叫者執行緒中直接執行被拒絕任務的run方法,除非執行緒池已經shutdown
,則直接拋棄任務。 -
AbortPolicy
該策略下,直接丟棄任務,並丟擲RejectedExecutionException
異常。 -
DiscardPolicy
該策略下,直接丟棄任務,什麼都不做。 -
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之前執行。