給師妹寫的《Java併發程式設計之執行緒池十八問》被表揚啦!

JavaBuild發表於2024-05-30

寫在開頭

    之前給一個大四正在找工作的學妹發了自己總結的關於Java併發中執行緒池的面試題集,總共18題,將之取名為《Java併發程式設計之執行緒池十八問》,今天聊天時受了學妹的誇讚,心裡很開心,畢竟自己整理的東西對別人起到了一點幫助,記錄一下!

image

Java併發程式設計之執行緒池十八問

   經過之前的學習,我們知道在Java中建立一個執行緒需要呼叫作業系統內科API,作業系統要為建立的執行緒分配一系列的資源,成本很高,因此,如果在一個程式中,我們頻繁的建立執行緒和銷燬執行緒,資源佔用巨大,效能很差,因此,便但成了 池化思想 ,將建立的執行緒放入一個池中管理,在Java中除了執行緒池,資料庫連線、HTTP連線也用到了池化思想!

   我們基於此,整理了執行緒池相關的常用面試題集,合計十八道,透過這樣的方式充分的瞭解和學習執行緒池。

第一問:什麼是執行緒池?

   所謂 執行緒池,就是一個可以管理若干執行緒的容器,當有任務需要處理時,會提交到執行緒池的任務佇列中,由執行緒池分配空閒的執行緒處理任務,處理完任務的執行緒不會被銷燬,而是線上程池中等待下一個任務。

第二問:為什麼要用執行緒池?

至於為什麼要用執行緒池,可以從如下幾點回答面試官:

  • 降低資源消耗: 頻繁的建立與銷燬執行緒,佔用大量資源,執行緒池的出現避免了這種情況,減少了資源的消耗;
  • 提高響應速度: 因為執行緒池中的執行緒處於待命狀態,有任務進來無需等待執行緒的建立就能立即執行(前提是有空閒執行緒,任務量巨大,還是需要排隊的哈);
  • 更好的管理執行緒: 執行緒是稀缺資源,如果無限制的建立,不僅會消耗系統資源,還會降低系統的穩定性,使用執行緒池可以進行統一的分配,調優和監控。

第三問:如何建立一個執行緒池,為什麼不推薦使用Executors?

在這裡我們提供2種構造執行緒池的方法:

方法一: 透過ThreadPoolExecutor建構函式來建立(首選)
   這是JDK中最核心的執行緒池工具類,在JDK1.8中,它提供了豐富的可設定的執行緒池構造引數,供我們設計不同的執行緒池,如下:
image

透過構造方法 ,可以給整個執行緒池設定大小、等待佇列、非核心執行緒存活時間、建立執行緒的工廠類、拒絕策略等,具體引數描述可見 第六問,它們線上程池中所對應的關係,可見下圖。
image

方法二: 透過 Executor 框架的工具類 Executors 來建立(不推薦)
   Executors 是java併發工具包中的一個靜態工廠類,在JDK1.5時被創造出來,提供了豐富的創造執行緒池的方法,透過它可以建立多種型別的執行緒池。

image

  • newFixedThreadPool:建立定長執行緒池,該執行緒池中的執行緒數量始終不變。當有一個新的任務提交時,執行緒池中若有空閒執行緒,則立即執行。若沒有,則新的任務會被暫存在一個任務佇列中,待有執行緒空閒時,便處理在任務佇列中的任務。當執行緒發生錯誤結束時,執行緒池會補充一個新的執行緒;
  • newCachedThreadPool:建立可快取的執行緒池,如果執行緒池的容量超過了任務數,自動回收空閒執行緒,任務增加時可以自動新增新執行緒,所有執行緒在當前任務執行完畢後,將返回執行緒池進行復用,執行緒池的容量不限制;
  • newScheduledThreadPool:建立定長執行緒池,可執行週期性的任務;
  • newSingleThreadExecutor:建立單執行緒的執行緒池,只有一個執行緒的執行緒池。若多餘一個任務被提交到該執行緒池,任務會被儲存在一個任務佇列中,待執行緒空閒,按先入先出的順序執行佇列中的任務,執行緒異常結束,會建立一個新的執行緒,能確保任務按提交順序執行;
  • newWorkStealingPool:任務可竊取執行緒池,不保證執行順序,當有空閒執行緒時會從其他任務佇列竊取任務執行,適合任務耗時差異較大的場景。

