Java線程池的使用

前言

在Java中,我們可以利用多線程來最大化地壓榨CPU多核計算的能力。但是,線程本身是把雙刃劍,我們需要知道它的利弊,才能在實際系統中遊刃有餘地運用。

在進入主題之前,我們先了解一下線程池的基本概念。

線程池,本質上是一種對象池,用於管理線程資源。
在任務執行前,需要從線程池中拿出線程來執行。
在任務執行完成之後,需要把線程放回線程池。
通過線程的這種反覆利用機制,可以有效地避免直接創建線程所帶來的壞處。

我們先來看看線程池帶來了哪些好處。

  1. 降低資源的消耗。線程本身是一種資源,創建和銷燬線程會有CPU開銷;創建的線程也會佔用一定的內存。
  2. 提高任務執行的響應速度。任務執行時,可以不必等到線程創建完之後再執行。
  3. 提高線程的可管理性。線程不能無限制地創建,需要進行統一的分配、調優和監控。

接下來,我們看看不使用線程池有哪些壞處。

  1. 頻繁的線程創建和銷燬會佔用更多的CPU和內存
  2. 頻繁的線程創建和銷燬會對GC產生比較大的壓力
  3. 線程太多,線程切換帶來的開銷將不可忽視
  4. 線程太少,多核CPU得不到充分利用,是一種浪費

因此,我們有必要對線程池進行比較完整地說明,以便能對線程池進行正確地治理。

線程池實現原理

線程池主要處理流程

通過上圖,我們看到了線程池的主要處理流程。我們的關注點在於,任務提交之後是怎麼執行的。大致如下:

  1. 判斷核心線程池是否已滿,如果不是,則創建線程執行任務
  2. 如果核心線程池滿了,判斷隊列是否滿了,如果隊列沒滿,將任務放在隊列中
  3. 如果隊列滿了,則判斷線程池是否已滿,如果沒滿,創建線程執行任務
  4. 如果線程池也滿了,則按照拒絕策略對任務進行處理

在jdk裏面,我們可以將處理流程描述得更清楚一點。來看看ThreadPoolExecutor的處理流程。

ThreadPoolExecutor的處理流程

我們將概念做一下映射。

  1. corePool -> 核心線程池
  2. maximumPool -> 線程池
  3. BlockQueue -> 隊列
  4. RejectedExecutionHandler -> 拒絕策略

入門級例子

爲了更直觀地理解線程池,我們通過一個例子來宏觀地瞭解一下線程池用法。

public class ThreadPoolTest {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                System.out.println("thread id is: " + Thread.currentThread().getId());
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

在這個例子中,我們首先創建了一個固定長度爲5的線程池。然後使用循環的方式往線程池中提交了10個任務,每個任務休眠1秒。在任務休眠之前,將任務所在的線程id進行打印輸出。

所以,理論上只會打印5個不同的線程id,且每個線程id會被打印2次。是不是這樣的呢?檢驗真理最好的方式就是運行一下。我們看看執行結果如何。

Executors

Executors是一個線程池工廠,提供了很多的工廠方法,我們來看看它大概能創建哪些線程池。

// 創建單一線程的線程池
public static ExecutorService newSingleThreadExecutor();
// 創建固定數量的線程池
public static ExecutorService newFixedThreadPool(int nThreads);
// 創建帶緩存的線程池
public static ExecutorService newCachedThreadPool();
// 創建定時調度的線程池
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize);
// 創建流式(fork-join)線程池
public static ExecutorService newWorkStealingPool();

1. 創建單一線程的線程池

顧名思義,這個線程池只有一個線程。若多個任務被提交到此線程池,那麼會被緩存到隊列(隊列長度爲Integer.MAX_VALUE)。當線程空閒的時候,按照FIFO的方式進行處理。

2. 創建固定數量的線程池

創建單一線程的線程池類似,只是這兒可以並行處理任務的線程數更多一些罷了。若多個任務被提交到此線程池,會有下面的處理過程。

  1. 如果線程的數量未達到指定數量,則創建線程來執行任務
  2. 如果線程池的數量達到了指定數量,並且有線程是空閒的,則取出空閒線程執行任務
  3. 如果沒有線程是空閒的,則將任務緩存到隊列(隊列長度爲Integer.MAX_VALUE)。當線程空閒的時候,按照FIFO的方式進行處理

3. 創建帶緩存的線程池

