小豹子帶你看原始碼:Java 執行緒池(三)提交任務

LeopPro發表於2018-02-14

承上啟下:上一篇文章小豹子講了執行緒池的例項化過程,粗略介紹了執行緒池的狀態轉換;這篇文章主要講了我執行執行緒池時遇到的小問題,以及 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,產生這樣問題的可能原因有三:

  1. ThreadPoolExecutor 內部程式碼有問題
  2. 我對 ThreadPoolExecutor 的使用方法不對
  3. 我設計的 ThreadFactoryRejectedExecutionHandler 有問題

原因 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 重構

上面的問題看似簡單,但能出現這麼低階的錯誤,值得我思考。我因為產生該錯誤的原因有二:

  1. 我不瞭解 ThreadPoolExecutor 的原理,從語法上看 ThreadFactory 的實現類只需要傳出一個 Thread 例項就行了,卻不知 Runnable r 不可或缺。
  2. 測試程式碼結構凌亂不堪。即便是測試程式碼,也不應該寫成麵條,自己看尚不能清楚明瞭,何談讀者?

於是,我決定對測試程式碼進行重構。這次重構,一要使執行緒工廠產生非守護執行緒,防止因為主程式的退出導致執行緒池中執行緒全部意外退出;二要對每個操作打日誌,我們要能直觀的觀察到執行緒池在做什麼,值得一提的是,對於阻塞佇列的日誌操作,我使用了動態代理的方式對每一個方法打日誌,不熟悉動態代理的童鞋可以戳我之前寫的小豹子帶你看原始碼: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 程式碼:

為了幫助理解,我根據程式碼邏輯畫了一個流程圖:

execute 方法流程圖

現在我明白了,只有等待佇列插入失敗(如達到容量上限等)情況下,才會建立非核心執行緒來處理任務,也就是說,我們使用的 LinkedBlockingQueue 佇列來作為等待佇列,那是看不到非核心執行緒被建立的現象的。

有心的讀者可能注意到了,整個過程沒有加鎖啊,怎樣保證併發安全呢?我們觀察這段程式碼,其實沒必要全部加鎖,只需要保證 addWorkerremoveworkQueue.offer 三個方法的執行緒安全,該方法就沒必要加鎖。事實上,在 addWorker 中是有對執行緒池狀態的 recheck 的,如果建立失敗會返回 false。

系列文章

小豹子還是一個大三的學生,小豹子希望你能“批判性的”閱讀本文,對本文內容中不正確、不妥當之處進行嚴厲的批評,小豹子感激不盡。

相關文章