(轉)一次Java線程池誤用引發的血案和總結

【轉載原因:針對問題,對線程池非常詳細講解】

【轉載原文:https://zhuanlan.zhihu.com/p/32867181?utm_source=wechat_session&utm_medium=social

這是一個十分嚴重的問題

自從最近的某年某月某天起,線上服務開始變得不那麼穩定。在高峯期,時常有幾臺機器的內存持續飆升,並且無法回收,導致服務不可用。

例如GC時間採樣曲線:

和內存使用曲線:

圖中所示,18:50-19:00的階段,已經處於服務不可用的狀態了。上游服務的超時異常會增加,該臺機器會觸發熔斷。熔斷觸發後,改臺機器的流量會打到其他機器,其他機器發生類似的情況的可能性會提高,極端情況會引起所有服務宕機,曲線掉底。

因爲線上內存過大,如果採用 jmap dump的方式,這個任務可能需要很久纔可以執行完,同時把這麼大的文件存放起來導入工具也是一件很難的事情。再看JVM啓動參數,也很久沒有變更過 Xms, Xmx, -XX:NewRatio, -XX:SurvivorRatio, 雖然沒有仔細分析程序使用內存情況,但看起來也無大礙。

於是開始找代碼,某年某天某月~ 嗯,注意到一段這樣的代碼提交:

private static ExecutorService executor = Executors.newFixedThreadPool(15);
public static void push2Kafka(Object msg) {
    executor.execute(new WriteTask(msg,  false));    
}

相關代碼的完整功能是,每次線上調用,都會把計算結果的日誌打到 Kafka,Kafka消費方再繼續後續的邏輯。內存被耗儘可能有一個原因是,因爲使用了 newFixedThreadPool 線程池,而它的工作機制是,固定了N個線程,而提交給線程池的任務隊列是不限制大小的,如果Kafka發消息被阻塞或者變慢,那麼顯然隊列裏面的內容會越來越多,也就會導致這樣的問題。

爲了驗證這個想法,做了個小實驗,把 newFixedThreadPool 線程池的線程個數調小一點,例如 1。果然壓測了一下,很快就復現了內存耗盡,服務不可用的悲劇。

最後的修復策略是使用了自定義的線程池參數,而非 Executors 默認實現解決了問題。下面就把線程池相關的原理和參數總結一下,避免未來踩坑。

1. Java線程池

雖然Java線程池理論,以及構造線程池的各種參數,以及 Executors 提供的默認實現之前研讀過,不過線上還沒有發生過線程池誤用引發的事故,所以有必要把這些參數再仔細琢磨一遍。

優先補充一些線程池的工作理論,有助於展開下面的內容。線程池顧名思義,就是由很多線程構成的池子,來一個任務,就從池子中取一個線程,處理這個任務。這個理解是我在第一次接觸到這個概念時候的理解,雖然整體基本切入到核心,但是實際上會比這個複雜。例如線程池肯定不會無限擴大的,否則資源會耗盡;當線程數到達一個階段,提交的任務會被暫時存儲在一個隊列中,如果隊列內容可以不斷擴大,極端下也會耗盡資源,那選擇什麼類型的隊列,當隊列滿如何處理任務,都有涉及很多內容。線程池總體的工作過程如下圖:

線程池內的線程數的大小相關的概念有兩個,一個是核心池大小,還有最大池大小。如果當前的線程個數比核心池個數小,當任務到來,會優先創建一個新的線程並執行任務。當已經到達核心池大小,則把任務放入隊列,爲了資源不被耗盡,隊列的最大容量可能也是有上限的,如果達到隊列上限則考慮繼續創建新線程執行任務,如果此刻線程的個數已經到達最大池上限,則考慮把任務丟棄。

在 java.util.concurrent 包中,提供了 ThreadPoolExecutor 的實現。

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
} 

既然有了剛剛對線程池工作原理對概述,這些參數就很容易理解了:

corePoolSize- 核心池大小,既然如前原理部分所述。需要注意的是在初創建線程池時線程不會立即啓動,直到有任務提交纔開始啓動線程並逐漸時線程數目達到corePoolSize。若想一開始就創建所有核心線程需調用prestartAllCoreThreads方法。

maximumPoolSize-池中允許的最大線程數。需要注意的是當核心線程滿且阻塞隊列也滿時纔會判斷當前線程數是否小於最大線程數,並決定是否創建新線程。

keepAliveTime - 當線程數大於核心時,多於的空閒線程最多存活時間

unit - keepAliveTime 參數的時間單位。

workQueue - 當線程數目超過核心線程數時用於保存任務的隊列。主要有3種類型的BlockingQueue可供選擇:無界隊列,有界隊列和同步移交。將在下文中詳細闡述。從參數中可以看到,此隊列僅保存實現Runnable接口的任務。 別看這個參數位置很靠後,但是真的很重要,因爲樓主的坑就因這個參數而起,這些細節有必要仔細瞭解清楚。

threadFactory - 執行程序創建新線程時使用的工廠。

handler - 阻塞隊列已滿且線程數達到最大值時所採取的飽和策略。java默認提供了4種飽和策略的實現方式:中止、拋棄、拋棄最舊的、調用者運行。將在下文中詳細闡述。

2. 可選擇的阻塞隊列BlockingQueue詳解

在重複一下新任務進入時線程池的執行策略:
如果運行的線程少於corePoolSize,則 Executor始終首選添加新的線程,而不進行排隊。(如果當前運行的線程小於corePoolSize,則任務根本不會存入queue中,而是直接運行)
如果運行的線程大於等於 corePoolSize,則 Executor始終首選將請求加入隊列,而不添加新的線程。
如果無法將請求加入隊列,則創建新的線程,除非創建此線程超出 maximumPoolSize,在這種情況下,任務將被拒絕。
主要有3種類型的BlockingQueue:

無界隊列

隊列大小無限制,常用的爲無界的LinkedBlockingQueue,使用該隊列做爲阻塞隊列時要尤其當心,當任務耗時較長時可能會導致大量新任務在隊列中堆積最終導致OOM。閱讀代碼發現,Executors.newFixedThreadPool 採用就是 LinkedBlockingQueue,而樓主踩到的就是這個坑,當QPS很高,發送數據很大,大量的任務被添加到這個無界LinkedBlockingQueue 中,導致cpu和內存飆升服務器掛掉。

有界隊列

常用的有兩類,一類是遵循FIFO原則的隊列如ArrayBlockingQueue與有界的LinkedBlockingQueue,另一類是優先級隊列如PriorityBlockingQueue。PriorityBlockingQueue中的優先級由任務的Comparator決定。
使用有界隊列時隊列大小需和線程池大小互相配合,線程池較小有界隊列較大時可減少內存消耗,降低cpu使用率和上下文切換,但是可能會限制系統吞吐量。

在我們的修復方案中,選擇的就是這個類型的隊列,雖然會有部分任務被丟失,但是我們線上是排序日誌蒐集任務,所以對部分對丟失是可以容忍的。

同步移交隊列

如果不希望任務在隊列中等待而是希望將任務直接移交給工作線程,可使用SynchronousQueue作爲等待隊列。SynchronousQueue不是一個真正的隊列,而是一種線程之間移交的機制。要將一個元素放入SynchronousQueue中,必須有另一個線程正在等待接收這個元素。只有在使用無界線程池或者有飽和策略時才建議使用該隊列。

3. 可選擇的飽和策略RejectedExecutionHandler詳解

JDK主要提供了4種飽和策略供選擇。4種策略都做爲靜態內部類在ThreadPoolExcutor中進行實現。

3.1 AbortPolicy中止策略

該策略是默認飽和策略。

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());
 } 

使用該策略時在飽和時會拋出RejectedExecutionException(繼承自RuntimeException),調用者可捕獲該異常自行處理。

3.2 DiscardPolicy拋棄策略

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
}

如代碼所示,不做任何處理直接拋棄任務