這種方式創建的線程池,核心線程池的長度爲0,線程池最大長度爲Integer.MAX_VALUE。由於本身使用SynchronousQueue作爲等待隊列的緣故,導致往隊列裏面每插入一個元素,必須等待另一個線程從這個隊列刪除一個元素。

4. 創建定時調度的線程池

和上面3個工廠方法返回的線程池類型有所不同,它返回的是ScheduledThreadPoolExecutor類型的線程池。平時我們實現定時調度功能的時候,可能更多的是使用第三方類庫,比如:quartz等。但是對於更底層的功能,我們仍然需要了解。

我們寫一個例子來看看如何使用。

public class ThreadPoolTest {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);

        // 定時調度,每個調度任務會至少等待`period`的時間,
        // 如果任務執行的時間超過`period`,則等待的時間爲任務執行的時間
        executor.scheduleAtFixedRate(() -> {
            try {
                Thread.sleep(10000);
                System.out.println(System.currentTimeMillis() / 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 0, 2, TimeUnit.SECONDS);

        // 定時調度,第二個任務執行的時間 = 第一個任務執行時間 + `delay`
        executor.scheduleWithFixedDelay(() -> {
            try {
                Thread.sleep(5000);
                System.out.println(System.currentTimeMillis() / 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 0, 2, TimeUnit.SECONDS);

        // 定時調度,延遲`delay`後執行,且只執行一次
        executor.schedule(() -> System.out.println("5 秒之後執行 schedule"), 5, TimeUnit.SECONDS);
    }
}
  • scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit),定時調度,每個調度任務會至少等待period的時間,如果任務執行的時間超過period,則等待的時間爲任務執行的時間
  • scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit),定時調度,第二個任務執行的時間 = 第一個任務執行時間 + delay
  • schedule(Runnable command, long delay, TimeUnit unit),定時調度,延遲delay後執行,且只執行一次

手動創建線程池

理論上,我們可以通過Executors來創建線程池,這種方式非常簡單。但正是因爲簡單,所以限制了線程池的功能。比如:無長度限制的隊列,可能因爲任務堆積導致OOM,這是非常嚴重的bug,應儘可能地避免。怎麼避免?歸根結底,還是需要我們通過更底層的方式來創建線程池。

拋開定時調度的線程池不管,我們看看ThreadPoolExecutor。它提供了好幾個構造方法,但是最底層的構造方法卻只有一個。那麼,我們就從這個構造方法着手分析。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler);

這個構造方法有7個參數,我們逐一來進行分析。

  1. corePoolSize,線程池中的核心線程數
  2. maximumPoolSize,線程池中的最大線程數
  3. keepAliveTime,空閒時間,當線程池數量超過核心線程數時,多餘的空閒線程存活的時間,即:這些線程多久被銷燬。
  4. unit,空閒時間的單位,可以是毫秒、秒、分鐘、小時和天,等等
  5. workQueue,等待隊列,線程池中的線程數超過核心線程數時,任務將放在等待隊列,它是一個BlockingQueue類型的對象
  6. threadFactory,線程工廠,我們可以使用它來創建一個線程
  7. handler,拒絕策略,當線程池和等待隊列都滿了之後,需要通過該對象的回調函數進行回調處理

這些參數裏面,基本類型的參數都比較簡單,我們不做進一步的分析。我們更關心的是workQueuethreadFactoryhandler,接下來我們將進一步分析。

1. 等待隊列-workQueue

等待隊列是BlockingQueue類型的,理論上只要是它的子類,我們都可以用來作爲等待隊列。

同時,jdk內部自帶一些阻塞隊列,我們來看看大概有哪些。

  1. ArrayBlockingQueue,隊列是有界的,基於數組實現的阻塞隊列
  2. LinkedBlockingQueue,隊列可以有界,也可以無界。基於鏈表實現的阻塞隊列
  3. SynchronousQueue,不存儲元素的阻塞隊列,每個插入操作必須等到另一個線程調用移除操作,否則插入操作將一直處於阻塞狀態。該隊列也是Executors.newCachedThreadPool()的默認隊列
  4. PriorityBlockingQueue,帶優先級的無界阻塞隊列

通常情況下,我們需要指定阻塞隊列的上界(比如1024)。另外,如果執行的任務很多,我們可能需要將任務進行分類,然後將不同分類的任務放到不同的線程池中執行。

2. 線程工廠-threadFactory

ThreadFactory是一個接口,只有一個方法。既然是線程工廠,那麼我們就可以用它生產一個線程對象。來看看這個接口的定義。

public interface ThreadFactory {

    /**
     * Constructs a new {@code Thread}.  Implementations may also initialize
     * priority, name, daemon status, {@code ThreadGroup}, etc.
     *
     * @param r a runnable to be executed by new thread instance
     * @return constructed thread, or {@code null} if the request to
     *         create a thread is rejected
     */
    Thread newThread(Runnable r);
}

Executors的實現使用了默認的線程工廠-DefaultThreadFactory。它的實現主要用於創建一個線程,線程的名字爲pool-{poolNum}-thread-{threadNum}

static class DefaultThreadFactory implements ThreadFactory {
    private static final AtomicInteger poolNumber = new AtomicInteger(1);
    private final ThreadGroup group;
    private final AtomicInteger threadNumber = new AtomicInteger(1);
    private final String namePrefix;

    DefaultThreadFactory() {
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() :
                              Thread.currentThread().getThreadGroup();
        namePrefix = "pool-" +
                      poolNumber.getAndIncrement() +
                     "-thread-";
    }

    public Thread newThread(Runnable r) {
        Thread t = new Thread(group, r,
                              namePrefix + threadNumber.getAndIncrement(),
                              0);
        if (t.isDaemon())
            t.setDaemon(false);
        if (t.getPriority() != Thread.NORM_PRIORITY)
            t.setPriority(Thread.NORM_PRIORITY);
        return t;
    }
}

很多時候,我們需要自定義線程名字。我們只需要自己實現ThreadFactory,用於創建特定場景的線程即可。

3. 拒絕策略-handler

所謂拒絕策略,就是當線程池滿了、隊列也滿了的時候,我們對任務採取的措施。或者丟棄、或者執行、或者其他...

jdk自帶4種拒絕策略,我們來看看。

  1. CallerRunsPolicy // 在調用者線程執行
  2. AbortPolicy // 直接拋出RejectedExecutionException異常
  3. DiscardPolicy // 任務直接丟棄,不做任何處理
  4. DiscardOldestPolicy // 丟棄隊列裏最舊的那個任務,再嘗試執行當前任務

這四種策略各有優劣,比較常用的是DiscardPolicy,但是這種策略有一個弊端就是任務執行的軌跡不會被記錄下來。所以,我們往往需要實現自定義的拒絕策略, 通過實現RejectedExecutionHandler接口的方式。

提交任務的幾種方式

往線程池中提交任務,主要有兩種方法,execute()submit()

execute()用於提交不需要返回結果的任務,我們看一個例子。

public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    executor.execute(() -> System.out.println("hello"));
}