為何很多大廠都禁止使用Executors 建立執行緒池呢?

   如果大家跟入到Executors這些方法的底層實現中去看一眼的話,立馬就知道原因了,像FixedThreadPool 和 SingleThreadExecutor這兩個方法內使用的是無界的 LinkedBlockingQueue儲存任務,任務佇列最大長度為 Integer.MAX_VALUE,這樣可能會堆積大量的請求,從而導致 OOM。

   而CachedThreadPool使用的是同步佇列 SynchronousQueue, 允許建立的執行緒數量也為 Integer.MAX_VALUE ,如果任務數量過多且執行速度較慢,可能會建立大量的執行緒,從而導致 OOM,其他的方法所提供的均是這種無界任務佇列,在高併發場景下導致OOM的風險很大,故大部分的公司已經不建議採用Executors提供的方法建立執行緒池了。

第四問:如何給執行緒池命名?

   如果我們的專案模組較多,在執行時呼叫了不同模組的執行緒池,為了在發生異常後快速定位問題,我們一般會在構建執行緒池時給它一個名字,這裡我們提供幾種執行緒池命名的方法。

方法一: 透過Spring 框架提供的CustomizableThreadFactory命名

ThreadFactory springThreadFactory = new CustomizableThreadFactory("Spring執行緒池:");
ExecutorService exec = new ThreadPoolExecutor(1, 1,
         0L, TimeUnit.MILLISECONDS,
         new LinkedBlockingQueue<Runnable>(10),springThreadFactory);
 exec.submit(() -> {
     log.info(exec.toString());
 });

方法二: 透過Google guava工具類提供的ThreadFactoryBuilder命名

//鏈式呼叫
ThreadFactory guavaThreadFactory = new ThreadFactoryBuilder().setNameFormat("guava執行緒池:").build();
ExecutorService exec = new ThreadPoolExecutor(1, 1,
          0L, TimeUnit.MILLISECONDS,
          new LinkedBlockingQueue<Runnable>(10),guavaThreadFactory );
  exec.submit(() -> {
      log.info(exec.toString());
  });

   其實還有一個是Apache commons-lang3 提供的 BasicThreadFactory工廠類,也可以給執行緒池命名,咱這裡就不貼程式碼了,原因是他們的本質都是透過Thread 的setName()方法實現的!所以,我們其實自己也可以設計一個工廠類也實現執行緒池的命名操作!

方法三: 自定義工廠類實現執行緒池命名

先定義一個工廠類,透過實現ThreadFactory的newThread方法,完成命名。

public class MyThreadFactory implements ThreadFactory {

    private final AtomicInteger threadNum = new AtomicInteger();
    private final String name;

    /**
     * 建立一個帶名字的執行緒池生產工廠
     */
    public MyThreadFactory(String name) {
        this.name = name;
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        t.setName(name + "-" + threadNum.incrementAndGet());
        return t;
    }
}

呼叫一下看看結果:

@Slf4j
public class Test {
    public static void main(String[] args) {
        MyThreadFactory myThreadFactory = new MyThreadFactory("javaBuild-pool");
        ExecutorService exec = new ThreadPoolExecutor(1, 1,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>(10),myThreadFactory);
        exec.submit(() -> {
            log.info(exec.toString());
        });
    }
}

輸出:

17:46:37.387 [javaBuild-pool-1] INFO com.javabuild.server.pojo.Test - java.util.concurrent.ThreadPoolExecutor@1ee7d6d6[Running, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]

