JAVA多執行緒使用場景和注意事項

小姐姐味道發表於2019-03-13

我曾經對自己的小弟說,如果你實在搞不清楚什麼時候用HashMap,什麼時候用ConcurrentHashMap,那麼就用後者,你的程式碼bug會很少。

他問我:ConcurrentHashMap是什麼? -.-

程式設計不是炫技。大多數情況下,怎麼把程式碼寫簡單,才是能力。

多執行緒生來就是複雜的,也是容易出錯的。一些難以理解的概念,要規避。本文不講基礎知識,因為你手裡就有jdk的原始碼。

JAVA多執行緒使用場景和注意事項

執行緒

Thread

第一類就是Thread類。大家都知道有兩種實現方式。第一可以繼承Thread覆蓋它的run方法;第二種是實現Runnable介面,實現它的run方法;而第三種建立執行緒的方法,就是通過執行緒池。

我們的具體程式碼實現,就放在run方法中。

我們關注兩種情況。一個是執行緒退出條件,一個是異常處理情況。

執行緒退出

有的run方法執行完成後,執行緒就會退出。但有的run方法是永遠不會結束的。結束一個執行緒肯定不是通過Thread.stop()方法,這個方法已經在java1.2版本就廢棄了。所以我們大體有兩種方式控制執行緒。

定義退出標誌放在while中

程式碼一般長這樣。

private volatile boolean flag= true;
public void run() {
    while (flag) {
    }
}
複製程式碼

標誌一般使用volatile進行修飾,使其讀可見,然後通過設定這個值來控制執行緒的執行,這已經成了約定俗成的套路。

使用interrupt方法終止執行緒

類似這種。

while(!isInterrupted()){……}
複製程式碼

對於InterruptedException,比如Thread.sleep所丟擲的,我們一般是補獲它,然後靜悄悄的忽略。中斷允許一個可取消任務來清理正在進行的工作,然後通知其他任務它要被取消,最後才終止,在這種情況下,此類異常需要被仔細處理。

interrupt方法不一定會真正”中斷”執行緒,它只是一種協作機制。interrupt方法通常不能中斷一些處於阻塞狀態的I/O操作。比如寫檔案,或者socket傳輸等。這種情況,需要同時呼叫正在阻塞操作的close方法,才能夠正常退出。

interrupt系列使用時候一定要注意,會引入bug,甚至死鎖。

異常處理

java中會丟擲兩種異常。一種是必須要捕獲的,比如InterruptedException,否則無法通過編譯;另外一種是可以處理也可以不處理的,比如NullPointerException等。

在我們的任務執行中,很有可能丟擲這兩種異常。對於第一種異常,是必須放在try,catch中的。但第二種異常如果不去處理的話,會影響任務的正常執行。

有很多同學在處理迴圈的任務時,沒有捕獲一些隱式的異常,造成任務在遇到異常的情況下,並不能繼續執行下去。如果不能確定異常的種類,可以直接捕獲Exception或者更通用的Throwable。

while(!isInterrupted()){
    try{
        ……
    }catch(Exception ex){
        ……
    }
}
複製程式碼

同步方式

java中實現同步的方式有很多,大體分為以下幾種。

  • synchronized 關鍵字
  • wait、notify等
  • Concurrent包中的ReentrantLock
  • volatile關鍵字
  • ThreadLocal區域性變數

生產者、消費者是wait、notify最典型的應用場景,這些函式的呼叫,是必須要放在synchronized程式碼塊裡才能夠正常執行的。它們同訊號量一樣,大多數情況下屬於炫技,對程式碼的可讀性影響較大,不推薦。關於ObjectMonitor相關的幾個函式,只要搞懂下面的圖,就基本ok了。

JAVA多執行緒使用場景和注意事項

使用ReentrantLock最容易發生錯誤的就是忘記在finally程式碼塊裡關閉鎖。大多數同步場景下,使用Lock就足夠了,而且它還有讀寫鎖的概念進行粒度上的控制。我們一般都使用非公平鎖,讓任務自由競爭。非公平鎖效能高於公平鎖效能,非公平鎖能更充分的利用cpu的時間片,儘量的減少cpu空閒的狀態時間。非公平鎖還會造成餓死現象:有些任務一直獲取不到鎖。

