Java併發(11)- 有關執行緒池的10個問題

knock_小新發表於2019-02-26

引言

在日常開發中,執行緒池是使用非常頻繁的一種技術,無論是服務端多執行緒接收使用者請求,還是客戶端多執行緒處理資料,都會用到執行緒池技術,那麼全面的瞭解執行緒池的使用、背後的實現原理以及合理的優化執行緒池的大小等都是非常有必要的。這篇文章會通過對十個常見問題的解答來講解執行緒池的基本功能以及背後的原理,希望能對大家有所幫助。

  1. 舉個例子來說明為什麼要使用執行緒池,有什麼好處?
  2. jdk1.8中提供了哪幾種基本的執行緒池?
  3. 執行緒池幾大元件的關係?
  4. ExecutorService的生命週期?
  5. 執行緒池中的執行緒能設定超時嗎?
  6. 怎麼取消執行緒池中的執行緒?
  7. 如何設定一個合適的執行緒池大小?
  8. 當使用有界佇列時,如何設定一個合適的佇列大小?
  9. 當使用有界佇列時,如果佇列已滿,如何選擇合適的拒絕策略?
  10. 如何統計執行緒池中的執行緒執行時間?

1. 舉個例子來說明為什麼要使用執行緒池,有什麼好處?

先來看這樣一個場景,服務端在一個執行緒內通過監聽8888埠來接收多個客戶端的訊息。為了避免阻塞主執行緒,每收到一個訊息,就開啟一個新的執行緒來處理,這樣主執行緒就可以不停的接收新的訊息。不使用執行緒池時程式碼的簡單實現如下:

public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = new ServerSocket(8888);

    while (true) {
        try {
            Socket socket = serverSocket.accept();

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    //do something
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();

        } catch (IOException e) {
        }
    }
}
複製程式碼

通過每次new一個新的執行緒的方式,不會阻塞主執行緒,提高了服務端接收訊息的能力。但是存在幾個非常明顯的問題:

  • 不停初始化執行緒的記憶體消耗,任何時候資源都是有限的,無限制的新建執行緒會佔用大量的記憶體空間。
  • 在CPU資源有限的情況下,新建更多的執行緒不僅不能達到併發處理客戶端訊息的目的,相反由於執行緒間的切換更加頻繁,會導致處理時間更長,效率更加低下。
  • 執行緒本身的建立與銷燬都需要耗費伺服器資源。
  • 不方便對執行緒進行集中管理。
    而這些問題都是可以通過使用執行緒池得倒解決的。

2. jdk1.8中提供了哪幾種基本的執行緒池以及它們的使用場景?

  • newFixedThreadPool,固定執行緒數的執行緒池。它的核心執行緒數(corePoolSize)和最大執行緒數(maximumPoolSize)是相等的。同時它使用一個無界的阻塞佇列LinkedBlockingQueue來儲存額外的任務,也就是說當達到nThreads的執行緒數在執行之後,所有的後續執行緒都會進入LinkedBlockingQueue中,不會再建立新的執行緒。

    使用場景:因為執行緒數固定,一般適用於可預測的並行任務執行環境。

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>());
}
複製程式碼
  • newCachedThreadPool,可快取執行緒的執行緒池。預設核心執行緒數(corePoolSize)為0,最大執行緒數(maximumPoolSize)為Integer.MAX_VALUE,它還有一個過期時間為60秒,當執行緒閒置超過60秒之後會被回收。內部使用SynchronousQueue作為阻塞佇列。

    使用場景:由於SynchronousQueue無容量的特性,導致了newCachedThreadPool不適合做長時間的任務。因為如果單個任務執行時間過長,每當無空閒執行緒時,會導致開啟新執行緒,而執行緒數量可以達到Integer.MAX_VALUE,儲存佇列又不能快取任務,很容易導致OOM的問題。所以他的使用場景一般在大量短時間任務的執行上。

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
複製程式碼
  • newSingleThreadExecutor,單執行緒執行緒池。預設核心執行緒數(corePoolSize)和最大執行緒數(maximumPoolSize)都為1,使用無界阻塞佇列LinkedBlockingQueue。

    使用場景:由於只能有一個執行緒在執行,而且其他任務都會排隊,適用於單執行緒序列執行有序任務的環境。

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
複製程式碼
  • newScheduledThreadPool與newSingleThreadScheduledExecutor,執行延時或者週期性任務的執行緒池,使用了一個內部實現的DelayedWorkQueue阻塞佇列。可以看到它的返回結果是ScheduledExecutorService,它擴充套件了ExecutorService介面,提供了用於延時和週期執行任務的方法。

    使用場景:用於延時啟動任務,或需要週期性執行的任務。

    public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
    }

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }

    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }
複製程式碼
  • newWorkStealingPool,它是jdk1.8提供的一種執行緒池,用於執行並行任務。預設並行級別為當前可用最大可用cpu數量的執行緒。

    使用場景:用於大耗時同時可以分段並行的任務。

    public static ExecutorService newWorkStealingPool() {
        return new ForkJoinPool
            (Runtime.getRuntime().availableProcessors(),
             ForkJoinPool.defaultForkJoinWorkerThreadFactory,
             null, true);
    }
複製程式碼

3. 執行緒池幾大元件的關係?

執行緒池簡單來說可以分為四大元件:Executor、ExecutorService、Executors以及ThreadPoolExecutor。

  • Executor介面定義了一個以Runnable為引數的execute方法。這也是對執行緒池框架的一個抽象,它將執行緒池能做什麼和具體怎麼做拆分開來,也可以看做是一個生產者和消費者模式,Executor負責生產任務,具體的執行緒池負責消費任務,讓使用者能夠更加靈活的切換執行緒池具體策略,它也是執行緒池多樣性的基礎。
public interface Executor {
    void execute(Runnable command);
}
複製程式碼

那麼在ThreadPoolExecutor中,是怎麼實現execute方法的呢?來看下ThreadPoolExecutor中execute方法的原始碼,裡面的註釋實在太詳細了,簡直時良好註釋的典範。這裡只做個簡單總結:首先當工作執行緒小於核心執行緒數時會嘗試新增worker到佇列中去執行,如果核心執行緒不夠用會將任務加入佇列中,如果入隊也不成功,會採取拒絕策略。

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    /*
        * Proceed in 3 steps:
        *
        * 1. If fewer than corePoolSize threads are running, try to
        * start a new thread with the given command as its first
        * task.  The call to addWorker atomically checks runState and
        * workerCount, and so prevents false alarms that would add
        * threads when it shouldn`t, by returning false.
        *
        * 2. If a task can be successfully queued, then we still need
        * to double-check whether we should have added a thread
        * (because existing ones died since last checking) or that
        * the pool shut down since entry into this method. So we
        * recheck state and if necessary roll back the enqueuing if
        * stopped, or start a new thread if there are none.
        *
        * 3. If we cannot queue task, then we try to add a new
        * thread.  If it fails, we know we are shut down or saturated
        * and so reject the task.
        */
    //ctl通過位運算同時標記了執行緒數量以及執行緒狀態
    int c = ctl.get();
    //workerCountOf方法用來統計當前執行的執行緒數量
    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);
}
複製程式碼
  • ExecutorService介面繼承自Executor介面,提供了更加完善的執行緒池控制功能。並將執行緒池的狀態分為執行中,關閉,終止三種。同時提供了帶返回值的提交,方便更好的控制提交的任務。
public interface ExecutorService extends Executor {
    //關閉執行緒池,關閉狀態
    void shutdown();
    //立即關閉執行緒池,關閉狀態
    List<Runnable> shutdownNow();
    
    boolean isShutdown();
    
    boolean isTerminated();
    //提交一個Callable型別的任務,帶Future返回值
    <T> Future<T> submit(Callable<T> task);
    //提交一個Runnable型別的任務,帶Future返回值
    Future<?> submit(Runnable task);
    //一段時間後終止執行緒池,終止狀態
    boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException;
    ......
}
複製程式碼