3.3 DiscardOldestPolicy拋棄舊任務策略

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                e.getQueue().poll();
                e.execute(r);
            }
} 

如代碼,先將阻塞隊列中的頭元素出隊拋棄,再嘗試提交任務。如果此時阻塞隊列使用PriorityBlockingQueue優先級隊列,將會導致優先級最高的任務被拋棄,因此不建議將該種策略配合優先級隊列使用。

3.4 CallerRunsPolicy調用者運行

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                r.run();
            }
} 

既不拋棄任務也不拋出異常,直接運行任務的run方法,換言之將任務回退給調用者來直接運行。使用該策略時線程池飽和後將由調用線程池的主線程自己來執行任務,因此在執行任務的這段時間裏主線程無法再提交新任務,從而使線程池中工作線程有時間將正在處理的任務處理完成。

4. Java提供的四種常用線程池解析

既然樓主踩坑就是使用了 JDK 的默認實現,那麼再來看看這些默認實現到底幹了什麼,封裝了哪些參數。簡而言之 Executors 工廠方法Executors.newCachedThreadPool() 提供了無界線程池,可以進行自動線程回收;Executors.newFixedThreadPool(int) 提供了固定大小線程池,內部使用無界隊列;Executors.newSingleThreadExecutor() 提供了單個後臺線程。

詳細介紹一下上述四種線程池。

4.1 newCachedThreadPool

public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
} 

在newCachedThreadPool中如果線程池長度超過處理需要,可靈活回收空閒線程,若無可回收,則新建線程。
初看該構造函數時我有這樣的疑惑:核心線程池爲0,那按照前面所講的線程池策略新任務來臨時無法進入核心線程池,只能進入 SynchronousQueue中進行等待,而SynchronousQueue的大小爲1,那豈不是第一個任務到達時只能等待在隊列中,直到第二個任務到達發現無法進入隊列才能創建第一個線程?
這個問題的答案在上面講SynchronousQueue時其實已經給出了,要將一個元素放入SynchronousQueue中,必須有另一個線程正在等待接收這個元素。因此即便SynchronousQueue一開始爲空且大小爲1,第一個任務也無法放入其中,因爲沒有線程在等待從SynchronousQueue中取走元素。因此第一個任務到達時便會創建一個新線程執行該任務。

4.2 newFixedThreadPool

 public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
 }

看代碼一目瞭然了,線程數量固定,使用無限大的隊列。再次強調,樓主就是踩的這個無限大隊列的坑。

4.3 newScheduledThreadPool

創建一個定長線程池,支持定時及週期性任務執行。

public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
}

在來看看ScheduledThreadPoolExecutor()的構造函數

 public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    } 

ScheduledThreadPoolExecutor的父類即ThreadPoolExecutor,因此這裏各參數含義和上面一樣。值得關心的是DelayedWorkQueue這個阻塞對列,在上面沒有介紹,它作爲靜態內部類就在ScheduledThreadPoolExecutor中進行了實現。簡單的說,DelayedWorkQueue是一個無界隊列,它能按一定的順序對工作隊列中的元素進行排列。

4.4 newSingleThreadExecutor

創建一個單線程化的線程池,它只會用唯一的工作線程來執行任務,保證所有任務按照指定順序(FIFO, LIFO, 優先級)執行。

public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
 } 

首先new了一個線程數目爲 1 的ScheduledThreadPoolExecutor,再把該對象傳入DelegatedScheduledExecutorService中,看看DelegatedScheduledExecutorService的實現代碼:

DelegatedScheduledExecutorService(ScheduledExecutorService executor) {
            super(executor);
            e = executor;
} 

在看看它的父類

DelegatedExecutorService(ExecutorService executor) { 
           e = executor; 
} 

其實就是使用裝飾模式增強了ScheduledExecutorService(1)的功能,不僅確保只有一個線程順序執行任務,也保證線程意外終止後會重新創建一個線程繼續執行任務。

結束語

雖然之前學習了不少相關知識,但是只有在實踐中踩坑才能印象深刻吧

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章