submit()用於提交一個需要返回果的任務。該方法返回一個Future對象,通過調用這個對象的get()方法,我們就能獲得返回結果。get()方法會一直阻塞,直到返回結果返回。另外,我們也可以使用它的重載方法get(long timeout, TimeUnit unit),這個方法也會阻塞,但是在超時時間內仍然沒有返回結果時,將拋出異常TimeoutException

public static void main(String[] args) throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    Future<Long> future = executor.submit(() -> {
        System.out.println("task is executed");
        return System.currentTimeMillis();
    });
    System.out.println("task execute time is: " + future.get());
}

關閉線程池

在線程池使用完成之後,我們需要對線程池中的資源進行釋放操作,這就涉及到關閉功能。我們可以調用線程池對象的shutdown()shutdownNow()方法來關閉線程池。

這兩個方法都是關閉操作,又有什麼不同呢?

  1. shutdown()會將線程池狀態置爲SHUTDOWN,不再接受新的任務,同時會等待線程池中已有的任務執行完成再結束。
  2. shutdownNow()會將線程池狀態置爲SHUTDOWN,對所有線程執行interrupt()操作,清空隊列,並將隊列中的任務返回回來。

另外,關閉線程池涉及到兩個返回boolean的方法,isShutdown()isTerminated,分別表示是否關閉和是否終止。

如何正確配置線程池的參數

前面我們講到了手動創建線程池涉及到的幾個參數,那麼我們要如何設置這些參數纔算是正確的應用呢?實際上,需要根據任務的特性來分析。

  1. 任務的性質:CPU密集型、IO密集型和混雜型
  2. 任務的優先級:高中低
  3. 任務執行的時間:長中短
  4. 任務的依賴性:是否依賴數據庫或者其他系統資源

