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


系統性能優化的幾種常用手段是異步和緩存。因此我們常常使用線程池異步處理一些業務。

線程池的使用還是相對比較簡單的,首先創建一個線程池,然后通過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------




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



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM