你也被Spring的這個“線程池”坑過嗎?

前兩天一個晚上,正當我沉浸在敲代碼的快樂中時,聽到隔壁的同事傳來一聲不可置信的驚呼:線程池提交命令怎麼可能會執行一秒多?

線程池提交方法執行一秒多?那不對啊,線程池提交應該是一個很快的操作,一般情況下不應該執行一秒多那麼長的時間。

看了一下那段代碼,好像也沒什麼問題,就是一個簡單的提交任務的代碼。

executor.execute( () -> {
    // 具體的任務代碼
    // 這裏有個for循環
});

雖然執行的Job裏面有一個for循環,可能比較耗時,但是execute提交任務的時候,並不會去真正去執行Job,所以應該不是這個原因引起的。

分析

看到這個情況,我們首先想到的是線程池提交任務時候的一個處理過程:

線程池原理圖

線程池原理圖

然後逐個分析一下有可能耗時一秒多的操作:

創建線程耗時?

根據上面的圖,我們可以知道,如果核心線程數量設置過大,就可能會不斷創建新的核心線程去執行任務。同理,如果核心線程池和任務隊列都滿了,會創建非核心線程去執行任務。

創建線程是比較耗時的,而且Java線程池在這裏創建線程的時候還上了鎖。

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();

我們寫個簡單的程序,可以模擬出來線程池耗時的操作,下面這段代碼創建2w個線程,在我的電腦裏大概會耗時6k多毫秒。

long before = System.currentTimeMillis();
for (int i = 0; i < 20000; i++) {
    // doSomething裏面睡眠一秒
    new Thread(() -> doSomething()).start();
}
long after = System.currentTimeMillis();
// 下面這行在我的電腦裏輸出6139
System.out.println(after - before);

但是看了一下我們的監控,線程數量一直比較健康,應該不是這個原因。再說那個地方新線程也不太可能達到這個量級。

入任務隊列耗時?

線程池的任務隊列是一個同步隊列。所以入隊列操作是同步的。

常用的幾個同步隊列:

  1. LinkedBlockingQueue

    鏈式阻塞隊列,底層數據結構是鏈表,默認大小是Integer.MAX_VALUE,也可以指定大小。

  2. ArrayBlockingQueue

    數組阻塞隊列,底層數據結構是數組,需要指定隊列的大小。

  3. SynchronousQueue

    同步隊列,內部容量爲0,每個put操作必須等待一個take操作,反之亦然。

  4. DelayQueue

    延遲隊列,該隊列中的元素只有當其指定的延遲時間到了,才能夠從隊列中獲取到該元素 。

所以使用特殊的同步隊列還是有可能導致execute方法阻塞一秒多的,比如SynchronousQueue。如果配合一個特殊的“拒絕策略”,是有可能造成這個現象的,我們將在下面給出例子。

拒絕策略?

線程數量達到最大線程數就會採用拒絕處理策略,四種拒絕處理的策略爲 :

  1. ThreadPoolExecutor.AbortPolicy:默認拒絕處理策略,丟棄任務並拋出異常。

  2. ThreadPoolExecutor.DiscardPolicy:丟棄新來的任務,但是不拋出異常。

  3. ThreadPoolExecutor.DiscardOldestPolicy:丟棄隊列頭部(最舊的)的任務,然後重新嘗試執行程序(如果再次失敗,重複此過程)。

  4. ThreadPoolExecutor.CallerRunsPolicy:由調用線程處理該任務。

可以看到,前面三種拒絕處理策略都是會“丟棄”任務,而最後一種不會。最後一種拒絕策略配合上面的SynchronousQueue,就有可能造成我們遇到的情況。示例代碼:

Executor executor = new ThreadPoolExecutor(2,2, 2, 
                     TimeUnit.MILLISECONDS,new SynchronousQueue<>(), 
                     new ThreadPoolExecutor.CallerRunsPolicy());
for (int i = 0; i < 3; i++) {
    long before = System.currentTimeMillis();
    executor.execute( () -> {
        // doSomething裏面睡眠一秒
        doSomething();
    });
    long after = System.currentTimeMillis();
    // 下面這段代碼,第三行會輸出1001
    System.out.println(after - before);
}

