IO密集型場景CompletableFuture使用的陷阱

1-概述

1.1 背景

企知道後臺服務存在大量的查詢可以併發,大量用到了java8的CompletableFuture特性,但是在性能測試中,遇到了併發的瓶頸。

經過分析,發現是由於CompletableFuture默認線程池以及公共線程池核心線程數過少,在重度IO操作、高併發場景下引起線程競爭、排隊,從而產生大量慢請求。

1.2 事實

本文通過代碼分析及測試驗證,得出以下兩個結論:

  1. 在多核(>2核)環境下,CompletableFuture的默認線程數量=可用CPU數-1;
  2. 默認線程數可以通過虛擬機參數 java.util.concurrent.ForkJoinPool.common.parallelism 來指定。

1.3 建議

在IO密集型場景中使用CompletableFuture來實現高併發時,採用以下方法之一:

方法一:使用CompletableFuture默認線程池,並指定 java.util.concurrent.ForkJoinPool.common.parallelism 爲一個較大的值(>1000ms/線程平均使用時間/併發任務數*期望QPS);

方法二:傳入自定義線程池,且自定義線程池的核心線程數使用較大的值(同上),或者使用合理的最大線程&隊列組合(既要避免難觸發擴容,也要避免頻繁擴縮容)。

2-分析過程

2.1 查看CompetableFuture.runAsync的代碼,發現在不傳入自定義線程池的情況下,其使用了名爲ASYNC_POOL的默認線程池:

public static CompletableFuture<Void> runAsync(Runnable runnable) {
    return asyncRunStage(ASYNC_POOL, runnable);
}

 

2.2 ASYNC_POOL則在默認使用ForkJoinPool.commonPool()線程池,如果線程池只有一個線程,則退化爲不使用線程池可每次創建新線程:

private static final boolean USE_COMMON_POOL =
    (ForkJoinPool.getCommonPoolParallelism() > 1);
/**
 * Default executor -- ForkJoinPool.commonPool() unless it cannot
 * support parallelism.
 */
private static final Executor ASYNC_POOL = USE_COMMON_POOL ?
ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();
/** Fallback if ForkJoinPool.commonPool() cannot support parallelism */
static final class ThreadPerTaskExecutor implements Executor {
    public void execute(Runnable r) {
        Objects.requireNonNull(r);
        new Thread(r).start();
    }
}

2.3 ForkJoinPool.commonPool()線程池線程數量=可用CPU數-1,也可以通過虛擬機參數來指定
 

//commonPool方法返回common靜態成員
public static ForkJoinPool commonPool() {
    // assert common != null : "static init error";
    return common;
}
//common本身是一個ForkJoinPool的靜態單例成員常量
static final ForkJoinPool common;
//common在靜態構造函數中初始化
static {
    ...
    @SuppressWarnings("removal")
    ForkJoinPool tmp = AccessController.doPrivileged(new PrivilegedAction<>() {
        public ForkJoinPool run() {
            return new ForkJoinPool((byte)0); }});
    common = tmp;

    COMMON_PARALLELISM = Math.max(common.mode & SMASK, 1);
}
//ForkJoinPool的併發度,默認值=可用邏輯CPU數-1,也可以通過系統屬性來指定,最大不超過MAX_CAP(0x7ffff=524287)
private ForkJoinPool(byte forCommonPoolOnly) {
    int parallelism = Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
    ...
    try {  // ignore exceptions in accessing/parsing properties
        ...
        String pp = System.getProperty
            ("java.util.concurrent.ForkJoinPool.common.parallelism");
        if (pp != null)
            parallelism = Integer.parseInt(pp);
    } catch (Exception ignore) {
    }
    ...
    int p = Math.min(Math.max(parallelism, 0), MAX_CAP), size;
    this.mode = p;
    ...
}

3-代碼驗證

3.1 驗證默認線程數

在我8核的MacBookAir中,運行以下代碼:

public class TestMain {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        final int times = 100;
        CompletableFuture<?>[] futures = new CompletableFuture[times];
        //發起100個異步任務
        for (int i = 0; i < times; i++) {
            // Lambda中不能使用外層非只讀變量
            final int j = i;
            futures[i] = CompletableFuture.runAsync(() -> {
                //打印當前線程名
                System.out.printf("[%s]%03d:%s\n", LocalDateTime.now(), j, Thread.currentThread().getName());
                try {
                    //阻塞線程
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
        }
        //等待線程完成,沒有這句則主線程退出會直接退出
        CompletableFuture.allOf(futures).join();
    }
}

運行結果(順序不一定如此):

[2024-04-09T10:55:17.187205]004:ForkJoinPool.commonPool-worker-5
[2024-04-09T10:55:17.189296]003:ForkJoinPool.commonPool-worker-4
[2024-04-09T10:55:17.188725]000:ForkJoinPool.commonPool-worker-1
[2024-04-09T10:55:17.188861]005:ForkJoinPool.commonPool-worker-6
[2024-04-09T10:55:17.188015]002:ForkJoinPool.commonPool-worker-3
[2024-04-09T10:55:17.187862]006:ForkJoinPool.commonPool-worker-7
[2024-04-09T10:55:17.186498]001:ForkJoinPool.commonPool-worker-2
[2024-04-09T10:55:18.249665]007:ForkJoinPool.commonPool-worker-6
[2024-04-09T10:55:18.254584]008:ForkJoinPool.commonPool-worker-2
[2024-04-09T10:55:18.254585]011:ForkJoinPool.commonPool-worker-7
[2024-04-09T10:55:18.254582]010:ForkJoinPool.commonPool-worker-4
[2024-04-09T10:55:18.254584]009:ForkJoinPool.commonPool-worker-1
[2024-04-09T10:55:19.258823]012:ForkJoinPool.commonPool-worker-6
[2024-04-09T10:55:19.260827]013:ForkJoinPool.commonPool-worker-4
[2024-04-09T10:55:19.261448]014:ForkJoinPool.commonPool-worker-2
[2024-04-09T10:55:20.261756]015:ForkJoinPool.commonPool-worker-6
[2024-04-09T10:55:20.265064]017:ForkJoinPool.commonPool-worker-2
[2024-04-09T10:55:20.263193]016:ForkJoinPool.commonPool-worker-4

...

很容易看出來,一直只有7(即8-1)個線程在運行任務。

 

3.2 驗證使用虛擬機參數指定線程數量

在命令行中,運行命令修改如下

java -Djava.util.concurrent.ForkJoinPool.common.parallelism=3 -classpath xxxx/TestMain.class TestMain

如果在IDEA中,可以在 運行配置-構建並運行-修改選項-添加虛擬機參數 打開虛擬機參數輸入框,並填入: -Djava.util.concurrent.ForkJoinPool.common.parallelism=3

再次運行驗證代碼,輸出如下:

[2024-04-09T11:00:26.626672]001:ForkJoinPool.commonPool-worker-2
[2024-04-09T11:00:26.626556]002:ForkJoinPool.commonPool-worker-3
[2024-04-09T11:00:26.626734]000:ForkJoinPool.commonPool-worker-1
[2024-04-09T11:00:27.671474]003:ForkJoinPool.commonPool-worker-2
[2024-04-09T11:00:27.673111]004:ForkJoinPool.commonPool-worker-1
[2024-04-09T11:00:27.673024]005:ForkJoinPool.commonPool-worker-3
[2024-04-09T11:00:28.675585]006:ForkJoinPool.commonPool-worker-2
[2024-04-09T11:00:28.676933]007:ForkJoinPool.commonPool-worker-1
[2024-04-09T11:00:28.676961]008:ForkJoinPool.commonPool-worker-3

...

我們發現虛擬機參數確實生效了,ForkJoinPool只有3個線程在運行任務。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章