Java 線程池講解——針對 IO 密集型任務

針對 IO 密集型的任務,我們可以針對原本的線程池做一些改造,從而可以提高任務的處理效率。

基本

阿里巴巴泰山版java開發手冊中有這麼一條:

線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 的方式,
這樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。

那麼如果要使用 ThreadPoolExecutor ,那就先來看看構造方法中的所有入參:

corePoolSize : 核心線程數,當線程池中的線程數量爲 corePoolSize 時,即使這些線程處於空閒狀態,也不會銷燬(除非設置 allowCoreThreadTimeOut)。
maximumPoolSize : 最大線程數,線程池中允許的線程數量的最大值。
keepAliveTime : 線程空閒時間,當線程池中的線程數大於 corePoolSize 時,多餘的空閒線程將在銷燬之前等待新任務的最長時間。
workQueue : 任務隊列
unit :線程空閒時間的單位。
threadFactory :線程工廠,線程池創建線程時使用的工廠。
handler : 拒絕策略,因達到線程邊界和任務隊列滿時,針對新任務的處理方法。

這麼說可能有些難以理解,你可以結合下圖進行參考:

那麼由此我們可以知道,當大量任務被放入線程池之後,先是被核心線程執行,多餘的會被放進隊列裏,當隊列滿了之後纔會創建額外的線程進行處理,再多就會採取拒絕策略。

但這樣真的能滿足我們的所有需求嗎?

任務的分類

正常來說,我們可以把需要處理的任務按照消耗資源的不同,分爲兩種:CPU 密集型IO 密集型

CPU 密集型

既然名字裏帶有CPU了,說明其消耗的主要資源就是 CPU 了。

具體是指那種包含大量運算、在持有的 CPU 分配的時間片上一直在執行任務、幾乎不需要依賴或等待其他任何東西。

這樣的任務,在我的理解中,處理起來其實沒有多少優化空間,因爲處理時幾乎沒有等待時間,所以一直佔有 CPU 進行執行,纔是最好的方式。

唯一能想到優化的地方,就是當單個線程累計較多任務時,其他線程能進行分擔,類似fork/join框架的概念。

設置線程數時,針對單臺機器,最好就是有幾個 CPU ,就創建幾個線程,然後每個線程都在執行這種任務,永不停歇。

IO 密集型

和上面一樣,既然名字裏帶有IO了,說明其消耗的主要資源就是 IO 了。

我們所接觸到的 IO ,大致可以分成兩種:磁盤 IO網絡 IO

磁盤 IO ,大多都是一些針對磁盤的讀寫操作,最常見的就是文件的讀寫,假如你的數據庫、 Redis 也是在本地的話,那麼這個也屬於磁盤 IO。

網絡 IO ,這個應該是大家更加熟悉的,我們會遇到各種網絡請求,比如 http 請求、遠程數據庫讀寫、遠程 Redis 讀寫等等。

IO 操作的特點就是需要等待,我們請求一些數據,由對方將數據寫入緩衝區,在這段時間中,需要讀取數據的線程根本無事可做,因此可以把 CPU 時間片讓出去,直到緩衝區寫滿。

既然這樣,IO 密集型任務其實就有很大的優化空間了(畢竟存在等待),那現有的線程池可以很好的滿足我們的需求嗎?

線程池的優化

還記得上面說的, ThreadPoolExecutor 針對多餘任務的處理,是先放到等待隊列中,當隊列塞滿後,再創建額外的線程進行處理。

假設我們的任務基本都是 IO 密集型,我們希望程序可以有更高的吞吐量,可以在更短的時間內處理更多的任務,那麼上面的 ThreadPoolExecutor 明顯是不滿足我們的需求,那該如何解決呢?

也許再來看看 ThreadPoolExecutor 的 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);
    }

針對放入隊列的操作,如果隊列放入失敗,線程池就會選擇去創建線程了。因此,我們或許可以嘗試自定義線程池,針對 offer 操作,做一些自定義處理。

也就是將任務放入隊列時,先檢查線程池的線程數是否小於最大線程數,如果是,則拒絕放入隊列,否則,再嘗試放入隊列中。

如果你有看過 dubbo 或者 tomcat 的線程池,你會發現他們就有這樣的實現方法。

比如 dubbo 中的 TaskQueue,我們來看看它的 offer 方法:

    @Override
    public boolean offer(Runnable runnable) {
        if (executor == null) {
            throw new RejectedExecutionException("The task queue does not have executor!");
        }

        int currentPoolThreadSize = executor.getPoolSize();
        // 如果有空閒等待的線程,則將任務放入隊列中,讓線程去處理任務
        if (executor.getSubmittedTaskCount() < currentPoolThreadSize) {
            return super.offer(runnable);
        }

        // 如果當前線程數小於最大線程數,則返回 false ,讓線程池去創建新的線程
        if (currentPoolThreadSize < executor.getMaximumPoolSize()) {
            return false;
        }

        // 否則,就將任務放入隊列中
        return super.offer(runnable);
    }

這樣就可以讓線程池優先新建線程了。需要注意的,此時的隊列因爲需要根據線程池中的線程數決定是否放入任務成功,所以需要持有executor對象,這點不要忘記奧。

總結

通過本篇文章,主要是讓大家重新瞭解了一下 ThreadPoolExecutor ,並針對高吞吐場景下如何進行局部優化。

有興趣的話可以訪問我的博客或者關注我的公衆號,說不定會有意外的驚喜。

https://death00.github.io/

公衆號:健程之道

點擊此處留言

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