承上啟下:上一篇文章小豹子講了執行緒池的例項化過程,粗略介紹了執行緒池的狀態轉換;這篇文章主要講了我執行執行緒池時遇到的小問題,以及
execute
方法的原始碼理解。
4 並不算疑難的 Bug
按照我們的規劃,下一步就應該提交任務,探究執行緒池執行任務時的內部行為,但首先,我要提交一個任務嘛。於是,接著上一篇文章的程式碼,我提交了一個任務:
@Test
public void submitTest() {
// 建立執行緒池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread();
}
}, new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println("拒絕服務");
}
});
// 提交任務,該任務為睡眠 1 秒後列印 Hello
threadPoolExecutor.submit(new Callable<String>() {
@Override
public String call() throws InterruptedException {
Thread.sleep(1000L);
System.out.println("Hello");
return null;
}
});
}
複製程式碼
而我並沒有看到任何輸出,程式也並沒有睡眠一秒,而是馬上結束了。哦對,我想起來,我們建立的執行緒預設是守護執行緒,當所有使用者執行緒結束之後,程式就會結束了,並不會理會是否還有守護執行緒在執行。那麼我們用一個簡單易行的辦法來解決這個問題 —— 不讓使用者執行緒結束,讓它多睡一會:
@Test
public void submitTest() throws InterruptedException {
// 建立執行緒池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread();
}
}, new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println("拒絕服務");
}
});
// 提交任務,該任務為睡眠 1 秒後列印 Hello
threadPoolExecutor.submit(new Callable<String>() {
@Override
public String call() throws InterruptedException {
Thread.sleep(1000L);
System.out.println("Hello");
return null;
}
});
// 使主執行緒休眠 5 秒,防止守護執行緒意外退出
Thread.sleep(5000L);
}
複製程式碼
然而,程式等待 5 秒之後,依舊沒有輸出。我的第一個反應是,我對於執行緒池的用法不對。是不是還需要呼叫某個方法來“啟用”或者“啟動”執行緒池?而無論在文件中,還是各部落格的例子中,我都沒有找到類似的方法。我們仔細思考一下這個 Bug,產生這樣問題的可能原因有三:
ThreadPoolExecutor
內部程式碼有問題- 我對
ThreadPoolExecutor
的使用方法不對 - 我設計的
ThreadFactory
或RejectedExecutionHandler
有問題
原因 1,可能性太小,幾乎沒有。那麼原因2、3,我們現在沒法排除,於是我嘗試構建一個最小可重現錯誤,將 ThreadPoolExecutor
剝離出來,看 Bug 是否重現:
最小可重現(minimal reproducible)這個思想是我在翻譯《使用 Rust 開發一個簡單的 Web 應用,第 4 部分 —— CLI 選項解析》時,作者用到的思想。就是在我們無法定位 Bug 時,剝離出當前程式碼中我們認為無關的部分,剝離後觀察 Bug 是否重現,一步步縮小 Bug 的範圍。通俗的說,就是排除法。
private class MyThreadFactory implements ThreadFactory{
@Override
public Thread newThread(Runnable r) {
return new Thread();
}
}
@Test
public void reproducibleTest() throws InterruptedException {
new MyThreadFactory().newThread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Hello");
}
}).start();
Thread.sleep(5000L);
}
複製程式碼
還是沒有任何輸出,不過這是一個好訊息,這意味著我們定位了問題所在:現在問題只可能出現在 MyThreadFactory
中,短短 6 行程式碼會有什麼問題呢?哎呦(拍大腿),我沒有把 Runnable r
傳給 new Thread()
啊,我一直在執行一個空執行緒啊,怎麼可能有任何輸出!於是:return new Thread(r);
這樣一改就好了。
5 重構
上面的問題看似簡單,但能出現這麼低階的錯誤,值得我思考。我因為產生該錯誤的原因有二:
- 我不瞭解
ThreadPoolExecutor
的原理,從語法上看ThreadFactory
的實現類只需要傳出一個Thread
例項就行了,卻不知Runnable r
不可或缺。 - 測試程式碼結構凌亂不堪。即便是測試程式碼,也不應該寫成麵條,自己看尚不能清楚明瞭,何談讀者?
於是,我決定對測試程式碼進行重構。這次重構,一要使執行緒工廠產生非守護執行緒,防止因為主程式的退出導致執行緒池中執行緒全部意外退出;二要對每個操作打日誌,我們要能直觀的觀察到執行緒池在做什麼,值得一提的是,對於阻塞佇列的日誌操作,我使用了動態代理的方式對每一個方法打日誌,不熟悉動態代理的童鞋可以戳我之前寫的小豹子帶你看原始碼:JDK 動態代理。
// import...
public class ThreadPoolExecutorTest {
/**
* 記錄啟動時間
*/
private final static long START_TIME = System.currentTimeMillis();
/**
* 自定義執行緒工廠,產生非守護執行緒,並列印日誌
*/
private class MyThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setDaemon(false);
debug("建立執行緒 - %s", thread.getName());
return thread;
}
}
/**
* 自定義拒絕服務異常處理器,列印拒絕服務資訊
*/
private class MyRejectedExecutionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
debug("拒絕請求,Runnable:%s,ThreadPoolExecutor:%s", r, executor);
}
}
/**
* 自定義任務,休眠 1 秒後列印當前執行緒名,並返回執行緒名
*/
private class MyTask implements Callable<String> {
@Override
public String call() throws InterruptedException {
Thread.sleep(1000L);
String threadName = Thread.currentThread().getName();
debug("MyTask - %s", threadName);
return threadName;
}
}
/**
* 對 BlockingQueue 的動態代理,實現對 BlockingQueue 的所有方法呼叫打 Log
*/
private class PrintInvocationHandler implements InvocationHandler {
private final BlockingQueue<?> blockingQueue;
private PrintInvocationHandler(BlockingQueue<?> blockingQueue) {
this.blockingQueue = blockingQueue;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
debug("BlockingQueue - %s,引數為:%s", method.getName(), Arrays.toString(args));
Object result = method.invoke(blockingQueue, args);
debug("BlockingQueue - %s 執行完畢,返回值為:%s", method.getName(), String.valueOf(result));
return result;
}
}
/**
* 產生 BlockingQueue 代理類
* @param blockingQueue 原 BlockingQueue
* @param <E> 任意型別
* @return 動態代理 BlockingQueue,執行任何方法時會打 Log
*/
@SuppressWarnings("unchecked")
private <E> BlockingQueue<E> debugQueue(BlockingQueue<E> blockingQueue) {
return (BlockingQueue<E>) Proxy.newProxyInstance(this.getClass().getClassLoader(),
new Class<?>[]{BlockingQueue.class},
new PrintInvocationHandler(blockingQueue));
}
/**
* 例項化一個 核心池為 3,最大池為 5,存活時間為 20s,利用上述阻塞佇列、執行緒工廠、拒絕服務處理器的執行緒池例項
* @return 返回 ThreadPoolExecutor 例項
*/
private ThreadPoolExecutor newTestPoolInstance() {
return new ThreadPoolExecutor(3, 5, 20,
TimeUnit.SECONDS, debugQueue(new LinkedBlockingQueue<>()),
new MyThreadFactory(), new MyRejectedExecutionHandler());
}
/**
* 向控制檯列印日誌,自動輸出時間,執行緒等資訊
* @param info
* @param arg
*/
private void debug(String info, Object... arg) {
long time = System.currentTimeMillis() - START_TIME;
System.out.println(String.format(((double) time / 1000) + "-" + Thread.currentThread().getName() + "-" + info, arg));
}
/**
* 測試例項化操作
*/
private void newInstanceTest() {
newTestPoolInstance();
}
/**
* 測試提交操作,提交 10 次任務
*/
private void submitTest() {
ThreadPoolExecutor threadPool = newTestPoolInstance();
for (int i = 0; i < 10; i++) {
threadPool.submit(new MyTask());
}
}
public static void main(String[] args) {
ThreadPoolExecutorTest test = new ThreadPoolExecutorTest();
test.submitTest();
}
}
複製程式碼
編譯,執行 =>
0.047-main-建立執行緒 - Thread-0
0.064-main-建立執行緒 - Thread-1
0.064-main-建立執行緒 - Thread-2
0.064-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@4d7e1886]
0.064-main-BlockingQueue - offer 執行完畢,返回值為:true
0.064-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@3cd1a2f1]
0.065-main-BlockingQueue - offer 執行完畢,返回值為:true
0.065-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@2f0e140b]
0.065-main-BlockingQueue - offer 執行完畢,返回值為:true
0.065-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@7440e464]
0.065-main-BlockingQueue - offer 執行完畢,返回值為:true
0.065-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@49476842]
0.065-main-BlockingQueue - offer 執行完畢,返回值為:true
0.065-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@78308db1]
0.065-main-BlockingQueue - offer 執行完畢,返回值為:true
0.065-main-BlockingQueue - offer,引數為:[java.util.concurrent.FutureTask@27c170f0]
0.065-main-BlockingQueue - offer 執行完畢,返回值為:true
1.065-Thread-1-MyTask - Thread-1
1.065-Thread-0-MyTask - Thread-0
1.065-Thread-2-MyTask - Thread-2
1.065-Thread-1-BlockingQueue - take,引數為:null
1.065-Thread-0-BlockingQueue - take,引數為:null
1.065-Thread-2-BlockingQueue - take,引數為:null
1.065-Thread-0-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@3cd1a2f1
1.065-Thread-2-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@2f0e140b
1.065-Thread-1-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@4d7e1886
2.065-Thread-1-MyTask - Thread-1
2.065-Thread-2-MyTask - Thread-2
2.065-Thread-0-MyTask - Thread-0
2.065-Thread-1-BlockingQueue - take,引數為:null
2.065-Thread-2-BlockingQueue - take,引數為:null
2.065-Thread-0-BlockingQueue - take,引數為:null
2.065-Thread-1-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@7440e464
2.065-Thread-2-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@49476842
2.065-Thread-0-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@78308db1
3.066-Thread-1-MyTask - Thread-1
3.066-Thread-2-MyTask - Thread-2
3.066-Thread-0-MyTask - Thread-0
3.066-Thread-2-BlockingQueue - take,引數為:null
3.066-Thread-1-BlockingQueue - take,引數為:null
3.066-Thread-0-BlockingQueue - take,引數為:null
3.066-Thread-2-BlockingQueue - take 執行完畢,返回值為:java.util.concurrent.FutureTask@27c170f0
4.067-Thread-2-MyTask - Thread-2
4.067-Thread-2-BlockingQueue - take,引數為:null
複製程式碼
日誌的格式是:時間(秒)-執行緒名-資訊
從日誌輸出中,我們可以獲知:
- 當佇列為空,執行緒數少於核心執行緒數時,提交任務會觸發建立執行緒,並立即執行任務
- 當核心執行緒均忙,再提交的請求會被儲存至阻塞佇列,等待執行緒空閒後執行佇列中的任務
- 除主執行緒外,始終只有三個工作執行緒
- 當佇列為空,工作執行緒還在執行的時候,工作執行緒會因為阻塞佇列的
take
方法阻塞(這一點由日誌後幾行可以看出,只有呼叫日誌,沒有呼叫完成的日誌)
由此,我產生一個疑問:為什麼始終只有三個執行緒?我的設定不是“核心池為 3,最大池為 5”嗎?為什麼只有三個執行緒在工作呢?
6 submit 任務
終於開始看原始碼了,我們以 submit
為切入點,探尋我們提交任務時,執行緒池做了什麼,submit
方法本身很簡單,就是將傳入引數封裝為 RunnableFuture
例項,然後呼叫 execute
方法,以下給出 submit
多個過載方法其中之一:
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task);
execute(ftask);
return ftask;
}
複製程式碼
那麼,我們繼續看 execute
的程式碼:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
複製程式碼
我們首先解釋一下 addWorker
方法,暫時我們只需要瞭解幾件事情就可以理解 execute
程式碼了:
- 該方法用於新建一個工作執行緒
- 該方法執行緒安全
- 該方法第一個引數是新執行緒要執行的第一個任務,第二個引數是是否新建核心執行緒
- 該方法如果新建執行緒成功,則返回
true
,否則返回false
那麼我們回過頭來理解 execute
程式碼:
為了幫助理解,我根據程式碼邏輯畫了一個流程圖:
現在我明白了,只有等待佇列插入失敗(如達到容量上限等)情況下,才會建立非核心執行緒來處理任務,也就是說,我們使用的 LinkedBlockingQueue
佇列來作為等待佇列,那是看不到非核心執行緒被建立的現象的。
有心的讀者可能注意到了,整個過程沒有加鎖啊,怎樣保證併發安全呢?我們觀察這段程式碼,其實沒必要全部加鎖,只需要保證 addWorker
、remove
和 workQueue.offer
三個方法的執行緒安全,該方法就沒必要加鎖。事實上,在 addWorker
中是有對執行緒池狀態的 recheck 的,如果建立失敗會返回 false。
系列文章
小豹子還是一個大三的學生,小豹子希望你能“批判性的”閱讀本文,對本文內容中不正確、不妥當之處進行嚴厲的批評,小豹子感激不盡。