【踩坑指南】執行緒池使用不當的五個坑

碼農談IT發表於2024-02-04

來源:程式設計師wayn

執行緒池是 Java 多執行緒程式設計中的一個重要概念,它可以有效地管理和複用執行緒資源,提高系統的效能和穩定性。但是執行緒池的使用也有一些注意事項和常見的錯誤,如果不小心,就可能會導致一些嚴重的問題,比如記憶體洩漏、死鎖、效能下降等。最後文末還有免費紅包封面可以領取,回饋給各位讀者朋友。

本文將介紹執行緒池使用不當的五個坑,以及如何避免和解決它們,大綱如下,

【踩坑指南】執行緒池使用不當的五個坑

坑一:執行緒池中異常消失

執行緒池執行方法時要新增異常處理,這是一個老生常談的問題,可是直到最近我都有同事還在犯這個錯誤,所以我還是要講一下,不過我還提到了一種優雅的執行緒池全域性異常處理的方法,大家可以往下看。

問題原因

@Test
public void test() throws Exception {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        5
        10
        60,
        TimeUnit.SECONDS, 
        new ArrayBlockingQueue<>(100000));
    Future<Integer> submit = threadPoolExecutor.execute(() -> {
        int i = 1 / 0// 發生異常
        return i;
    });
}

如上程式碼,線上程池執行任務時,沒有新增異常處理。導致任務內部發生異常時,內部錯誤無法被記錄下來。

解決方法

線上程池執行任務方法內新增 try/catch 處理,程式碼如下,

@Test
public void test() throws Exception {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        5
        10
        60,
        TimeUnit.SECONDS, 
        new ArrayBlockingQueue<>(100000));
    Future<Integer> submit = threadPoolExecutor.execute(() -> {
        try {
            int i = 1 / 0;
            return i;
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return null;
        }
    });
}

優雅的進行執行緒池異常處理

當執行緒池呼叫任務方法很多時,那麼每個執行緒池任務執行的方法內都要新增 try/catch 處理,這就不優雅了,其實 ThreadPoolExecutor 執行緒池類支援傳入 ThreadFactory 引數用於自定義執行緒工廠,這樣我們在建立執行緒時,就可以指定 setUncaughtExceptionHandler 異常處理方法。

這樣就可以做到全域性處理異常了,程式碼如下,

ThreadFactory threadFactory = r -> {
    Thread thread = new Thread(r);
    thread.setUncaughtExceptionHandler((t, e) -> {
        // 記錄執行緒異常
        log.error(e.getMessage(), e);
    });
    return thread;
};
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
    5
    10
    60,
    TimeUnit.SECONDS, 
    new ArrayBlockingQueue<>(100000));
threadPoolExecutor.execute(() -> {
    log.info("---------------------");
    int i = 1 / 0;
});

不過要注意的是上面 setUncaughtExceptionHandler 方法只能針對執行緒池的 execute 方法來全域性處理異常。對於執行緒池的 submit 方法是無法處理的。

坑二:拒絕策略設定錯誤導致介面超時

在 Java 中,執行緒池拒絕策略可以說一個常見八股文問題。大家雖然都記住了執行緒池有四種決絕策略,可是實際程式碼編寫中,我發現大多數人都只會用 CallerRunsPolicy 策略(由呼叫執行緒處理任務)。我吃過這個虧,因此也拿出來講講。

問題原因

曾經有一個線上業務介面使用了執行緒池進行第三方介面呼叫,執行緒池配置裡的拒絕策略採用的是 CallerRunsPolicy。示例程式碼如下,

// 某個線上執行緒池配置如下
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        50// 最小核心執行緒數
        50// 最大執行緒數,當佇列滿時,能建立的最大執行緒數
        60L, TimeUnit.SECONDS, // 空閒執行緒超過核心執行緒時,回收該執行緒的最大等待時間
        new LinkedBlockingQueue<>(5000), // 阻塞佇列大小,當核心執行緒使用滿時,新的執行緒會放進佇列
        new CustomizableThreadFactory("task"), // 自定義執行緒名
        new ThreadPoolExecutor.CallerRunsPolicy() // 執行緒執行的拒絕策略
);