synchronized通過鎖升級機制,速度不見得就比lock慢。而且,通過jstack,能夠方便的看到其堆疊,使用還是比較廣泛。

volatile總是能保證變數的讀可見,但它的目標是基本型別和它鎖的基本物件。假如是它修飾的是集合類,比如Map,那麼它保證的讀可見是map的引用,而不是map物件,這點一定要注意。

synchronized和volatile都體現在位元組碼上(monitorenter、monitorexit),主要是加入了記憶體屏障。而Lock,是純粹的java api。

ThreadLocal很方便,每個執行緒一份資料,也很安全,但要注意記憶體洩露。假如執行緒存活時間長,我們要保證每次使用完ThreadLocal,都呼叫它的remove()方法(具體來說是expungeStaleEntry),來清除資料。

關於Concurrent包

concurrent包是在AQS的基礎上搭建起來的,AQS提供了一種實現阻塞鎖和一系列依賴FIFO等待佇列的同步器的框架。

執行緒池

最全的執行緒池大概有7個引數,想要合理使用執行緒池,肯定不會不會放過這些引數的優化。

執行緒池引數

concurrent包最常用的就是執行緒池,平常工作建議直接使用執行緒池,Thread類就可以降低優先順序了。我們常用的主要有newSingleThreadExecutor、newFixedThreadPool、newCachedThreadPool、排程等,使用Executors工廠類建立。

newSingleThreadExecutor可以用於快速建立一個非同步執行緒,非常方便。而newCachedThreadPool永遠不要用在高併發的線上環境,它用的是無界佇列對任務進行緩衝,可能會擠爆你的記憶體。

我習慣性自定義ThreadPoolExecutor,也就是引數最全的那個。

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) 
複製程式碼

假如我的任務可以預估,corePoolSize,maximumPoolSize一般都設成一樣大的,然後存活時間設的特別的長。可以避免執行緒頻繁建立、關閉的開銷。I/O密集型和CPU密集型的應用執行緒開的大小是不一樣的,一般I/O密集型的應用執行緒就可以開的多一些。

threadFactory我一般也會定義一個,主要是給執行緒們起一個名字。這樣,在使用jstack等一些工具的時候,能夠直觀的看到我所建立的執行緒。

監控

高併發下的執行緒池,最好能夠監控起來。可以使用日誌、儲存等方式儲存下來,對後續的問題排查幫助很大。

通常,可以通過繼承ThreadPoolExecutor,覆蓋beforeExecute、afterExecute、terminated方法,達到對執行緒行為的控制和監控。

執行緒池飽和策略

最容易被遺忘的可能就是執行緒的飽和策略了。也就是執行緒和緩衝佇列的空間全部用完了,新加入的任務將如何處置。jdk預設實現了4種策略,預設實現的是AbortPolicy,也就是直接丟擲異常。下面介紹其他幾種。

DiscardPolicy 比abort更加激進,直接丟掉任務,連異常資訊都沒有。

CallerRunsPolicy 由呼叫的執行緒來處理這個任務。比如一個web應用中,執行緒池資源佔滿後,新進的任務將會在tomcat執行緒中執行。這種方式能夠延緩部分任務的執行壓力,但在更多情況下,會直接阻塞主執行緒的執行。

DiscardOldestPolicy 丟棄佇列最前面的任務,然後重新嘗試執行任務(重複此過程)。

很多情況下,這些飽和策略可能並不能滿足你的需求,你可以自定義自己的策略,比如將任務持久化到一些儲存中。

阻塞佇列

阻塞佇列會對當前的執行緒進行阻塞。當佇列中有元素後,被阻塞的執行緒會自動被喚醒,這極大的提高的編碼的靈活性,非常方便。在併發程式設計中,一般推薦使用阻塞佇列,這樣實現可以儘量地避免程式出現意外的錯誤。阻塞佇列使用最經典的場景就是socket資料的讀取、解析,讀資料的執行緒不斷將資料放入佇列,解析執行緒不斷從佇列取資料進行處理。