第五問:如何設定執行緒池的大小?

   我們在建立執行緒池的時候,執行緒池的大小是值得關注的點,執行緒池過小的話,在高併發場景下,同一時間有大量的任務請求處理,處理執行緒不夠用,大量的任務堆積在任務佇列中,CPU沒有得到充分的使用,任務量過大時還可能帶來OOM問題;執行緒池過大的話也會帶來問題,大量的執行緒可能會在同一時間競爭CPU資源,帶來頻繁的上下文切換,導致相應時間過長,效率低下。

那麼如何設定一個比較合適的執行緒池大小呢?
我們在這裡推薦一個公式:最佳執行緒數 = N(CPU 核心數)∗(1+WT(執行緒等待時間)/ST(執行緒計算時間)),其中 WT(執行緒等待時間)=執行緒執行總時間 - ST(執行緒計算時間)。

對於CPU密集型任務來說,WT/ST近似於0,故最佳執行緒數為N,不過一般情況下為了防止任務異常暫停導致CPU空閒,會多加一個執行緒,也就是N+1。

對於IO密集型任務來說,大部分時間都在做IO處理工作,執行緒幾乎都在等待,這時WT/ST會很大,上述公式就會算出一個很大的執行緒數,但為了避免執行緒過多帶來上下文切換問題,建議最佳執行緒數為2N。

第六問:執行緒池的常見引數有哪些?

線上程池中我們常見的引數如下:

  • corePoolSize:執行緒池中用來工作的核心執行緒數量,也可理解為執行緒池保有的最小執行緒數;
  • maximumPoolSize:最大執行緒數,執行緒池允許建立的最大執行緒數;
  • keepAliveTime:超出 corePoolSize 後建立的執行緒存活時間或者是所有執行緒最大存活時間,一個執行緒如果在一段時間內,都沒有執行任務,說明很閒,keepAliveTime 和 unit 就是用來定義這個“一段時間”的引數。也就是說,如果一個執行緒空閒了keepAliveTime & unit這麼久,而且執行緒池的執行緒數大於 corePoolSize ,那麼這個空閒的執行緒就要被回收了;
  • unit:keepAliveTime 的時間單位;
  • workQueue:任務佇列,是一個阻塞佇列,當執行緒數達到核心執行緒數後,會將任務儲存在阻塞佇列中;
  • threadFactory:執行緒池內部建立執行緒所用的工廠,可以自定義如何建立執行緒,如給執行緒指定name。
  • handler:自定義任務的拒絕策略。執行緒池中所有執行緒都在忙碌,且任務佇列已滿,執行緒池就會拒絕接收再提交的任務(後面的問題中會詳細講)。

除了在構造執行緒池時進行引數的初始化配置,在ThreadPoolExecutor中還提供了引數動態配置的方法:

image

第七問:說一說執行緒池的5種狀態?

在ThreadPoolExecutor的原始碼中定義了5個常量,用來標識執行緒池在整個任務處理週期中的狀態。

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;
  1. RUNNING: 執行緒池一旦被建立,就處於 RUNNING 狀態,任務數為 0,能夠接收新任務,對已排隊的任務進行處理。

  2. SHUTDOWN: 不接收新任務,但能處理已排隊的任務。呼叫執行緒池的 shutdown() 方法,執行緒池由 RUNNING 轉變為 SHUTDOWN 狀態。

  3. STOP: 不接收新任務,不處理已排隊的任務,並且會中斷正在處理的任務。呼叫執行緒池的 shutdownNow() 方法,執行緒池由(RUNNING 或 SHUTDOWN ) 轉變為 STOP 狀態。

  4. TIDYING:
    1)SHUTDOWN 狀態下,任務數為 0, 其他所有任務已終止,執行緒池會變為 TIDYING 狀態。

    2)執行緒池在 SHUTDOWN 狀態,任務佇列為空且執行中任務為空,執行緒池就會由 SHUTDOWN 轉變為 TIDYING 狀態。

    3)執行緒池在 STOP 狀態,執行緒池中執行中任務為空時,就會由 STOP 轉變為 TIDYING 狀態。

  5. TERMINATED: 執行緒池徹底終止。執行緒池在 TIDYING 狀態執行完 terminated() 方法就會由 TIDYING 轉變為 TERMINATED 狀態。

