系統效能優化的幾種常用手段是非同步和快取。因此我們常常使用執行緒池非同步處理一些業務。
執行緒池的使用還是相對比較簡單的,首先建立一個執行緒池,然後通過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------
如果這個辦法對您有用,或者您希望持續關注,也可以掃描下方二維碼或者在微信公眾號中搜尋【碼路無涯】