JAVA多線程使用場景和注意事項

我曾經對本身的小弟說,若是你實在搞不清楚何時用HashMap,何時用ConcurrentHashMap,那麼就用後者,你的代碼bug會不多。java

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

編程不是炫技。大多數狀況下,怎麼把代碼寫簡單,纔是能力。web

多線程生來就是複雜的,也是容易出錯的。一些難以理解的概念,要規避。本文不講基礎知識,由於你手裏就有jdk的源碼。 算法

線程

Thread

第一類就是Thread類。你們都知道有兩種實現方式。第一能夠繼承Thread覆蓋它的run方法;第二種是實現Runnable接口,實現它的run方法;而第三種建立線程的方法,就是經過線程池。編程

咱們的具體代碼實現,就放在run方法中。api

咱們關注兩種狀況。一個是線程退出條件,一個是異常處理狀況。tomcat

線程退出

有的run方法執行完成後,線程就會退出。但有的run方法是永遠不會結束的。結束一個線程確定不是經過Thread.stop()方法,這個方法已經在java1.2版本就廢棄了。因此咱們大致有兩種方式控制線程。安全

定義退出標誌放在while中bash

代碼通常長這樣。網絡

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了。

使用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補充一些功能,但我以爲你不會爲了這丁點效率去犧牲編碼的可讀性。

相關文章
相關標籤/搜索