沒想到,這麼簡單的執行緒池用法,深藏這麼多坑!

樓下小黑哥發表於2020-06-29

又又又踩坑了

生產有個對賬系統,每天需要從渠道端下載對賬檔案,然後開始日終對賬。這個系統已經執行了很久,前兩天突然收到簡訊預警,沒有獲取渠道端對賬檔案。

ps:對賬系統詳細實現方式:對賬系統設計與實現

本以為又是渠道端搞事情,上去一排查才發現,所有下載任務都被阻塞了。再進一步排查原始碼,才發現自己一直用錯了執行緒池某個方法。

由於執行緒建立比較昂貴,正式專案中我們都會使用執行緒池執行非同步任務。執行緒池,使用池化技術儲存執行緒物件,使用的時候直接取出來,用完歸還以便使用。

雖然執行緒池的使用非常方法非常簡單,但是越簡單,越容易踩坑。細數一下,這些年來因為執行緒池導致生產事故也有好幾起。

所以今天,小黑哥就針對執行緒池的話題,給大家演示一下怎麼使用執行緒池才會踩坑。

希望大家看完,可以完美避開這些坑~

先贊後看,養成習慣。微信搜尋「程式通事」,關注就完事了!

慎用 Executors 元件

Java 從 JDK1.5 開始提供執行緒池的實現類,我們只需要在建構函式內傳入相關引數,就可以建立一個執行緒池。

不過執行緒池的建構函式可以說非常複雜,就算最簡單的那個建構函式,也需要傳入 5 個引數。這對於新手來說,非常不方便哇。

也許 JDK 開發者也考慮到這個問題,所以非常貼心給我們提供一個工具類 Executors,用來快捷建立建立執行緒池。

雖然這個工具類使用真的非常方便,可以少寫很多程式碼,但是小黑哥還是建議生產系統還是老老實實手動建立執行緒池,慎用Executors,尤其是工具類中兩個方法 Executors#newFixedThreadPoolExecutors#newCachedThreadPool

如果你圖了方便使用上述方法建立了執行緒池,那就是一顆定時炸彈,說不準那一天生產系統就會?。

我們來看兩個?,看下這個這兩個方法會有什麼問題。

假設我們有個應用有個批量介面,每次請求將會下載 100w 個檔案,這裡我們使用 Executors#newFixedThreadPool批量下載。

下面方法中,我們隨機休眠,模擬真實下載耗時。

為了快速復現問題,調整 JVM 引數為 -Xmx128m -Xms128m

private ExecutorService threadPool = Executors.newFixedThreadPool(10);

/**
 * 批量下載對賬檔案
 *
 * @return
 */