具體的狀態轉換可見下圖:
image

第八問:請你說一說執行緒池的執行原理(重要)?

上面聊了那麼多,我們應該對於執行緒池的作用有了一個大致的瞭解,現在來看一下它整個生命週期內是如何執行的。(以ThreadPoolExceutor為例)

1、剛new出來的執行緒池裡預設是沒有執行緒的,只有一個傳入的阻塞佇列;

2、當我們執行execute提交一個方法後,會判斷當前執行緒池中執行緒數是否小於核心執行緒數(corePoolSize),如果小於,那麼就直接透過 ThreadFactory 建立一個執行緒來執行這個任務,當任務執行完之後,執行緒不會退出,而是會去阻塞佇列中獲取任務;

3、如果當前執行的執行緒數等於或大於核心執行緒數,但是小於最大執行緒數,那麼就把該任務放入到任務佇列裡等待執行。

4、如果向任務佇列投放任務失敗(任務佇列已經滿了),但是當前執行的執行緒數是小於最大執行緒數的,就新建一個執行緒來執行任務。

5、如果當前執行的執行緒數已經等同於最大執行緒數了,新建執行緒將會使當前執行的執行緒超出最大執行緒數,那麼當前任務會被拒絕,呼叫RejectedExecutionHandler.rejectedExecution()方法。

我們跟入到execute方法的原始碼中,去看看它是如何實現的。

public void execute(Runnable command) {
    // 首先檢查提交的任務是否為null,是的話則丟擲NullPointerException。
    if (command == null)
        throw new NullPointerException();

    // 獲取執行緒池的當前狀態(ctl是一個AtomicInteger,其中包含了執行緒池狀態和工作執行緒數)
    //private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    int c = ctl.get();

    // 1. 檢查當前執行的工作執行緒數是否少於核心執行緒數(corePoolSize)
    if (workerCountOf(c) < corePoolSize) {
        // 如果少於核心執行緒數,嘗試新增一個新的工作執行緒來執行提交的任務
        // addWorker方法會檢查執行緒池狀態和工作執行緒數,並決定是否真的新增新執行緒
        if (addWorker(command, true))
            return;
        // 重新獲取執行緒池的狀態,因為在嘗試新增執行緒的過程中執行緒池的狀態可能已經發生變化
        c = ctl.get();
    }

    // 2. 嘗試將任務新增到任務佇列中
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        // 雙重檢查執行緒池的狀態
        if (! isRunning(recheck) && remove(command))  // 如果執行緒池已經停止,從佇列中移除任務
            reject(command);
        // 如果執行緒池正在執行,但是工作執行緒數為0,嘗試新增一個新的工作執行緒
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    // 3. 如果任務佇列滿了,嘗試新增一個新的非核心工作執行緒來執行任務
    else if (!addWorker(command, false))
        // 如果無法新增新的工作執行緒(可能因為執行緒池已經停止或者達到最大執行緒數限制),則拒絕任務
        reject(command);
}

這段原始碼裡透過workerCountOf()來計算當前工作執行緒數,透過addWorker()來新增執行緒執行任務,透過reject()來拒絕任務。

第九問:執行緒池的拒絕策略有哪些?

   上面的多個問題中都有提及執行緒池的拒絕策略,當執行緒池中所有執行緒都在忙碌,且任務佇列已滿,執行緒池就會拒絕接收再提交的任務,合理的配置拒絕策略對於一個執行緒池來說至關重要!

