前兩天一個晚上,正當我沉浸在敲代碼的快樂中時,聽到隔壁的同事傳來一聲不可置信的驚呼:線程池提交命令怎麼可能會執行一秒多?
線程池提交方法執行一秒多?那不對啊,線程池提交應該是一個很快的操作,一般情況下不應該執行一秒多那麼長的時間。
看了一下那段代碼,好像也沒什麼問題,就是一個簡單的提交任務的代碼。
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);
但是看了一下我們的監控,線程數量一直比較健康,應該不是這個原因。再說那個地方新線程也不太可能達到這個量級。
入任務隊列耗時?
線程池的任務隊列是一個同步隊列。所以入隊列操作是同步的。
常用的幾個同步隊列:
-
LinkedBlockingQueue
鏈式阻塞隊列,底層數據結構是鏈表,默認大小是
Integer.MAX_VALUE
,也可以指定大小。 -
ArrayBlockingQueue
數組阻塞隊列,底層數據結構是數組,需要指定隊列的大小。
-
SynchronousQueue
同步隊列,內部容量爲0,每個put操作必須等待一個take操作,反之亦然。
-
DelayQueue
延遲隊列,該隊列中的元素只有當其指定的延遲時間到了,才能夠從隊列中獲取到該元素 。
所以使用特殊的同步隊列還是有可能導致execute
方法阻塞一秒多的,比如SynchronousQueue
。如果配合一個特殊的“拒絕策略”,是有可能造成這個現象的,我們將在下面給出例子。
拒絕策略?
線程數量達到最大線程數就會採用拒絕處理策略,四種拒絕處理的策略爲 :
-
ThreadPoolExecutor.AbortPolicy:默認拒絕處理策略,丟棄任務並拋出異常。
-
ThreadPoolExecutor.DiscardPolicy:丟棄新來的任務,但是不拋出異常。
-
ThreadPoolExecutor.DiscardOldestPolicy:丟棄隊列頭部(最舊的)的任務,然後重新嘗試執行程序(如果再次失敗,重複此過程)。
-
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
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