SimpleAsyncTaskExecutor

所以我們遇到的問題會是上面的種種原因導致的嗎?帶着這些猜測,我們去找到了定義executor的代碼。

SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
executor.setConcurrencyLimit(20);

設置最大併發數量是20好像沒什麼問題,等等,這個SimpleAsyncTaskExecutor是個什麼鬼?

好像是Spring提供的一個線程池吧……(聲音逐漸不自信)

em…看了一下包的定義,org.springframework.core.task,確實是Spring提供的。至於是不是線程池,先看看類圖:

實現的是Executor接口,但是繼承樹裏爲什麼沒有ThreadPoolExecutor?我們猜測可能是Spring自己實現了一個線程池?雖然應該沒什麼必要。

源碼

帶着疑問,我們繼續看了一下這個類的源碼。主要看execute方法,發現每次執行之前,都要先調用一個beforeAccess方法,這個方法裏面有這樣一段很奇怪的代碼:

beforeAccess

beforeAccess

while循環去檢查,如果當前併發線程數量大於等於設置的最大值,就等待。

找到原因了,這應該就是罪魁禍首。可是爲什麼Spring要這麼設計呢?

我們在SimpleAsyncTaskExecutor類的註釋上面找到了作者的留言:

 * <p><b>NOTE: This implementation does not reuse threads!</b> Consider a
 * thread-pooling TaskExecutor implementation instead, in particular for
 * executing a large number of short-lived tasks.

大概意思就是:這個實現並不複用線程,如果你要複用線程請去使用線程池的實現。這個是用來執行很多耗時很短的任務的。

至此,真相大白。

反思

使用接口前先了解一下

造成這個問題的根本原因是,我們以爲SimpleAsyncTaskExecutor是一個“線程池”,而其實它不是!!!

我們在使用開源項目的時候,往往直接就用了,不會去仔細看看它的源碼,也可能沒有考慮清楚它的應用環境。等到程序出問題了才發現,已經晚了。

所以使用接口之前最好先了解一下,至少要看看官方文檔或者接口文檔/註釋。

哪怕是真的出問題了,看源碼也不失爲一種排查問題的方式,因爲代碼都是死的,它不會騙人。

代碼規約

阿里有這麼一個代碼規約:不建議我們直接使用Executors類中的線程池,而是通過ThreadPoolExecutor的方式,這樣的處理方式讓寫的同學需要更加明確線程池的運行規則,規避資源耗盡的風險。

以前我還不太理解,心想使用Executors類可以提高可讀性,JDK提供了這樣的工具類,不用白不用。直到遇到這個問題,才明白這條規約的良苦用心。

如果我們使用規範的方式去使用線程池,而不是用一個所謂的Spring提供的“線程池”,就不會遇到這個問題了。

明確接口職責

再來想一想爲什麼同事會把它當成一個線程池?因爲它的類名、方法名都太像一個線程池了。它實現了Executor接口的execute方法,才導致我們誤以爲它是一個線程池。

所以迴歸到Executor這個接口上來,它的職責究竟是什麼?我們可以在JDK的execute方法上看到這個註釋:

/**
* Executes the given command at some time in the future.  The command
* may execute in a new thread, in a pooled thread, or in the calling
* thread, at the discretion of the {@code Executor} implementation.
*/

大意就是,在將來某個時間執行傳入的命令,這個命令可能會在一個新的線程裏面執行,可能會在線程池裏,也可能在調用這個方法的線程中,具體怎麼執行是由實現類去決定的。

所以這纔是Executor這個類的職責,它的職責並不是提供一個線程池的接口,而是提供一個“將來執行命令”的接口。

所以,真正能代表線程池意義的,是ThreadPoolExecutor類,而不是Executor接口。

在我們寫代碼的時候,也要定義清楚接口的職責喲。這樣別人用你的接口或者閱讀源碼的時候,纔不會疑惑。


作者:編了個程
鏈接:https://juejin.im/post/5f033132f265da22fa61672b

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