在JDK中提供了RejectedExecutionHandler介面的4種實現作為我們構建執行緒池傳參使用:

  1. AbortPolicy:預設的拒絕策略,丟棄任務並丟擲throws RejectedExecutionException;
  2. CallerRunsPolicy:由提交任務的執行緒自己去執行該任務;
  3. DiscardPolicy:直接丟棄任務,不丟擲任何異常;
  4. DiscardOldestPolicy:從佇列中剔除最先進入佇列的任務,然後再次提交任務。

除了這4種拒絕策略之外,我們也可以自己實現RejectedExecutionHandler介面,設計自己需要的拒絕方式哈。

第十問:如果不允許丟棄任務,應該選什麼拒絕策略?

根絕上一問中描述的幾種拒絕策略的特點,在這裡我們果斷選擇CallerRunsPolicy,直接在呼叫execute方法的執行緒中執行被拒絕的任務,如果執行程式已關閉,則會丟棄該任務。

public static class CallerRunsPolicy implements RejectedExecutionHandler {

        public CallerRunsPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                // 直接主執行緒執行,而不是執行緒池中的執行緒執行
                r.run();
            }
        }
    }

但這種策略會存在問題,如果我們拋給主執行緒的任務很耗時的話,會嚴重影響其他任務的提交速度,影響程式的整體效能,一般情況下不建議使用。

第十一問:執行緒池中的執行緒如何實現複用的?

我們知道執行緒池的核心功能就是實現執行緒的重複利用,那麼執行緒池是如何實現執行緒的複用呢?
我們在上面的第八問中知道了執行緒池透過addWorker()方法新增任務,而在這個方法的底層會將任務和執行緒一起封裝到一個Worker物件中,Worker 繼承了 AQS,也就是具有一定鎖的特性。

image

然後Worker中有一個run方法,執行時會去呼叫runWorker()方法來執行任務,我們來看一下這個方法的原始碼:

final void runWorker(Worker w) {
    // 獲取當前工作執行緒
    Thread wt = Thread.currentThread();
    
    // 從 Worker 中取出第一個任務
    Runnable task = w.firstTask;
    w.firstTask = null;
    
    // 解鎖 Worker(允許中斷)
    w.unlock(); 
    
    boolean completedAbruptly = true;
    try {
        // 當有任務需要執行或者能夠從任務佇列中獲取到任務時,工作執行緒就會持續執行
        while (task != null || (task = getTask()) != null) {
            // 鎖定 Worker,確保在執行任務期間不會被其他執行緒干擾
            w.lock();
            
            // 如果執行緒池正在停止,並確保執行緒已經中斷
            // 如果執行緒沒有中斷並且執行緒池已經達到停止狀態,中斷執行緒
            if ((runStateAtLeast(ctl.get(), STOP) ||
                 (Thread.interrupted() &&
                  runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            
            try {
                // 在執行任務之前,可以插入一些自定義的操作
                beforeExecute(wt, task);
                
                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);
                }
            } finally {
                // 清空任務,並更新完成任務的計數
                task = null;
                w.completedTasks++;
                // 解鎖 Worker
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        // 工作執行緒退出的後續處理
        processWorkerExit(w, completedAbruptly);
    }
}

在這段原始碼中我們看到了執行緒被複用的原因了,就是這個while的迴圈,當有任務需要執行或者能夠從任務佇列中獲取到任務時,工作執行緒就會持續執行;如果從 getTask 獲取不到方法的話,就會呼叫 finally 中的 processWorkerExit 方法,將執行緒退出。

第十二問:執行緒池中的執行緒是如何獲取任務的?

執行緒獲取任務的操作,在上一問中已經可以窺見了,就是這個getTask()方法

private Runnable getTask() {
    // 標誌,表示最後一個poll()操作是否超時
    boolean timedOut = false;

    // 無限迴圈,直到獲取到任務或決定工作執行緒應該退出
    for (;;) {
    	// 獲取執行緒池的當前狀態(ctl是一個AtomicInteger,其中包含了執行緒池狀態和工作執行緒數)
        int c = ctl.get();
        int rs = runStateOf(c);

        // 如果執行緒池狀態是SHUTDOWN或更高(如STOP)並且任務佇列為空,那麼工作執行緒應該減少並退出
        if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
            decrementWorkerCount();
            return null;
        }

        int wc = workerCountOf(c);

        // 檢查工作執行緒是否應當在沒有任務執行時,經過keepAliveTime之後被終止
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

        // 如果工作執行緒數超出最大執行緒數或者超出核心執行緒數且上一次poll()超時,並且佇列為空或工作執行緒數大於1,
        // 則嘗試減少工作執行緒數
        if ((wc > maximumPoolSize || (timed && timedOut))
            && (wc > 1 || workQueue.isEmpty())) {
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }

        try {
            // 根據timed標誌,決定是無限期等待任務,還是等待keepAliveTime時間
            Runnable r = timed ?
                workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :  // 指定時間內等待
                workQueue.take();  // 無限期等待
            if (r != null)  // 成功獲取到任務
                return r;
            // 如果poll()超時,則設定timedOut標誌
            timedOut = true;
        } catch (InterruptedException retry) {
            // 如果在等待任務時執行緒被中斷,重置timedOut標誌並重新嘗試獲取任務
            timedOut = false;
        }
    }
}

整個原始碼中的核心程式碼是workQueue的poll與take方法的選擇問題,當timed為true時,則採用poll()方法獲取佇列中的頭部任務,引數keepAliveTime也就是構造執行緒池時傳入的空閒時間,這個方法的意思就是從佇列中阻塞 keepAliveTime 時間來獲取任務,獲取不到就會返回 null,否則採用take()無線期等待獲取任務,知道獲取到。

而這裡的透過 boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;這句也給timed賦值,要麼將allowCoreThreadTimeOut 設定為true,或者工作執行緒數大於核心執行緒數時,可以設定超時時間的去獲取任務。

第十三問:執行緒池常用的阻塞佇列有哪些?

在Executors中不同的建立執行緒池方法中採用了不同的阻塞佇列,在此透過原始碼中的使用情況,彙總一下這些佇列以及特點:

// 1、無界佇列 LinkedBlockingQueue,容量Integer.MAX_VALUE
public static ExecutorService newFixedThreadPool(int nThreads) {

    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());

}

// 1、無界佇列 LinkedBlockingQueue
public static ExecutorService newSingleThreadExecutor() {

    return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));

}

// 2、同步佇列 SynchronousQueue,沒有容量,不儲存元素,目的是保證對於提交的任務,如果有空閒執行緒,則使用空閒執行緒來處理;否則新建一個執行緒來處理任務,因此執行緒最多可建立Integer.MAX_VALUE個。
public static ExecutorService newCachedThreadPool() {

    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());

}

// 3、DelayedWorkQueue(延遲阻塞佇列),新增元素滿了之後會自動擴容原來容量的 1/2,即永遠不會阻塞,最大擴容可達 Integer.MAX_VALUE。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

第十四問:執行緒池中的執行緒異常後,是銷燬還是複用呢?

對於這個問題我們要分兩種情況去分析,第一種是透過 execute() 提交任務時,在執行過程中丟擲異常,且沒有在任務內被捕獲,當前執行緒會因此終止,異常資訊會記錄在日誌或控制檯中,並且執行緒池會移除異常執行緒,重新建立一個執行緒補上去。

我們在第十一問中討論執行緒複用時,去分析過runWorker的原始碼,在原始碼的最後有個finally中呼叫了processWorkerExit方法,我們跟入進去後會發現,當任務執行丟擲異常後,當前工作執行緒和任務均被移除,並建立新的執行緒。

image

第二種透過submit()提交任務時,如果在任務執行中發生異常,這個異常不會直接列印出來。相反,異常會被封裝在由submit()返回的Future物件中。當呼叫Future.get()方法時,可以捕獲到一個ExecutionException。在這種情況下,執行緒不會因為異常而終止,它會繼續存在於執行緒池中,準備執行後續的任務。

