Java入門系列之執行緒池ThreadPoolExecutor原理分析思考(十五)

Jeffcky發表於2020-04-17

前言

關於執行緒池原理分析請參看《http://objcoding.com/2019/04/25/threadpool-running/》,建議對原理不太瞭解的童鞋先看下此文然後再來看本文,這裡通過對原理的學習我談談對執行緒池的理解,若有錯誤之處,還望批評指正。

執行緒池思考

執行緒池我們可認為是準備好執行應用程式級任務的預先例項化的備用執行緒集合,執行緒池通過同時執行多個任務來提高效能,同時防止執行緒建立過程中的時間和記憶體開銷,例如,一個Web伺服器在啟動時例項化執行緒池,這樣當客戶端請求進入時,它就不會花時間建立執行緒,與為每個任務都建立執行緒相比,執行緒池通過避免一次無限建立執行緒來避免資源(處理器,核心,記憶體等)用盡,建立一定數量的執行緒後,通常將多餘的任務放在等待佇列中,直到有執行緒可用於新任務。下面我們通過一個簡單的例子來概括執行緒池原理,如下:

    public static void main(String[] args) {

        ArrayBlockingQueue<Runnable> arrayBlockingQueue = new ArrayBlockingQueue<>(5);

        ThreadPoolExecutor poolExecutor =
                new ThreadPoolExecutor(2,
                        5, Long.MAX_VALUE, TimeUnit.NANOSECONDS, arrayBlockingQueue);

        for (int i = 0; i < 11; i++) {
            try {
                poolExecutor.execute(new Task());
            } catch (RejectedExecutionException ex) {
                System.out.println("拒絕任務 = " + (i + 1));
            }
            printStatus(i + 1, poolExecutor);
        }
    }

    static void printStatus(int taskSubmitted, ThreadPoolExecutor e) {
        StringBuilder s = new StringBuilder();
        s.append("工作池大小 = ")
                .append(e.getPoolSize())
                .append(", 核心池大小 = ")
                .append(e.getCorePoolSize())
                .append(", 佇列大小 = ")
                .append(e.getQueue().size())
                .append(", 佇列剩餘容量 = ")
                .append(e.getQueue().remainingCapacity())
                .append(", 最大池大小 = ")
                .append(e.getMaximumPoolSize())
                .append(", 提交任務數 = ")
                .append(taskSubmitted);

        System.out.println(s.toString());
    }

    static class Task implements Runnable {

        @Override
        public void run() {
            while (true) {
                try {
                    Thread.sleep(1000000);
                } catch (InterruptedException e) {
                    break;
                }
            }
        }
    }

如上例子很好的闡述了執行緒池基本原理,我們宣告一個有界佇列(容量為5),例項化執行緒池的核心池大小為2,最大池大小為10,建立執行緒沒有自定義實現,預設通過執行緒池工廠建立,拒絕策略為預設,提交11個任務。在啟動執行緒池時,預設情況下它將以無執行緒啟動,當我們提交第一個任務時,將產生第一個工作執行緒,並將任務移交給該執行緒,只要當前工作執行緒數小於配置的核心池大小,即使某些先前建立的核心執行緒可能處於空閒狀態,也會為每個新提交的任務生成一個新的工作執行緒(注意:當工作執行緒池大小未超過核心池大小時以建立的Worker中的第一個任務執行即firstTask,而繞過了阻塞佇列),若超過核心池大小會將任務放入阻塞佇列,一旦阻塞佇列滿後將重新建立執行緒任務,若任務超過最大執行緒池大小將執行拒絕策略。當阻塞佇列為無界佇列(如LinkedBlockingQueue),很顯然設定的最大池大小將無效。我們再來闡述下,當工作執行緒數達到核心池大小時,若此時提交的任務越來越多,執行緒池的具體表現行為是什麼呢?

1、只要有任何空閒的核心執行緒(先前建立的工作執行緒,但已經完成分配的任務),它們將接管提交的新任務並執行。

2、如果沒有可用的空閒核心執行緒,則每個提交的新任務都將進入已定義的工作佇列中,直到有一個核心執行緒可以處理它為止。如果工作佇列已滿,但仍然沒有足夠的空閒核心執行緒來處理任務,那麼執行緒池將恢復而建立新的工作執行緒,新任務將由它們來執行。 一旦工作執行緒數達到最大池大小,執行緒池將再次停止建立新的工作執行緒,並且在此之後提交的所有任務都將被拒絕。

由上述2我們知道,一旦達到核心執行緒大小就會進入阻塞佇列(阻塞佇列未滿),我們可認為這是一種執行阻塞佇列優先的機制,那我們是不是可以思考一個問題:何不建立非核心執行緒來擴充套件執行緒池大小而不是進入阻塞佇列,當達到最大池大小時才進入阻塞佇列進行排隊,這種方式和預設實現方式在效率和效能上是不是可能會更好呢? 但是從另外一個層面來講,既然不想很快進入阻塞佇列,那麼何不將指定的核心池大小進行擴充套件大一些呢?我們知道執行緒數越多那麼將導致明顯的資料爭用問題,也就是說在非峰值系統中的執行緒數會很多,所以在峰值系統中通過建立非核心執行緒理論上是不是能夠比預設立即進入阻塞佇列具有支撐規模化的任務更加具有效能上的優勢呢?那麼我們怎樣才能修改預設操作呢?我們首先來看看在執行任務時的操作

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方法,新增一個是否有空閒核心池的執行緒,讓其接待任務。所以我們繼承上述有界阻塞佇列,如下:

public class CustomArrayBlockingQueue<E> extends ArrayBlockingQueue {

    private final AtomicInteger idleThreadCount = new AtomicInteger();

    public CustomArrayBlockingQueue(int capacity) {
        super(capacity);
    }

    @Override
    public boolean offer(Object o) {
        return idleThreadCount.get() > 0 && super.offer(o);
    }
}

但是不幸的是,通過對執行緒池原始碼的分析,我們並不能夠得到空閒的核心池的執行緒,但是我們可以跟蹤核心池中的空閒執行緒,在獲取任務方法中如下:

boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

if ((wc > maximumPoolSize || (timed && timedOut))
    && (wc > 1 || workQueue.isEmpty())) {
    if (compareAndDecrementWorkerCount(c))
        return null;
    continue;
}

try {
    Runnable r = timed ?
        workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
        workQueue.take();
    if (r != null)
        return r;
    timedOut = true;
} catch (InterruptedException retry) {
    timedOut = false;
}

如上擷取獲取任務的核心,若工作執行緒大小大於核心池大小時,預設情況下會進入阻塞佇列此時通過pool獲取阻塞佇列中的任務,若工作執行緒大小小於核心池大小時,此時會呼叫take方法獲從阻塞佇列中獲取可用的任務,此時說明當前核心池執行緒處於空閒狀態,如果佇列中沒有任務,則執行緒將在此呼叫時會阻塞,直到有可用的任務為止,因此核心池執行緒仍然處於空閒狀態,所以我們增加上述計數器,否則,呼叫方法返回,此時該執行緒不再處於空閒狀態,我們可以減少計數器,重寫take方法,如下:

@Override
public Object take() throws InterruptedException {
    idleThreadCount.incrementAndGet();
    Object take = super.take();
    idleThreadCount.decrementAndGet();
    return take;
}

接下來我們再來考慮timed為true的情況,在這種情況下,執行緒將使用poll方法,很顯然,進入poll方法的任何執行緒當前都處於空閒狀態,因此我們可以在工作佇列中重寫此方法的實現,以在開始時增加計數器,然後,我們可以呼叫實際的poll方法,這可能導致以下兩種情況之,如果佇列中沒有任務,則執行緒將等待此呼叫以提供所提供的超時,然後返回null。到此時,執行緒將超時,並將很快從池中退出,從而將空閒執行緒數減少1,因此我們可以在此時減少計數器,否則由方法呼叫返回,因此該執行緒不再處於空閒狀態,此時我們也可以減少計數器。

@Override
public Object poll(long timeout, TimeUnit unit) throws InterruptedException {
    idleThreadCount.incrementAndGet();
    Object poll = super.poll(timeout, unit);
    idleThreadCount.decrementAndGet();
    return poll;
}

通過上述我們對offer、pool、take方法的重寫,使得在沒有基於核心池的空閒執行緒進行擴充套件非核心執行緒,還未結束,若達到了最大池大小,此時我們需要將其新增到阻塞佇列中排隊,所以最終使用我們自定義的阻塞佇列,並使用自定義的拒絕策略,如下:

CustomArrayBlockingQueue<Runnable> arrayBlockingQueue = new CustomArrayBlockingQueue<>(5);

ThreadPoolExecutor poolExecutor =
        new ThreadPoolExecutor(10,
                100, Long.MAX_VALUE, TimeUnit.NANOSECONDS, arrayBlockingQueue
                , Executors.defaultThreadFactory(), (r, executor) -> {
            if (!executor.getQueue().add(r)) {
                System.out.println("拒絕任務");
            }
        });

for (int i = 0; i < 150; i++) {
    try {
        poolExecutor.execute(new Task());
    } catch (RejectedExecutionException ex) {
        System.out.println("拒絕任務 = " + (i + 1));
    }
    printStatus(i + 1, poolExecutor);
}

上述我們實現自定義的拒絕策略,將拒絕的任務放入到阻塞佇列中,若阻塞佇列已滿而不能再接收新的任務,我們將呼叫預設的拒絕策略或者是其他處理程式,所以在將任務新增到阻塞佇列中即呼叫add方法時,我們還需要重寫add方法,如下:

@Override
public boolean add(Object o) {
    return super.offer(o);
}

總結

以上詳細內容只是針對執行緒池的預設實現而引發的思考,通過如上方式是否能夠對於規模化的任務處理起來在效能上有一定改善呢?可能也有思慮不周全的地方,暫且分析於此。

相關文章