threadPoolExecutor.execute(() -> {
    // 呼叫第三方介面
    ...
});

在第三方介面異常的情況下,執行緒池任務呼叫第三方介面一直超時,導致核心執行緒數、最大執行緒數堆積被佔滿、阻塞佇列也被佔滿的情況下,也就會執行拒絕策略,但是由於使用的是 CallerRunsPolicy 策略,導致執行緒任務直接由我們的業務執行緒來執行。

因為第三方介面異常,所以業務執行緒執行也會繼繼續超時,線上服務採用的 Tomcat 容器,最終也就導致 Tomcat 的最大執行緒數也被佔滿,進而無法繼續向外提供服務。

解決方法

首先我們要考慮業務介面的可用性,就算執行緒池任務被丟棄,也不應該影響業務介面。

在業務介面穩定性得到保證的情況下,在考慮到執行緒池任務的重要性,不是很重要的話,可以使用 DiscardPolicy 策略直接丟棄,要是很重要,可以考慮使用訊息佇列來替換執行緒池。

坑三:重複建立執行緒池導致記憶體溢位

不知道大家有沒有犯過這個問題,不過我確實犯過,歸根結底還是寫程式碼前,沒有思考好業務邏輯,直接動手,寫一步算一步 😂。所以說寫程式碼的前的一些邏輯梳理、拆分、程式碼設計很重要。

問題原因

這個問題的原因很簡單,就是在一個方法內重複建立了執行緒池,在執行完之後卻沒有關閉。比較經典的就是在定時任務內使用執行緒池時有可能犯這個問題,示例程式碼如下,

@XxlJob("test")
public void test() throws Exception {
    // 某個線上執行緒池配置如下
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            50// 最小核心執行緒數
            50// 最大執行緒數,當佇列滿時,能建立的最大執行緒數
            60L, TimeUnit.SECONDS, // 空閒執行緒超過核心執行緒時,回收該執行緒的最大等待時間
            new LinkedBlockingQueue<>(5000), // 阻塞佇列大小,當核心執行緒使用滿時,新的執行緒會放進佇列
            new CustomizableThreadFactory("task"), // 自定義執行緒名
            new ThreadPoolExecutor.CallerRunsPolicy() // 執行緒執行的拒絕策略
    );
    threadPoolExecutor.execute(() -> {
        // 任務邏輯
        ...
    });
}

當我們在定時任務中想使用執行緒池來縮短任務執行時間時,千萬要注意別再任務內建立了執行緒池,一旦犯了,基本都會在程式執行一段時間後發現程式突然間就掛了,留下了一堆記憶體 dump 報錯的檔案 😂。

解決方法

使用執行緒池單例,切勿重複建立執行緒池。示例程式碼如下,

// 某個線上執行緒池配置如下
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        50// 最小核心執行緒數
        50// 最大執行緒數,當佇列滿時,能建立的最大執行緒數
        60L, TimeUnit.SECONDS, // 空閒執行緒超過核心執行緒時,回收該執行緒的最大等待時間
        new LinkedBlockingQueue<>(5000), // 阻塞佇列大小,當核心執行緒使用滿時,新的執行緒會放進佇列
        new CustomizableThreadFactory("task"), // 自定義執行緒名
        new ThreadPoolExecutor.CallerRunsPolicy() // 執行緒執行的拒絕策略
);
@XxlJob("test")
public void test() throws Exception {
    threadPoolExecutor.execute(() -> {
        // 任務邏輯
        // ...
    });
}

坑四:共用執行緒池執行不同型別任務導致效率低下

有時候,我們可能會想要節省執行緒資源,把不同型別的任務都放到同一個執行緒池中執行,比如主要的業務邏輯和次要的日誌記錄、監控等。這看起來很合理,但是實際上,這樣做可能會導致一個任務影響另一個任務,甚至導致死鎖的問題。

問題原因