我們透過submit()的底層原始碼發現,其實它的內部封裝的是execute方法,只不過它的任務被放在了RunnableFuture物件裡。

  public Future<?> submit(Runnable task) {
    if (task == null) throw new NullPointerException();
     RunnableFuture<Void> ftask = newTaskFor(task, null);
     execute(ftask);
     return ftask;
  }

根據上一種方法的解釋,我們知道execute方法會丟擲異常終止執行緒的,為什麼submit中不會呢,貓膩肯定在RunnableFuture裡,經過一頓跟蹤發現(圖片來源:京東技術),這個Future中實現的run方法,對異常進行了捕獲,所以並不會往上丟擲,也就不會移除異常執行緒以及新建執行緒了。

image

第十五問:如何對執行緒池進行監控?

為了更好的檢測執行緒池的執行情況,以及出現問題時的快速定位,ThreadPoolExecutor中提供了一些方法來獲取執行緒池的執行狀態:

  1. getCompletedTaskCount:獲取已經執行完成的任務數量;
  2. getLargestPoolSize:獲取執行緒池裡曾經建立過的最大的執行緒數量。這個主要是用來判斷執行緒是否滿過;
  3. getActiveCount:獲取正在執行任務的執行緒資料;
  4. getPoolSize:獲取當前執行緒池中執行緒數量的大小。

除此之外,還有不少的方法就不一一列舉了,看圖!
image

這裡補充一點,其實細心的小夥伴應該也已經發現了,在第十一問,裡runWorker原始碼中,在執行任務之前會回撥 beforeExecute 方法,執行任務之後會回撥 afterExecute 方法,而這些方法預設都是空實現,這為我們提供了重新實現的空間!

第十六問:如何合理的關閉一個執行緒池?

在JDK 1.8 中,執行緒池的停止一般使用 shutdown()、shutdownNow()這兩種方法。

方法一: shutdown()

public void shutdown() {
    final ReentrantLock mainLock = this.mainLock; // ThreadPoolExecutor的主鎖
    mainLock.lock(); // 加鎖以確保獨佔訪問

    try {
        checkShutdownAccess(); // 檢查是否有關閉的許可權
        advanceRunState(SHUTDOWN); // 將執行器的狀態更新為SHUTDOWN
        interruptIdleWorkers(); // 中斷所有閒置的工作執行緒
        onShutdown(); // ScheduledThreadPoolExecutor中的掛鉤方法,可供子類重寫以進行額外操作
    } finally {
        mainLock.unlock(); // 無論try塊如何退出都要釋放鎖
    }
    tryTerminate(); // 如果條件允許,嘗試終止執行器
}

在shutdown的原始碼中,會啟動一次順序關閉,在這次關閉中,執行器不再接受新任務,但會繼續處理佇列中的已存在任務,當所有任務都完成後,執行緒池中的執行緒會逐漸退出。

方法二: shutdown()

/**
 * 嘗試停止所有正在執行的任務,停止處理等待的任務,
 * 並返回等待處理的任務列表。
 *
 * @return 從未開始執行的任務列表
 */
public List<Runnable> shutdownNow() {
    List<Runnable> tasks; // 用於儲存未執行的任務的列表
    final ReentrantLock mainLock = this.mainLock; // ThreadPoolExecutor的主鎖
    mainLock.lock(); // 加鎖以確保獨佔訪問
    try {
        checkShutdownAccess(); // 檢查是否有關閉的許可權
        advanceRunState(STOP); // 將執行器的狀態更新為STOP
        interruptWorkers(); // 中斷所有工作執行緒
        tasks = drainQueue(); // 清空佇列並將結果放入任務列表中
    } finally {
        mainLock.unlock(); // 無論try塊如何退出都要釋放鎖
    }
    tryTerminate(); // 如果條件允許,嘗試終止執行器
    return tasks; // 返回佇列中未被執行的任務列表
}

