細數執行緒池五大坑,一不小心線上就崩了

zy苦行僧發表於2021-11-01

系統效能優化的幾種常用手段是非同步和快取。因此我們常常使用執行緒池非同步處理一些業務。

執行緒池的使用還是相對比較簡單的,首先建立一個執行緒池,然後通過execute或submit執行任務。

但魔鬼往往藏於細節之中,稍有不慎就會出錯。本文將會詳細總結執行緒池容易出錯的五大坑


一、拒絕策略引數知多少
二、拒絕策略使用不當,系統阻塞不可用
三、多工get()異常時,結果獲取有誤
四、ThreadLocal與執行緒池搭配使用,上下文缺失
五、父子任務共用同一執行緒池,系統“飢餓”死鎖


以下為執行緒池的核心流程【具體內容參考:執行緒池原理
在這裡插入圖片描述

一、拒絕策略引數知多少

我們都知道,當任務過多,執行緒池處理不過來時會被拒絕,進入拒絕策略

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

通過實現RejectedExecutionHandler,就可以作為執行緒池的拒絕策略使用。
目前官方提供了四種拒絕策略,分別為:

  • CallerRunsPolicy:由任務呼叫方執行
  • AbortPolicy:丟擲異常,同樣也是由任務呼叫方處理異常
  • DiscardPolicy:丟棄當前任務
  • DiscardOldestPolicy:丟棄佇列中最老的任務,並執行當前任務

執行緒池有execute和submit兩種方法執行任務:
execute執行我們最原始的任務;
而submit則不同,先是將我們最原始的任務封裝成FutureTask任務,然後將FutureTask任務交由execute執行

執行緒池拒絕策略中Runnable r就是execute執行的任務,因此當使用r時就要注意它是我們最原始的任務還是FutureTask任務


二、拒絕策略使用不當,系統阻塞不可用

前面我們講到submit方法執行任務時,執行緒池會先封裝任務到FutureTask中,然後我們通過FutureTask的get()方法獲取任務處理的結果
【具體內容參考:一張動圖,徹底懂了execute和submit

Possible state transitions:
NEW -> COMPLETING -> NORMAL(任務執行完成)
NEW ->COMPLETING -> EXCEPTIONAL(任務丟擲異常)
NEW -> CANCELLED(任務被取消)
NEW -> INTERRUPTING -> INTERRUPTED(任務被打斷)

FutureTask在被建立時狀態為NEW,任務執行到某個階段就會修改成相應狀態,直到達到最終態。

FutureTask根據狀態變更來標識任務執行進度的,因此get()方法也是在狀態達到最終態(任務執行成果/異常/被取消/被打斷)時才能返回結果,否則掛起當前執行緒等待到達最終態。

問題原因:
1、當任務通過submit方法執行時,會建立FutureTask(此時狀態為NEW)
2、任務被拒絕且拒絕策略為丟棄任務(DiscardOleddestPolicy或DiscardPolicy)時,任務直接被執行緒池丟棄(此時狀態仍為NEW)
3、當執行get()方法時,由於任務一直處於NEW狀態,沒有達到最終態,執行緒會一直處於阻塞狀態

解決方案:
問題原因在於:任務無法變成最終態,導致阻塞。
因此我們可以重寫rejectedExecution方法,將任務置為最終態
FutureTask的cancel方法可以將任務狀態置為CANCELLED或INTERRUPTED

public static RejectedExecutionHandler customDiscardPolicy () {
  return new DiscardPolicy() {
     @Override
     public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
          if (!e.isShutdown()) {
              if (r != null && r instanceof FutureTask) {
                  ((FutureTask) r).cancel(true);
               }
           }
      }
  };
}

三、多工get()異常時,結果獲取有誤

submit方法中,futureTask會捕獲異常,在get()時丟擲。
若批量執行多個方法,且for迴圈get()結果時,捕獲異常要在迴圈內,而不是迴圈外。否則會影響其他任務的結果輸出

捕獲異常在迴圈外,當一個任務get異常時,後續其他任務就不能再獲取結果

List<TaskResult> taskResultList = new ArrayList<>();
try {
    for (Future<TaskResult> future : futureList) {
        if (future == null) {continue;}
        TaskResult result = future.get();
        taskResultList.add(result);
    }
} catch (Throwable t) {
    //這種場景下,當一個任務get異常時,後續其他任務就不能再獲取結果
    LOGGER.error("任務執行異常", t);
}

