Java併發程式設計實踐

PinXiong發表於2020-07-12

最近閱讀了《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介面),並且該處理器至少會將異常資訊記錄到日誌中。

如果你希望在任務由於發生異常和失敗時獲得通知,並且執行一些特定於任務的居處操作,那麼可以將任務封裝在能捕獲異常的RunnableCallable中,或者改寫ThreadPoolExecutor.afterExecute()方法。
只有通過execute()提交的任務,才能將它丟擲的異常交給未捕獲異常處理器,而通過submit提交的任務的異常都被封裝在Future.get()ExecutionException中重新丟擲。

JVM關閉

關閉鉤子是指通過Runtime.addShutdownHook註冊的但尚未開始的執行緒。JVM並不能保證關閉鉤子的呼叫順序。在關閉應用程式執行緒時,如果有(守護或非守護)執行緒仍然在執行,那麼這些執行緒接下來將與關閉程式併發執行。當所有的關閉鉤子都執行結束時,如果runFinalizersOnExittrue,那麼JVM將執行終結器,然後再停止。

關閉鉤子應該是執行緒安全的。它們在訪問共享資料時必須使用同步機制,並且小心地避免發生死鎖,這與其他併發程式碼的要求相同。而且,關閉鉤子不應該對應用程式的狀態或者JVM的關閉原因做出任何假設,因此在編寫關閉鉤子的程式碼時必須考慮周全。

關閉ExecutorService

ExecutorService提供了兩種關閉方法:

  • ExecutorService.shutdown():正常關閉
  • ExecutorService.shutdownNow():強行關閉
    這兩種關閉方式的差別在於各自的安全性響應性:強行關閉的速度更快,但風險也更大,因為任務很可能在執行到一半時被結束;而正常關閉雖然速度慢,但卻更安全,因為ExecutorService會一直等到佇列中的所有任務都執行完成後才關閉。在其他擁有執行緒的服務中也應該考慮提供類似的關閉方式以供選擇。
  1. 正常關閉
try{
    // 正常關閉
    executorService.shutdown(); 
    // 等待指定時間直到結束,超時會丟擲InterruptedException異常
    executorService.awaitTermination(timeout, unit); 
}catch(InterruptedException ex){
    // do something
}
  1. 強行關閉
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中提供了四種工作佇列:

  1. ArrayBlockingQueue 基於陣列的有界阻塞佇列,按FIFO排序。新任務進來後,會放到該佇列的隊尾,有界的陣列可以防止資源耗盡問題。當執行緒池中執行緒數量達到corePoolSize後,再有新任務進來,則會將任務放入該佇列的隊尾,等待被排程。如果佇列已經是滿的,則建立一個新執行緒,如果執行緒數量已經達到maximumPoolSize,則會執行拒絕策略。

  2. LinkedBlockingQuene 基於連結串列的無界阻塞佇列(其實最大容量為Interger.MAX),按照FIFO排序。由於該佇列的近似無界性,當執行緒池中執行緒數量達到corePoolSize後,再有新任務進來,會一直存入該佇列,而不會去建立新執行緒直到maximumPoolSize,因此使用該工作佇列時,引數maximumPoolSize其實是不起作用的。

  3. SynchronousQuene 一個不快取任務的阻塞佇列,生產者放入一個任務必須等到消費者取出這個任務。也就是說新任務進來時,不會快取,而是直接被排程執行該任務,如果沒有可用執行緒,則建立新執行緒,如果執行緒數量達到maximumPoolSize,則執行拒絕策略。

  4. PriorityBlockingQueue 具有優先順序的無界阻塞佇列,優先順序通過引數Comparator實現。

  • threadFactory 執行緒工廠

建立一個新執行緒時使用的工廠,可以用來設定執行緒名是否為daemon執行緒Thread.UncaughtExceptionHandler等等。

  • handler 拒絕策略

當工作佇列中的任務已到達最大限制,並且執行緒池中的執行緒數量也達到最大限制,這時如果有新任務提交進來,該如何處理呢。這裡的拒絕策略,就是解決這個問題的,JDK中提供了4中拒絕策略:

  1. CallerRunsPolicy 該策略下,在呼叫者執行緒中直接執行被拒絕任務的run方法,除非執行緒池已經shutdown,則直接拋棄任務。

  2. AbortPolicy 該策略下,直接丟棄任務,並丟擲RejectedExecutionException異常。

  3. DiscardPolicy該策略下,直接丟棄任務,什麼都不做。

  4. DiscardOldestPolicy 該策略下,拋棄進入佇列最早的那個任務,然後嘗試把這次拒絕的任務放入佇列

條件佇列

條件佇列使得一組執行緒(稱之為等待執行緒集合)能夠通過某種方式來等待特定的條件變成真。傳統佇列的元素是一個個資料,而與之不同的是,條件佇列中的元素是一個個正在等待相關條件的執行緒。

正如每個Java物件都可以作為一個鎖,每個物件同樣可以作為一個條件佇列,並且ObjectwaitnotifynotifyAll方法就構成了內部條件佇列的API。物件的內建鎖與其內部條件佇列是相互關聯的,要呼叫物件X中條件佇列的任何一個方法,必須持有物件X上的鎖。這就是因為“等待由狀態構成的條件”與“維護狀態一致性”這兩種機制必須被緊密地繫結在一起:只有能對狀態進行檢查時,才能在某個條件上等待,並且只有能修改狀態時,才能從條件等待中釋放一個執行緒。

當使用條件等待時(例如Object.waitCondition.await

  • 通常都有一個條件謂詞,包括一些物件狀態的測試,執行緒在執行前必須首先通過這些測試
  • 在呼叫wait之前測試條件謂詞,並且從wait中返回是再次進行測試
  • 在一個迴圈中呼叫wait
  • 確保使用與條件佇列相關的鎖來保護構成條件謂詞的各個狀態變數
  • 當呼叫waitnotifynotifyAll等方法時,一定要持有與條件佇列相關的鎖
  • 在檢查條件謂詞之後以及開始執行相應的操作之前,不要釋放鎖

降低鎖競爭程度的幾種方式

  • 減少鎖的持有時間
  • 降低鎖的請求頻率
  • 使用帶有協調機制的獨佔鎖,這些機制允許更高的併發性

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,或者呼叫isInterruptedinterrupted)。
  • 終結器規則。物件的建構函式必須啟動在該物件的終結器之前執行完成。
  • 傳遞性。如果操作A在操作B之前執行,並且操作B在操作C之前執行,那麼操作A必須在操作C之前執行。

相關文章