@RequestMapping("/batchDownload")
public String batchDownload() {
    
    // 模擬下載 100w 個檔案
    for (int i = 0; i < 1000000; i++) {
        threadPool.execute(() -> {
            // 隨機休眠,模擬下載耗時
            Random random = new Random();
            try {
                TimeUnit.SECONDS.sleep(random.nextInt(100));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }

    return "process";
}

程式執行之後,多請求幾次這個批量下載方法,程式很快就會 OOM

檢視 Executors#newFixedThreadPool原始碼,我們可以看到這個方法建立了一個預設的 LinkedBlockingQueue 當做任務佇列。

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

這個問題槽點就在於 LinkedBlockingQueue,這個佇列的預設構造方法如下:

/**
 * Creates a {@code LinkedBlockingQueue} with a capacity of
 * {@link Integer#MAX_VALUE}.
 */
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

建立 LinkedBlockingQueue 佇列時,如果我們不指定佇列數量,預設數量上限為 Integer.MAX_VALUE。這麼大的數量,我們簡直可以當做無界佇列了。

上面我們使用 newFixedThreadPool,我們僅使用了固定數量的執行緒下載。如果執行緒都在執行任務,執行緒池將會任務加入任務佇列中。

如果執行緒池執行任務過慢,任務將會一直堆積在佇列中。由於我們佇列可以認為是無界的,可以無限制新增任務,這就導致記憶體佔用越來越高,直到 OOM 爆倉。

ps:執行緒池基本工作原理

下面我們將上面的例子稍微修改一下,使用 newCachedThreadPool 建立執行緒池。

程式執行之後,多請求幾次這個批量下載方法,程式很快就會 OOM ,不過這次報錯資訊與之前資訊與之前不同。

從報錯資訊來看,這次 OOM 的主要原因是因為無法再建立新的執行緒。

這次看下一下 newCachedThreadPool 方法的原始碼,可以看到這個方法將會建立最大執行緒數為 Integer.MAX_VALUE 的的執行緒池。

image-20200627180428310

由於這個執行緒池使用 SynchronousQueue 佇列,這個佇列比較特殊,沒辦法儲存任務。所以預設情況下,執行緒池只要接到一個任務,就會建立一個執行緒。

一旦執行緒池收到大量任務,就會建立大量執行緒。Java 中的執行緒是會佔用一定的記憶體空間 ,所以建立大量的執行緒是必然會導致 OOM

先贊後看,養成習慣。微信搜尋「程式通事」,關注就完事了!

複用執行緒池

由於執行緒池的構造方法比較複雜,而 Executors 建立的執行緒池比較坑,所以我們有個專案中自己封裝了一個執行緒池工具類。

工具類程式碼如下:

public static ThreadPoolExecutor getThreadPool() {
    // 為了快速復現問題,故將執行緒池 核心執行緒數與最大執行緒數設定為 100
    return new ThreadPoolExecutor(100, 100, 60, TimeUnit.SECONDS, new LinkedBlockingDeque<>(200));
}

專案程式碼中這樣使用這個工具類:

@RequestMapping("/batchDownload")
public String batchDownload() {
    ExecutorService threadPool = ThreadPoolUtils.getThreadPool();

    // 模擬下載 100w 個檔案
    for (int i = 0; i < 100; i++) {
        threadPool.execute(() -> {
            // 隨機休眠,模擬下載耗時
            Random random = new Random();
            try {
                TimeUnit.SECONDS.sleep(random.nextInt(100));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }

    return "process";
}

使用 WRK 工具對這個介面同時發起多個請求,很快應用就會丟擲 OOM

每次請求都會建立一個新的執行緒池執行任務,如果短時間內有大量的請求,就會建立很多的執行緒池,間接導致建立很多執行緒。從而導致記憶體佔盡,發生 OOM 問題。

這個問題修復辦法很簡單,要麼工具類生成一個單例執行緒池,要麼專案程式碼中複用建立出來的執行緒池。

Spring 非同步任務

上面程式碼中我們都是自己建立一個執行緒池執行非同步任務,這樣還是比較麻煩。在 Spring 中, 我們可以在方法上使用 Spring 註解 @Async,然後執行非同步任務。

程式碼如下:

@Async
public void async() throws InterruptedException {
    log.info("async process");
    Random random = new Random();
    TimeUnit.SECONDS.sleep(random.nextInt(100));
}

不過使用 Spring 非同步任務,我們需要自定義執行緒池,不然大量請求下,還是有可能發生 OOM 問題。

這是原因主要是 Spring 非同步任務預設使用 Spring 內部執行緒池 SimpleAsyncTaskExecutor

image-20200627191850022

這個執行緒池比較坑爹,不會複用執行緒。也就是說來一個請求,將會新建一個執行緒。

所以如果需要使用非同步任務,一定要使用自定義執行緒池替換預設執行緒池。

如果使用 XML 配置,我們可以增加如下配置:

<task:executor id="myexecutor" pool-size="5"  />
<task:annotation-driven executor="myexecutor"/>

如果使用註解配置,我們需要設定一個 Bean:

@Bean(name = "threadPoolTaskExecutor")
public Executor threadPoolTaskExecutor() {
    ThreadPoolTaskExecutor executor=new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setThreadNamePrefix("test-%d");
    // 其他設定
    return new ThreadPoolTaskExecutor();
}

然後使用註解時指定執行緒池名稱:

@Async("threadPoolTaskExecutor")
public void xx() {
    // 業務邏輯
}

如果是 SpringBoot 專案,從本人測試情況來看,預設將會建立核心執行緒數為 8,最大執行緒數為 Integer.MAX_VALUE,佇列數也為 Integer.MAX_VALUE執行緒池。

ps:以下程式碼基於 Spring-Boot 2.1.6-RELEASE,暫不確定 Spring-Boot 1.x 版本是否也是這種策略,熟悉的同學的,也可以留言指出一下。

雖然上面的執行緒池不用擔心建立過多執行緒的問題,不是還是有可能佇列任務過多,導致 OOM 的問題。所以還是建議使用自定義執行緒池嗎,或者在配置檔案修改預設配置,例如:

spring.task.execution.pool.core-size=10
spring.task.execution.pool.max-size=20
spring.task.execution.pool.queue-capacity=200

Spring 相關踩坑案例: Spring 定時任務突然不執行

執行緒池方法使用不當

最後再來說下文章開頭的我踩到的這個坑,這個問題主要是因為理解錯這個方法。

錯誤程式碼如下:

// 建立執行緒池
ExecutorService threadPool = ...
List<Callable<String>> tasks = new ArrayList<>();
// 批量建立任務
for (int i = 0; i < 100; i++) {
    tasks.add(() -> {
        Random random = new Random();
        try {
            TimeUnit.SECONDS.sleep(random.nextInt(100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "success";
    });
}
// 執行所有任務
List<Future<String>> futures = threadPool.invokeAll(tasks);
// 獲取結果
for (Future<String> future : futures) {
    try {
        future.get();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
}

上面程式碼中,使用 invokeAll執行所有任務。由於這個方法返回值為 List<Future<T>>,我誤以為這個方法如 submit一樣,非同步執行,不會阻塞主執行緒。

實際上從原始碼上,這個方法實際上逐個呼叫 Future#get獲取任務結果,而這個方法會同步阻塞主執行緒。

一旦某個任務被永久阻塞,比如 Socket 網路連線位置超時時間,導致任務一直阻塞在網路連線,間接導致這個方法一直被阻塞,從而影響後續方法執行。

如果需要使用 invokeAll 方法,最好使用其另外一個過載方法,設定超時時間。

總結

今天文章通過幾個例子,給大家展示了一下執行緒池使用過程一些坑。為了快速復現問題,上面的示例程式碼還是比較極端,實際中可能並不會這麼用。

不過即使這樣,我們千萬不要抱著僥倖的心理,認為這些任務很快就會執行結束。我們在生產上碰到好幾次事故,正常的情況執行都很快。但是偶爾外部程式抽瘋,返回時間變長,就可能導致系統中存在大量任務,導致 OOM

最後總結一下幾個執行緒池幾個最佳實踐:

第一,生產系統慎用 Executors 類提供的便捷方法,我們需要自己根據自己的業務場景,配置合理的執行緒數,任務佇列,拒絕策略,執行緒回收策略等等,並且一定記得自定義執行緒池的命名方式,以便於後期排查問題。

第二,執行緒池不要重複建立,每次都建立一個執行緒池可能比不用執行緒池還要糟糕。如果使用其他同學建立的執行緒池工具類,最好還是看一下實現方式,防止自己誤用。

第三,一定不要按照自己的片面理解去使用 API 方法,如果把握不準,一定要去看下方法上註釋以及相關原始碼。

歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:studyidea.cn

相關文章