因此在迴圈內捕獲異常,各個任務互相不受影響

List<TaskResult> taskResultList = new ArrayList<>();
for (Future<TaskResult> future : futureList) {
    try {
        if (future == null) {continue;}
        TaskResult result = future.get();
        taskResultList.add(result);
    } catch (Throwable t) {
        LOGGER.error("任務執行異常", t);
    }
}

四、ThreadLocal與執行緒池搭配使用,上下文缺失

ThreadLocal的使用一般都是這幾個方法:

private final static ThreadLocal<CacheInfo> cacheInfoThreadLocal = new ThreadLocal<CacheInfo>();
​
cacheInfoThreadLocal.set(cacheInfo);
cacheInfoThreadLocal.get();
cacheInfoThreadLocal.remove();

為防止記憶體洩漏,在使用完ThreadLocal後都會呼叫remove()清除資料

問題描述:
1、當任務需要呼叫方執行緒的ThreadLocal資訊時,通用方式就是將呼叫方ThreadLocal資訊賦值到執行任務的執行緒中,在任務執行結束後呼叫remove()清除資料
2、同時任務恰好被執行緒池拒絕,且使用的拒絕策略是CallerRunsPolicy時,任務會被呼叫方執行緒執行。
3、若此時任務執行結束後仍呼叫remove()清除資料,清除的就會是呼叫方的ThreadLocal資料。
呼叫方ThreadLocal資料被清除,資料丟失在工作中將會是災難性的。

解決方案:
問題出現的原因是任務由於被拒絕,導致誤刪除了呼叫方ThreadLocal資料
因此可以在任務執行時判斷執行執行緒是否為呼叫方執行緒。
若是則不用set()複製和remove()清空資料

public abstract class ParallelCallableTask<V> implements Callable<V> {
    //呼叫方執行緒名稱
    private String mainThreadName;
    
    public ParallelCallableTask() {
        mainThreadName = Thread.currentThread().getName();
    }
​
    @Override
    public V call() throws Exception {
        //是否為同一執行緒
        boolean sameThread = sameThread();
        return proccess(sameThread);
    }
​
    /**判斷 呼叫方執行緒 和 執行執行緒 是否為同一執行緒*/
    private boolean sameThread () {
        String curThreadName = Thread.currentThread().getName();
        return curThreadName.equals(mainThreadName);
    }
    
    //任務重寫這個方法並根據sameThread判斷是否需要set和remove呼叫方執行緒的ThreadLocal資料
    public abstract V proccess(boolean sameThread);
}

待執行的任務通過重寫process方法,並根據sameThread判斷是否和主執行緒一致,一致則不重複設定相同的threadLocal和刪除threadLocal


五、父子任務共用同一執行緒池,系統“飢餓”死鎖

A方法呼叫B方法,AB方法稱為父子任務。

當他們都被同一個執行緒池執行時,一定條件下會出現以下場景:
1、父任務獲取到執行緒池執行緒執行,而子任務則被暫存到佇列中
2、當父任務佔滿了執行緒池所有的執行緒,等待子任務返回結果後,結束父任務
3、此時子任務由於在佇列中,一直不能等到執行緒來處理,導致不能從佇列中釋放
4、父子任務互相等待,從而造成“飢餓”死鎖

我們舉一個簡單例子:

假設執行緒池引數設定為:核心和最大執行緒數為1,佇列容量為1

A方法內呼叫B方法:
A() {
   B();
}

現在父子任務都被同一個執行緒池進行呼叫,整個流程為(如圖所示):
在這裡插入圖片描述

1、執行緒池建立核心執行緒,並執行A方法
2、執行到B方法時,將B交給執行緒池執行,由於沒有多餘執行緒,因此暫存佇列
3、A任務等待B任務執行完,B任務等待A任務釋放執行緒。從而互相等待,造成“飢餓”死鎖

解決方案:

問題原因在於互相等待,因此只要保證類似的父子任務不要被同一執行緒池執行即可


------The End------




如果這個辦法對您有用,或者您希望持續關注,也可以掃描下方二維碼或者在微信公眾號中搜尋【碼路無涯】


相關文章