不同的性質的任務,我們採取的配置將有所不同。在《Java併發編程實踐》中有相應的計算公式。

通常來說,如果任務屬於CPU密集型,那麼我們可以將線程池數量設置成CPU的個數,以減少線程切換帶來的開銷。如果任務屬於IO密集型,我們可以將線程池數量設置得更多一些,比如CPU個數*2。

PS:我們可以通過Runtime.getRuntime().availableProcessors()來獲取CPU的個數。

線程池監控

如果系統中大量用到了線程池,那麼我們有必要對線程池進行監控。利用監控,我們能在問題出現前提前感知到,也可以根據監控信息來定位可能出現的問題。

那麼我們可以監控哪些信息?又有哪些方法可用於我們的擴展支持呢?

首先,ThreadPoolExecutor自帶了一些方法。

  1. long getTaskCount(),獲取已經執行或正在執行的任務數
  2. long getCompletedTaskCount(),獲取已經執行的任務數
  3. int getLargestPoolSize(),獲取線程池曾經創建過的最大線程數,根據這個參數,我們可以知道線程池是否滿過
  4. int getPoolSize(),獲取線程池線程數
  5. int getActiveCount(),獲取活躍線程數(正在執行任務的線程數)

其次,ThreadPoolExecutor留給我們自行處理的方法有3個,它在ThreadPoolExecutor中爲空實現(也就是什麼都不做)。

  1. protected void beforeExecute(Thread t, Runnable r) // 任務執行前被調用
  2. protected void afterExecute(Runnable r, Throwable t) // 任務執行後被調用
  3. protected void terminated() // 線程池結束後被調用

針對這3個方法,我們寫一個例子。

public class ThreadPoolTest {
    public static void main(String[] args) {
        ExecutorService executor = new ThreadPoolExecutor(1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1)) {
            @Override protected void beforeExecute(Thread t, Runnable r) {
                System.out.println("beforeExecute is called");
            }
            @Override protected void afterExecute(Runnable r, Throwable t) {
                System.out.println("afterExecute is called");
            }
            @Override protected void terminated() {
                System.out.println("terminated is called");
            }
        };

        executor.submit(() -> System.out.println("this is a task"));
        executor.shutdown();
    }
}

輸出結果如下:

beforeExecute is called
this is a task
afterExecute is called
terminated is called

一個特殊的問題

任何代碼在使用的時候都可能遇到問題,線程池也不例外。樓主在現實的系統中就遇到過很奇葩的問題。我們來看一個例子。

public class ThreadPoolTest {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            executor.submit(new DivTask(100, i));
        }
    }

    static class DivTask implements Runnable {
        int a, b;

        public DivTask(int a, int b) {
            this.a = a;
            this.b = b;
        }

        @Override public void run() {
            double result = a / b;
            System.out.println(result);
        }
    }
}

該代碼執行的結果如下。

 

我們循環了5次,理論上應該有5個結果被輸出。可是最終的執行結果卻很讓人很意外--只有4次輸出。我們進一步分析發現,當第一次循環,除數爲0時,理論上應該拋出異常纔對,但是這兒卻沒有,異常被莫名其妙地吞掉了!

這又是爲什麼呢?

我們進一步看看submit()方法,這個方法是一個非阻塞方法,有一個返回對象,返回的是Future對象。那麼我們就猜測,會不會是因爲沒有對Future對象做處理導致的。

我們將代碼微調一下,重新運行,異常信息終於打印出來了。

for (int i = 0; i < 5; i++) {
    Future future= executor.submit(new DivTask(100, i));
    try {
        future.get();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

PS:在使用submit()的時候一定要注意它的返回對象Future,爲了避免任務執行異常被吞掉的問題,我們需要調用Future.get()方法。另外,使用execute()將不會出現這種問題。

總結

通過這篇文章,我們已經對Java線程池有了一個比較全面和深入的理解。根據前人的經驗,我們需要注意下面幾點:

  1. 儘量使用手動的方式創建線程池,避免使用Executors工廠類
  2. 根據場景,合理設置線程池的各個參數,包括線程池數量、隊列、線程工廠和拒絕策略
  3. 在調線程池submit()方法的時候,一定要儘量避免任務執行異常被吞掉的問題


 

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