問題的原因是,不同型別的任務可能有不同的執行時間、優先順序、依賴關係等,如果放到同一個執行緒池中,就可能會出現以下幾種情況:

  • 如果一個任務執行時間過長,或者出現異常,那麼它就會佔用執行緒池中的一個執行緒,導致其他任務無法及時得到執行,影響系統的吞吐量和響應時間。
  • 如果一個任務的優先順序較低,或者不是很重要,那麼它就可能搶佔執行緒池中的一個執行緒,導致其他任務無法及時得到執行,影響系統的可用性和正確性。
  • 如果一個任務依賴於另一個任務的結果,或者需要等待另一個任務的完成,那麼它就可能造成執行緒池中的一個執行緒被阻塞,導致其他任務無法及時得到執行,甚至導致死鎖的問題。

解決方法

解決方法也很簡單,就是使用不同的執行緒池來執行不同型別的任務,根據任務的特點和重要性來分配執行緒資源,避免一個任務影響另一個任務。具體來說,有以下幾個建議:

  • 對於主要的業務邏輯,使用一個專門的執行緒池,根據業務的併發度和響應時間,設定合適的執行緒池引數,保證業務的正常執行和高效處理。
  • 對於次要的日誌記錄、監控等,使用一個單獨的執行緒池,根據任務的頻率和重要性,設定合適的執行緒池引數,保證任務的非同步執行和不影響主業務。
  • 對於有依賴關係的任務,使用一個單獨的執行緒池,根據任務的數量和複雜度,設定合適的執行緒池引數,保證任務的有序執行和不造成死鎖。

坑五:使用 ThreadLocal 和執行緒池的不相容問題

ThreadLocal 是 Java 提供的一個工具類,它可以讓每個執行緒擁有自己的變數副本,從而實現執行緒間的資料隔離,比如儲存一些執行緒相關的上下文資訊,如使用者 ID、請求 ID 等。這看起來很有用,但是如果和執行緒池一起使用,就可能會出現一些意想不到的問題,比如資料錯亂、記憶體洩漏等。

問題原因

問題的原因是,ThreadLocal 和執行緒池的設計理念是相悖的,ThreadLocal 是基於執行緒的,而執行緒池是基於任務的。具體來說,有以下幾個問題:

  • ThreadLocal 的變數是繫結線上程上的,而執行緒池的執行緒是可以複用的,如果一個執行緒執行完一個任務後,沒有清理 ThreadLocal 的變數,那麼這個變數就會被下一個執行的任務繼承,導致資料錯亂的問題。
  • ThreadLocal 的變數是儲存在 Thread 類的一個 ThreadLocalMap 型別的屬性中的,這個屬性是一個弱引用的 Map,它的鍵是 ThreadLocal 物件,而值是變數的副本。如果 ThreadLocal 物件被回收,那麼它的鍵就會失效,但是值還會保留在 Map 中,導致記憶體洩漏的問題。

解決方法

解決方法也很簡單,就是在使用 ThreadLocal 和執行緒池的時候,注意以下幾點:

  • 在使用 ThreadLocal 的變數之前,要確保為每個執行緒設定了正確的初始值,避免使用上一個任務的遺留值。
  • 在使用 ThreadLocal 的變數之後,要及時地清理 ThreadLocal 的變數,避免變數的副本被下一個執行的任務繼承,或者佔用記憶體空間,導致記憶體洩漏的問題。可以使用 try-finally 語句,或者使用 Java 8 提供的 AutoCloseable 介面,來實現自動清理的功能。
  • 在使用 ThreadLocal 的時候,要注意執行緒池的大小和任務的數量,避免建立過多的 ThreadLocal 物件和變數的副本,導致記憶體佔用過大的問題。可以使用一些工具,如 VisualVM,來監控執行緒池和 ThreadLocal 的狀態,及時發現和解決問題。

總結

本文給大家介紹了執行緒池使用不當的五個坑,分別是執行緒池中異常消失、執行緒池決絕策略設定錯誤、重複建立執行緒池導致記憶體溢位、使用同一個執行緒池執行不同型別的任務、使用 ThreadLocal 和執行緒池的不相容問題,以及它們的問題原因和解決方法。希望這些內容對大家有幫助。

來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70024924/viewspace-3006216/,如需轉載,請註明出處,否則將追究法律責任。

相關文章