ArrayBlockingQueue對訪問者的呼叫預設是不公平的,我們可以通過設定構造方法引數將其改成公平阻塞佇列。

LinkedBlockingQueue佇列的預設最大長度為Integer.MAX_VALUE,這在用做執行緒池佇列的時候,會比較危險。

SynchronousQueue是一個不儲存元素的阻塞佇列。每一個put操作必須等待一個take操作,否則不能繼續新增元素。佇列本身不儲存任何元素,吞吐量非常高。對於提交的任務,如果有空閒執行緒,則使用空閒執行緒來處理;否則新建一個執行緒來處理任務”。它更像是一個管道,在一些通訊框架中(比如rpc),通常用來快速處理某個請求,應用較為廣泛。

DelayQueue是一個支援延時獲取元素的無界阻塞佇列。放入DelayQueue的物件需要實現Delayed介面,主要是提供一個延遲的時間,以及用於延遲佇列內部比較排序。這種方式通常能夠比大多數非阻塞的while迴圈更加節省cpu資源。

另外還有PriorityBlockingQueue和LinkedTransferQueue等,根據字面意思就能猜測它的用途。線上程池的構造引數中,我們使用的佇列,一定要注意其特性和邊界。比如,即使是最簡單的newFixedThreadPool,在某些場景下,也是不安全的,因為它使用了無界佇列。

CountDownLatch

假如有一堆介面A-Y,每個介面的耗時最大是200ms,最小是100ms。

我的一個服務,需要提供一個介面Z,呼叫A-Y介面對結果進行聚合。介面的呼叫沒有順序需求,介面Z如何在300ms內返回這些資料?

此類問題典型的還有賽馬問題,只有通過平行計算才能完成問題。歸結起來可以分為兩類:

  • 實現任務的並行性
  • 開始執行前等待n個執行緒完成任務

在concurrent包出現之前,需要手工的編寫這些同步過程,非常複雜。現在就可以使用CountDownLatch和CyclicBarrier進行便捷的編碼。

CountDownLatch是通過一個計數器來實現的,計數器的初始值為執行緒的數量。每當一個執行緒完成了自己的任務後,計數器的值就會減1。當計數器值到達0時,它表示所有的執行緒已經完成了任務,然後在閉鎖上等待的執行緒就可以恢復執行任務。 CyclicBarrier與其類似,可以實現同樣的功能。不過在日常的工作中,使用CountDownLatch會更頻繁一些。

訊號量

Semaphore雖然有一些應用場景,但大部分屬於炫技,在編碼中應該儘量少用。

訊號量可以實現限流的功能,但它只是常用限流方式的一種。其他兩種是漏桶演算法、令牌桶演算法。

hystrix的熔斷功能,也有使用訊號量進行資源的控制。

Lock && Condition

在Java中,對於Lock和Condition可以理解為對傳統的synchronized和wait/notify機制的替代。concurrent包中的許多阻塞佇列,就是使用Condition實現的。

但這些類和函式對於初中級碼農來說,難以理解,容易產生bug,應該在業務程式碼中嚴格禁止。但在網路程式設計、或者一些框架類工程中,這些功能是必須的,萬不可將這部分的工作隨便分配給某個小弟。

End

不管是wait、notify,還是同步關鍵字或者鎖,能不用就不用,因為它們會引發程式的複雜性。最好的方式,是直接使用concurrent包所提供的機制,來規避一些編碼方面的問題。

concurrent包中的CAS概念,在一定程度上算是無鎖的一種實現。更專業的有類似disruptor的無鎖佇列框架,但它依然是建立在CAS的程式設計模型上的。近些年,類似AKKA這樣的事件驅動模型正在走紅,但程式設計模型簡單,不代表實現簡單,背後的工作依然需要多執行緒去協調。

golang引入協程(coroutine)概念以後,對多執行緒加入了更加輕量級的補充。java中可以通過javaagent技術載入quasar補充一些功能,但我覺得你不會為了這丁點效率去犧牲編碼的可讀性。

JAVA多執行緒使用場景和注意事項

相關文章