還是通過ThreadPoolExecutor來說明,ThreadPoolExecutor中將執行緒池狀態進行了擴充套件,定義了5種狀態,這5種狀態通過Integer.SIZE的高3位來表示。程式碼如下:

* The runState provides the main lifecycle control, taking on values:
*   能夠接受新任務也能處理佇列中的任務
*   RUNNING:  Accept new tasks and process queued tasks
*   不能接受新任務,但能處理佇列中的任務
*   SHUTDOWN: Don`t accept new tasks, but process queued tasks
    不能接受新任務,也不能處理佇列中的任務,同時會中斷正在執行的任務
*   STOP:     Don`t accept new tasks, don`t process queued tasks,
*             and interrupt in-progress tasks
    所有的任務都被終止,工作執行緒為0
*   TIDYING:  All tasks have terminated, workerCount is zero,
*             the thread transitioning to state TIDYING
*             will run the terminated() hook method
    terminated方法執行完成
*   TERMINATED: terminated() has completed
private static final int COUNT_BITS = Integer.SIZE - 3;

private static final int RUNNING    = -1 << COUNT_BITS;//101
private static final int SHUTDOWN   =  0 << COUNT_BITS;//000
private static final int STOP       =  1 << COUNT_BITS;//001
private static final int TIDYING    =  2 << COUNT_BITS;//010
private static final int TERMINATED =  3 << COUNT_BITS;//011
複製程式碼

再來看看通過ExecutorService介面對這5種狀態的轉換:

public interface ExecutorService extends Executor {
    //關閉執行緒池,執行緒池狀態會從RUNNING變為SHUTDOWN
    void shutdown();
    //立即關閉執行緒池RUNNING或者SHUTDOWN到STOP
    List<Runnable> shutdownNow();
    //STOP、TIDYING以及TERMINATED都返回true
    boolean isShutdown();
    //TERMINATED狀態返回true
    boolean isTerminated();
    //一段時間後終止執行緒池,TERMINATED
    boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException;
    ......
}
複製程式碼
  • Executors提供了一系列獲取執行緒池的靜態方法,相當於執行緒池工廠,是對ThreadPoolExecutor的一個封裝,簡化了使用者切換Executor和ExecutorService的各種實現細節。

  • ThreadPoolExecutor是對Executor以及ExecutorService的實現,提供了具體的執行緒池實現。

4. ExecutorService的生命週期?

這個問題在上面已經做了解說,ExecutorService的生命週期通過介面定義可以分為執行中,關閉,終止三種狀態。

ThreadPoolExecutor在具體實現上提供了更加詳細的五種狀態:RUNNING、SHUTDOWN、STOP、TIDYING以及TERMINATED。各種狀態的說明以及轉換可以看上一個問題的答案。

5. 執行緒池中的執行緒能設定超時嗎?

執行緒池中的執行緒是可以進行超時控制的,通過ExecutorService的submit來提交任務,這樣會返回一個Future型別的結果,來看看Future介面的程式碼:

public interface Future<V> {
    
    boolean cancel(boolean mayInterruptIfRunning);

    boolean isCancelled();

    boolean isDone();
    //獲取返回結果,並在出現錯誤或者中斷時throws Exception
    V get() throws InterruptedException, ExecutionException;
    //timeout時間內獲取返回結果,並在出現錯誤、中斷以及超時時throws Exception
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}
複製程式碼

Future定義了get()以及get(long timeout, TimeUnit unit)方法,get()方法會阻塞當前呼叫,一直到獲取到返回結果,get(long timeout, TimeUnit unit)會在指定時間內阻塞,當超時後會丟擲TimeoutException錯誤。這樣就可以達到執行緒超時控制的目的。簡單使用示例如下:

Future<String> future = executor.submit(callable);
try {
    future.get(2000, TimeUnit.SECONDS);
} catch (InterruptedException e1) {
    //中斷後處理
} catch (ExecutionException e1) {
    //丟擲異常處理
} catch (TimeoutException e1) {
    //超時處理
}
複製程式碼

這裡有一個問題就是因為get方法是阻塞的—通過LockSupport.park實現,那麼執行緒池中執行緒比較多的情況下要怎麼獲取每個執行緒的超時呢?這裡除了自定義執行緒池實現或者自定義執行緒工廠來實現之外,使用ThreadPoolExecutor本身的功能我也沒想到更好的辦法。有一個非常笨的解決方案是開啟同執行緒池數量相等的執行緒進行監聽。大家如果有更好的辦法可以留言提出。

6. 怎麼取消執行緒池中的執行緒?

這個問題和上面的問題解決方案一樣,同樣也是通過ExecutorService的submit來提交任務,獲取Future,呼叫Future中的cancel方法來達到目的。

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
}
複製程式碼

cancel方法有一個mayInterruptIfRunning引數,當為true時,代表任務能接受並處理中斷,呼叫了interrupt方法。如果為false,代表如果任務沒啟動就不要執行它,不會呼叫interrupt方法。

取消的本質實際上還是通過interrupt來實現的,這就是說,如果執行緒本身不能響應中斷,就算呼叫了cancel方法也沒用。一般情況下通過lockInterruptibly、park和await方法阻塞的執行緒都是能響應中斷的,執行中的執行緒就需要開發者自己實現中斷了。

7. 如何設定一個合適的執行緒池大小?

如何設定一個合適的執行緒池大小,這個問題我覺得是沒有一個固定公式的。或者可以說,只有一些簡單的設定規則,但放到具體業務中,又各有不同,只能根據現場環境測試過後再來分析。

設定合適的執行緒池大小分為兩部分,一部分是最大執行緒池大小,一部分是最小執行緒池大小。在ThreadPoolExecutor中體現在最大執行緒數(maximumPoolSize)和核心執行緒數(corePoolSize)。

最大執行緒池大小的設定首先跟當前機器cpu核心數密切相關,一般情況來說要想最大化利用cpu,設定為cpu核心數就可以了,比如4核cpu伺服器可以設定為4。但實際情況又大有不同,因為往往我們執行的任務都會涉及到IO,比如任務中執行了一個從資料庫查詢資料的操作,那麼這段時間cpu實際上是沒有最大化利用的,這樣我們就可以適當擴大maximumPoolSize的大小。在有些情況下任務會是cpu密集型的,如果這樣設定更多的執行緒不僅不會提高效率,反而因為執行緒的建立銷燬以及切換開銷而大大降低了效率,所以說最大執行緒池的大小需要根據業務情況具體測試後才能設定一個合適的大小。

最小執行緒池大小相比較最大執行緒池大小設定起來相對容易一些,因為最小執行緒一般來說是可以根據業務情況來預估進行設定,比如大多數情況下會有2個任務在執行,很小概率會有超過2個任務執行,那麼直接設定最小執行緒池大小為2就可以。但有一點需要知道的是每間隔多長時間會有超過2個任務,如果每2分鐘會有一次超過2個任務的情況,那麼我們可以將執行緒過期時間設定的稍微久一點,比如4分鐘,這樣就算頻繁的超過2個任務,也可以利用快取的執行緒池。

總的來說設定最大和最小執行緒池都是一個沒有固定公式的問題,都需要考慮實際業務情況和機器配置,根據實際業務情況多做測試才能做到最優化設定。在一切沒有決定之前,可以使用軟體架構的KISS原則,設定最大以及最小執行緒數都為cpu核心數即可,後續在做優化。

8. 當使用有界佇列時,如何設定一個合適的佇列大小?

要設定合適的佇列大小,先要明白佇列什麼時候會被使用。在ThreadPoolExecutor的實現中,使用佇列的情況有點特殊。它會先使用核心執行緒池大小的執行緒,之後會將任務加入佇列中,再之後佇列滿了之後才會擴大到最大執行緒池大小的執行緒。也就是說佇列的使用並不是等待執行緒不夠用了才使用,而是等待核心執行緒不夠用了就使用。我不是太能理解這樣設計的意圖,按《Java效能權威權威指南》一書中的說法是這樣提供了兩個節流閥,第一個是佇列,第二個是最大執行緒池。但這樣做並不能給使用者最優的體驗,既然要使用最大執行緒池,那為什麼不在第一次就使用呢?

知道了ThreadPoolExecutor使用執行緒池的時機,那麼再來預估合適的佇列大小就很方便了。如果單個任務執行時間在100ms,最小執行緒數是2,使用者能忍受的最大延時在2s,那麼我們可以這樣簡單推算出佇列大小:2/2s/100ms=10,這樣滿佇列時最大延時就在2s之內。當然還有其他一些影響因素,比如部分任務超過或者小於100ms,最大執行緒池的利用等等,可以在這基礎上做簡單調整。

9. 當使用有界佇列時,如果佇列已滿,如何選擇合適的拒絕策略?

ThreadPoolExecutor中提供了四種RejectedExecutionHandler,每種分工都比較明確,選擇起來並不困難。它們分別是:AbortPolicy、DiscardPolicy、DiscardOldestPolicy以及CallerRunsPolicy。下面貼出了他們的原始碼並做了簡單說明,使用的時候可以根據需要自行選擇。

//AbortPolicy
//預設的拒絕策略,直接丟擲RejectedExecutionException異常供呼叫者做後續處理
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    throw new RejectedExecutionException("Task " + r.toString() +
                                            " rejected from " +
                                            e.toString());
}

//DiscardPolicy
//不做任何處理,將任務直接拋棄掉
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
}

//DiscardOldestPolicy
//拋棄佇列中的下一個任務,然後嘗試做提交。這個使用我覺得應該是在知道當前要提交的任務比較重要,必須要被執行的場景
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) {
        e.getQueue().poll();
        e.execute(r);
    }
}

//CallerRunsPolicy
//直接使用呼叫者執行緒執行,相當於同步執行,會阻塞呼叫者執行緒,不太友好感覺。
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) {
        r.run();
    }
}
複製程式碼

10. 如何統計執行緒池中的執行緒執行時間?

要統計執行緒池中的執行緒執行時間,就需要了解執行緒池中的執行緒是在什麼地方,什麼時機執行的?知道了執行緒的執行狀態,然後線上程執行前後新增自己的處理就可以了,所以先來找到ThreadPoolExecutor中執行緒具體執行的程式碼:

final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // allow interrupts
    boolean completedAbruptly = true;
    try {
        while (task != null || (task = getTask()) != null) {
            w.lock();
            // If pool is stopping, ensure thread is interrupted;
            // if not, ensure thread is not interrupted.  This
            // requires a recheck in second case to deal with
            // shutdownNow race while clearing interrupt
            if ((runStateAtLeast(ctl.get(), STOP) ||
                    (Thread.interrupted() &&
                    runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
                beforeExecute(wt, task); //執行task.run()的前置方法
                Throwable thrown = null;
                try {
                    task.run();
                } catch (RuntimeException x) {
                    thrown = x; throw x;
                } catch (Error x) {
                    thrown = x; throw x;
                } catch (Throwable x) {
                    thrown = x; throw new Error(x);
                } finally {
                    afterExecute(task, thrown);//執行task.run()的後置方法
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}
複製程式碼

可以看到runWorker方法中在task.run()也就是任務執行前後分別執行了beforeExecute以及afterExecute方法,著兩個方法在ThreadPoolExecutor的繼承類中都是可重寫的,提供了極大的靈活性。我們可以在繼承ThreadPoolExecutor之後在任務執行前後做任何自己需要做的事情,當然也就包括任務執行的時間統計了。

順便說一句,熟悉spring原始碼的同學看到這裡是不是發現和spring中的postprocesser前後置處理器有異曲同工之妙?區別在於一個是通過繼承來覆蓋,一個是通過介面來實現。

總結

其實執行緒池框架涉及到的問題遠不止這些,包括ThreadFactory、ForkJoinPool等等還有很多值得花時間研究的地方。本文也只是閱讀jdk原始碼、《Java併發程式設計實戰》以及《Java效能優化權威指南》之後的一點點總結,如有錯誤遺漏的地方,希望大家能多多指出。

參考資料:

  • 《Java併發程式設計實戰》
  • 《Java效能優化權威指南》

相關文章