與shutdown不同的是shutdownNow會嘗試終止所有的正在執行的任務,清空佇列,停止失敗會丟擲異常,並且返回未被執行的任務列表。

第十七問:說一說執行緒池的應用場景?

我們在真正的java開發過程中,會經常使用到多執行緒場景,特別是併發量較大的系統中,我們不可能透過頻繁的建立與切換執行緒來處理大量任務,因此,執行緒池無疑是一個很好的選擇,既可管理任務,又能高效利用執行緒,諸如多請求的WEB伺服器、平行計算、非同步處理等場景下,使用執行緒池可達到事半功倍的效果!

我們這裡以平行計算為例,寫一個小demo感受一下哈

public class Test {
    public static void main(String[] args) {
        //初始化執行緒池
        ExecutorService exec = buildThreadPoolExecutor();
        //定義一個計算任務
        Callable<String> task = new Callable<String>() {
            @Override
            public String call() {
                // 這裡模擬一些數值計算
                Integer res = 2+2;
                return "[thread-name:" + Thread.currentThread().getName() + ",計算結果:" + res + "]";
            }
        };
        //執行10次計算任務,並儲存結果
        List<Future<String>> results = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            results.add(exec.submit(task));
        }
        //遍歷輸出結果
        for (Future<String> result : results) {
            try {
                System.out.println(result.get());
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
        }
        //關閉執行緒池
        exec.shutdown();
    }

    /**
     * 構建執行緒池
     * @return
     */
    public static ExecutorService buildThreadPoolExecutor() {
        return new ThreadPoolExecutor(5, 10, 10, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(100), new ThreadFactoryBuilder().setNameFormat("javabuild-%s").build()
                , new ThreadPoolExecutor.CallerRunsPolicy());
    }
}

輸出:

[thread-name:javabuild-0,計算結果:4]
[thread-name:javabuild-1,計算結果:4]
[thread-name:javabuild-2,計算結果:4]
[thread-name:javabuild-3,計算結果:4]
[thread-name:javabuild-4,計算結果:4]
[thread-name:javabuild-0,計算結果:4]
[thread-name:javabuild-0,計算結果:4]
[thread-name:javabuild-0,計算結果:4]
[thread-name:javabuild-0,計算結果:4]
[thread-name:javabuild-0,計算結果:4]

透過輸出我們看到,我們重新命名了執行緒池,且不同的執行緒都執行完成了這個計算任務,並輸出正確的結果。

第十八問:請你設計一個根據任務優先順序執行的執行緒池?

為了考察面試者對於執行緒池的掌握程度,很多面試官可能會讓你設計一個執行緒池,比如:請你設計一個根據任務優先順序執行的執行緒池

拿到這樣的問題後,我們結合自己的所學冷靜分析,首先,我們的任務儲存在哪裡?在構造執行緒池會傳入一個阻塞佇列,作為我們任務存放的容器,在所有的佇列中有一個優先順序佇列:PriorityBlockingQueue ,它的底層是透過小頂堆形式實現,即值最小的元素優先出隊。

在選好任務佇列後,我們要在佇列中對任務進行排序,這個排序規則需要和麵試官進一步溝通,但排序的實現可以使用2種方式。

  1. 建立 PriorityBlockingQueue 時傳入一個 Comparator 物件來指定任務之間的排序規則(比如眾多非同步運算任務中,按照乘法、除法、減法、加法的順序優先執行任務);
  2. 或者對提交的任務實現Comparable 介面,並重寫 compareTo 方法來指定任務之間的優先順序比較規則。

總結

OK,以上就是基於執行緒池的知識點以及眾多大廠面試經驗進行的梳理彙總,總共以十八問的方式呈現給大家,裡面也許有很多不足,感謝小夥伴們指正哈,對了還有一部分注意事項,考慮到本文篇幅問題,準備在後面的博文中在繼續增補吧!

結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!
image

相關文章