天天看點

【面試必備】我跟面試官聊了一個小時線程池!

大家好,這篇文章主要跟大家聊下 Java 線程池面試中可能會問到的一些問題。

全程幹貨,耐心看完,你能輕松應對各種線程池面試。

相信各位 Javaer 在面試中或多或少肯定被問到過線程池相關問題吧,線程池是一個相對比較複雜的體系,基于此可以問出各種各樣、五花八門的問題。

若你很熟悉線程池,如果可以,完全可以滔滔不絕跟面試官扯一個小時線程池,一般面試也就一個小時左右,那麼這樣留給面試官問其他問題的時間就很少了,或者其他問題可能問的也就不深入了,那你通過面試的幾率是不就更大點了呢。

【面試必備】我跟面試官聊了一個小時線程池!

下面我們開始列下線程池面試可能會被問到的問題以及該怎麼回答,以下隻是參考答案,你也可以加入自己的了解。

1. 面試官:日常工作中有用到線程池嗎?什麼是線程池?為什麼要使用線程池?

一般面試官考察你線程池相關知識前,大機率會先問這個問題,如果你說沒用過,不了解,ok,那就沒以下問題啥事了,估計你的面試結果肯定也兇多吉少了。

作為 JUC 包下的門面擔當,線程池是名副其實的 JUC 一哥,不了解線程池,那說明你對 JUC 包其他工具也了解的不咋樣吧,對 JUC 沒深入研究過,那就是沒掌握到 Java 的精髓,給面試官這樣一個印象,那結果可想而知了。

是以說,這一分一定要吃下,那我們應該怎麼回答好這問題呢?

可以這樣說:

計算機發展到現在,摩爾定律在現有工藝水準下已經遇到難易突破的實體瓶頸,通過多核 CPU 并行計算來提升伺服器的性能已經成為主流,随之出現了多線程技術。

線程作為作業系統寶貴的資源,對它的使用需要進行控制管理,線程池就是采用池化思想(類似連接配接池、常量池、對象池等)管理線程的工具。

JUC 給我們提供了 ThreadPoolExecutor 體系類來幫助我們更友善的管理線程、并行執行任務。

下圖是 Java 線程池繼承體系:

使用線程池可以帶來以下好處:

  1. 降低資源消耗。降低頻繁建立、銷毀線程帶來的額外開銷,複用已建立線程
  2. 降低使用複雜度。将任務的送出和執行進行解耦,我們隻需要建立一個線程池,然後往裡面送出任務就行,具體執行流程由線程池自己管理,降低使用複雜度
  3. 提高線程可管理性。能安全有效的管理線程資源,避免不加限制無限申請造成資源耗盡風險
  4. 提高響應速度。任務到達後,直接複用已建立好的線程執行

線程池的使用場景簡單來說可以有:

  1. 快速響應使用者請求,響應速度優先。比如一個使用者請求,需要通過 RPC 調用好幾個服務去擷取資料然後聚合傳回,此場景就可以用線程池并行調用,響應時間取決于響應最慢的那個 RPC 接口的耗時;又或者一個注冊請求,注冊完之後要發送短信、郵件通知,為了快速傳回給使用者,可以将該通知操作丢到線程池裡異步去執行,然後直接傳回用戶端成功,提高使用者體驗。
  2. 機關時間處理更多請求,吞吐量優先。比如接受 MQ 消息,然後去調用第三方接口查詢資料,此場景并不追求快速響應,主要利用有限的資源在機關時間内盡可能多的處理任務,可以利用隊列進行任務的緩沖
【面試必備】我跟面試官聊了一個小時線程池!

2. 面試官:ThreadPoolExecutor 都有哪些核心參數?

其實一般面試官問你這個問題并不是簡單聽你說那幾個參數,而是想要你描述下線程池執行流程。

青銅回答:

包含核心線程數(corePoolSize)、最大線程數(maximumPoolSize),空閑線程逾時時間(keepAliveTime)、時間機關(unit)、阻塞隊列(workQueue)、拒絕政策(handler)、線程工廠(ThreadFactory)這7個參數。

鑽石回答:

回答完包含這幾個參數之後,會再主動描述下線程池的執行流程,也就是 execute() 方法執行流程。

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);
}
           

可以總結出如下主要執行流程,當然看上述代碼會有一些異常分支判斷,可以自己順理加到下述執行主流程裡

  1. 判斷線程池的狀态,如果不是RUNNING狀态,直接執行拒絕政策
  2. 如果目前線程數 < 核心線程池,則建立一個線程來處理送出的任務
  3. 如果目前線程數 > 核心線程數且任務隊列沒滿,則将任務放入阻塞隊列等待執行
  4. 如果 核心線程池 < 目前線程池數 < 最大線程數,且任務隊列已滿,則建立新的線程執行送出的任務
  5. 如果目前線程數 > 最大線程數,且隊列已滿,則執行拒絕政策拒絕該任務

王者回答:

在回答完包含哪些參數及 execute 方法的執行流程後。然後可以說下這個執行流程是 JUC 标準線程池提供的執行流程,主要用在 CPU 密集型場景下。

像 Tomcat、Dubbo 這類架構,他們内部的線程池主要用來處理網絡 IO 任務的,是以他們都對 JUC 線程池的執行流程進行了調整來支援 IO 密集型場景使用。

他們提供了阻塞隊列 TaskQueue,該隊列繼承 LinkedBlockingQueue,重寫了 offer() 方法來實作執行流程的調整。

@Override
    public boolean offer(Runnable o) {
        //we can't do any checks
        if (parent==null) return super.offer(o);
        //we are maxed out on threads, simply queue the object
        if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o);
        //we have idle threads, just add it to the queue
        if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o);
        //if we have less threads than maximum force creation of a new thread
        if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false;
        //if we reached here, we need to add it to the queue
        return super.offer(o);
    }
           

可以看到他在入隊之前做了幾個判斷,這裡的 parent 就是所屬的線程池對象

1.如果 parent 為 null,直接調用父類 offer 方法入隊

2.如果目前線程數等于最大線程數,則直接調用父類 offer()方法入隊

3.如果目前未執行的任務數量小于等于目前線程數,仔細思考下,是不是說明有空閑的線程呢,那麼直接調用父類 offer() 入隊後就馬上有線程去執行它

4.如果目前線程數小于最大線程數量,則直接傳回 false,然後回到 JUC 線程池的執行流程回想下,是不是就去添加新線程去執行任務了呢

5.其他情況都直接入隊

具體可以看之前寫過的這篇文章

動态線程池(DynamicTp),動态調整Tomcat、Jetty、Undertow線程池參數篇

可以看出當目前線程數大于核心線程數時,JUC 原生線程池首先是把任務放到隊列裡等待執行,而不是先建立線程執行。

如果 Tomcat 接收的請求數量大于核心線程數,請求就會被放到隊列中,等待核心線程處理,這樣會降低請求的總體響應速度。

是以 Tomcat并沒有使用 JUC 原生線程池,利用 TaskQueue 的 offer() 方法巧妙的修改了 JUC 線程池的執行流程,改寫後 Tomcat 線程池執行流程如下:

  1. 判斷如果目前線程數小于核心線程池,則建立一個線程來處理送出的任務
  2. 如果目前目前線程池數大于核心線程池,小于最大線程數,則建立新的線程執行送出的任務
  3. 如果目前線程數等于最大線程數,則将任務放入任務隊列等待執行
  4. 如果隊列已滿,則執行拒絕政策

然後還可以再說下線程池的 Worker 線程模型,繼承 AQS 實作了鎖機制。線程啟動後執行 runWorker() 方法,runWorker() 方法中調用 getTask() 方法從阻塞隊列中擷取任務,擷取到任務後先執行 beforeExecute() 鈎子函數,再執行任務,然後再執行 afterExecute() 鈎子函數。若逾時擷取不到任務會調用 processWorkerExit() 方法執行 Worker 線程的清理工作。

詳細源碼解讀可以看之前寫的文章:

線程池源碼解析

【面試必備】我跟面試官聊了一個小時線程池!

3. 面試官:什麼是阻塞隊列?說說常用的阻塞隊列有哪些?

阻塞隊列 BlockingQueue 繼承 Queue,是我們熟悉的基本資料結構隊列的一種特殊類型。

當從阻塞隊列中擷取資料時,如果隊列為空,則等待直到隊列有元素存入。當向阻塞隊列中存入元素時,如果隊列已滿,則等待直到隊列中有元素被移除。提供 offer()、put()、take()、poll() 等常用方法。

JDK 提供的阻塞隊列的實作有以下幾種:

1)ArrayBlockingQueue:由數組實作的有界阻塞隊列,該隊列按照 FIFO 對元素進行排序。維護兩個整形變量,辨別隊列頭尾在數組中的位置,在生産者放入和消費者擷取資料共用一個鎖對象,意味着兩者無法真正的并行運作,性能較低。

2)LinkedBlockingQueue:由連結清單組成的有界阻塞隊列,如果不指定大小,預設使用 Integer.MAX_VALUE 作為隊列大小,該隊列按照 FIFO 對元素進行排序,對生産者和消費者分别維護了獨立的鎖來控制資料同步,意味着該隊列有着更高的并發性能。

3)SynchronousQueue:不存儲元素的阻塞隊列,無容量,可以設定公平或非公平模式,插入操作必須等待擷取操作移除元素,反之亦然。

4)PriorityBlockingQueue:支援優先級排序的無界阻塞隊列,預設情況下根據自然序排序,也可以指定 Comparator。

5)DelayQueue:支援延時擷取元素的無界阻塞隊列,建立元素時可以指定多久之後才能從隊列中擷取元素,常用于緩存系統或定時任務排程系統。

6)LinkedTransferQueue:一個由連結清單結構組成的無界阻塞隊列,與LinkedBlockingQueue相比多了transfer和tryTranfer方法,該方法在有消費者等待接收元素時會立即将元素傳遞給消費者。

7)LinkedBlockingDeque:一個由連結清單結構組成的雙端阻塞隊列,可以從隊列的兩端插入和删除元素。

【面試必備】我跟面試官聊了一個小時線程池!

4. 面試官:你剛說到了 Worker 繼承 AQS 實作了鎖機制,那 ThreadPoolExecutor 都用到了哪些鎖?為什麼要用鎖?

1)mainLock 鎖

ThreadPoolExecutor 内部維護了 ReentrantLock 類型鎖 mainLock,在通路 workers 成員變量以及進行相關資料統計記賬(比如通路 largestPoolSize、completedTaskCount)時需要擷取該重入鎖。

面試官:為什麼要有 mainLock?

private final ReentrantLock mainLock = new ReentrantLock();

    /**
     * Set containing all worker threads in pool. Accessed only when
     * holding mainLock.
     */
    private final HashSet<Worker> workers = new HashSet<Worker>();

    /**
     * Tracks largest attained pool size. Accessed only under
     * mainLock.
     */
    private int largestPoolSize;

    /**
     * Counter for completed tasks. Updated only on termination of
     * worker threads. Accessed only under mainLock.
     */
    private long completedTaskCount;
           

可以看到 workers 變量用的 HashSet 是線程不安全的,是不能用于多線程環境的。largestPoolSize、completedTaskCount 也是沒用 volatile 修飾,是以需要在鎖的保護下進行通路。

面試官:為什麼不直接用個線程安全容器呢?

其實 Doug 老爺子在 mainLock 變量的注釋上解釋了,意思就是說事實證明,相比于線程安全容器,此處更适合用 lock,主要原因之一就是串行化 interruptIdleWorkers() 方法,避免了不必要的中斷風暴

面試官:怎麼了解這個中斷風暴呢?

其實簡單了解就是如果不加鎖,interruptIdleWorkers() 方法在多線程通路下就會發生這種情況。一個線程調用interruptIdleWorkers() 方法對 Worker 進行中斷,此時該 Worker 出于中斷中狀态,此時又來一個線程去中斷正在中斷中的 Worker 線程,這就是所謂的中斷風暴。

面試官:那 largestPoolSize、completedTaskCount 變量加個 volatile 關鍵字修飾是不是就可以不用 mainLock 了?

這個其實 Doug 老爺子也考慮到了,其他一些内部變量能用 volatile 的都加了 volatile 修飾了,這兩個沒加主要就是為了保證這兩個參數的準确性,在擷取這兩個值時,能保證擷取到的一定是修改方法執行完成後的值。如果不加鎖,可能在修改方法還沒執行完成時,此時來擷取該值,擷取到的就是修改前的值。

2)Worker 線程鎖

剛也說了 Worker 線程繼承 AQS,實作了 Runnable 接口,内部持有一個 Thread 變量,一個 firstTask,及 completedTasks 三個成員變量。

基于 AQS 的 acquire()、tryAcquire() 實作了 lock()、tryLock() 方法,類上也有注釋,該鎖主要是用來維護運作中線程的中斷狀态。在 runWorker() 方法中以及剛說的 interruptIdleWorkers() 方法中用到了。

面試官:這個維護運作中線程的中斷狀态怎麼了解呢?

protected boolean tryAcquire(int unused) {
      if (compareAndSetState(0, 1)) {
          setExclusiveOwnerThread(Thread.currentThread());
          return true;
      }
      return false;
  }
  public void lock()        { acquire(1); }
  public boolean tryLock()  { return tryAcquire(1); }
           

在runWorker() 方法中擷取到任務開始執行前,需要先調用 w.lock() 方法,lock() 方法會調用 tryAcquire() 方法,tryAcquire() 實作了一把非重入鎖,通過 CAS 實作加鎖。

interruptIdleWorkers() 方法會中斷那些等待擷取任務的線程,會調用 w.tryLock() 方法來加鎖,如果一個線程已經在執行任務中,那麼 tryLock() 就擷取鎖失敗,就保證了不能中斷運作中的線程了。

是以 Worker 繼承 AQS 主要就是為了實作了一把非重入鎖,維護線程的中斷狀态,保證不能中斷運作中的線程。

【面試必備】我跟面試官聊了一個小時線程池!

5. 面試官:你在項目中是怎樣使用線程池的?Executors 了解嗎?

這裡面試官主要想知道你日常工作中使用線程池的姿勢,現在大多數公司都在遵循阿裡巴巴 Java 開發規範,該規範裡明确說明不允許使用

Executors 建立線程池,而是通過 ThreadPoolExecutor 顯示指定參數去建立

你可以這樣說,知道 Executors 工具類,很久之前有用過,也踩過坑,Executors 建立的線程池有發生 OOM 的風險。

Executors.newFixedThreadPool 和 Executors.SingleThreadPool 建立的線程池内部使用的是無界(Integer.MAX_VALUE)的 LinkedBlockingQueue 隊列,可能會堆積大量請求,導緻 OOM

Executors.newCachedThreadPool 和Executors.scheduledThreadPool 建立的線程池最大線程數是用的Integer.MAX_VALUE,可能會建立大量線程,導緻 OOM

自己在日常工作中也有封裝類似的工具類,但是都是記憶體安全的,參數需要自己指定适當的值,也有基于 LinkedBlockingQueue 實作了記憶體安全阻塞隊列 MemorySafeLinkedBlockingQueue,當系統記憶體達到設定的剩餘門檻值時,就不在往隊列裡添加任務了,避免發生 OOM

我們一般都是在 Spring 環境中使用線程池的,直接使用 JUC 原生 ThreadPoolExecutor 有個問題,Spring 容器關閉的時候可能任務隊列裡的任務還沒處理完,有丢失任務的風險。

我們知道 Spring 中的 Bean 是有生命周期的,如果 Bean 實作了 Spring 相應的生命周期接口(InitializingBean、DisposableBean接口),在 Bean 初始化、容器關閉的時候會調用相應的方法來做相應處理。

是以最好不要直接使用 ThreadPoolExecutor 在 Spring 環境中,可以使用 Spring 提供的 ThreadPoolTaskExecutor,或者 DynamicTp 架構提供的 DtpExecutor 線程池實作。

也會按業務類型進行線程池隔離,各任務執行互不影響,避免共享一個線程池,任務執行參差不齊,互相影響,高耗時任務會占滿線程池資源,導緻低耗時任務沒機會執行;同時如果任務之間存在父子關系,可能會導緻死鎖的發生,進而引發 OOM。

更多使用姿勢參考之前發的文章:

線程池,我是誰?我在哪兒?

【面試必備】我跟面試官聊了一個小時線程池!

6. 面試官:剛你說到了通過 ThreadPoolExecutor 來建立線程池,那核心參數設定多少合适呢?

這個問題該怎麼回答呢?

可能很多人都看到過《Java 并發程式設計事件》這本書裡介紹的一個線程數計算公式:

Ncpu = CPU 核數

Ucpu = 目标 CPU 使用率,0 <= Ucpu <= 1

W / C = 等待時間 / 計算時間的比例

要程式跑到 CPU 的目标使用率,需要的線程數為:

Nthreads = Ncpu * Ucpu * (1 + W / C)

這公式太偏理論化了,很難實際落地下來,首先很難擷取準确的等待時間和計算時間。再着一個服務中會運作着很多線程,比如 Tomcat 有自己的線程池、Dubbo 有自己的線程池、GC 也有自己的背景線程,我們引入的各種架構、中間件都有可能有自己的工作線程,這些線程都會占用 CPU 資源,是以通過此公式計算出來的誤差一定很大。

是以說怎麼确定線程池大小呢?

其實沒有固定答案,需要通過壓測不斷的動态調整線程池參數,觀察 CPU 使用率、系統負載、GC、記憶體、RT、吞吐量 等各種綜合名額資料,來找到一個相對比較合理的值。

是以不要再問設定多少線程合适了,這個問題沒有标準答案,需要結合業務場景,設定一系列資料名額,排除可能的幹擾因素,注意鍊路依賴(比如連接配接池限制、三方接口限流),然後通過不斷動态調整線程數,測試找到一個相對合适的值。

【面試必備】我跟面試官聊了一個小時線程池!

7. 面試官:你們線程池是咋監控的?

因為線程池的運作相對而言是個黑盒,它的運作我們感覺不到,該問題主要考察怎麼感覺線程池的運作情況。

可以這樣回答:

我們自己對線程池 ThreadPoolExecutor 做了一些增強,做了一個線程池管理架構。主要功能有監控告警、動态調參。主要利用了 ThreadPoolExecutor 類提供的一些 set、get方法以及一些鈎子函數。

動态調參是基于配置中心實作的,核心參數配置在配置中心,可以随時調整、實時生效,利用了線程池提供的 set 方法。

監控,主要就是利用線程池提供的一些 get 方法來擷取一些名額資料,然後采集資料上報到監控系統進行大盤展示。也提供了 Endpoint 實時檢視線程池名額資料。

同時定義了5中告警規則。

  1. 線程池活躍度告警。活躍度 = activeCount / maximumPoolSize,當活躍度達到配置的門檻值時,會進行事前告警。
  2. 隊列容量告警。容量使用率 = queueSize / queueCapacity,當隊列容量達到配置的門檻值時,會進行事前告警。
  3. 拒絕政策告警。當觸發拒絕政策時,會進行告警。
  4. 任務執行逾時告警。重寫 ThreadPoolExecutor 的 afterExecute() 和 beforeExecute(),根據目前時間和開始時間的內插補點算出任務執行時長,超過配置的門檻值會觸發告警。
  5. 任務排隊逾時告警。重寫 ThreadPoolExecutor 的 beforeExecute(),記錄送出任務時時間,根據目前時間和送出時間的內插補點算出任務排隊時長,超過配置的門檻值會觸發告警

通過監控+告警可以讓我們及時感覺到我們業務線程池的執行負載情況,第一時間做出調整,防止事故的發生。

【面試必備】我跟面試官聊了一個小時線程池!

8. 面試官:你在使用線程池的過程中遇到過哪些坑或者需要注意的地方?

這個問題其實也是在考察你對一些細節的掌握程度,就全甩鍋給年輕剛畢業沒經驗的自己就行。可以适當多說些,也證明自己對線程池有着豐富的使用經驗。

1)OOM 問題。剛開始使用線程都是通過 Executors 建立的,前面說了,這種方式建立的線程池會有發生 OOM 的風險。

2)任務執行異常丢失問題。可以通過下述4種方式解決

  1. 在任務代碼中增加 try、catch 異常處理
  2. 如果使用的 Future 方式,則可通過 Future 對象的 get 方法接收抛出的異常
  3. 為工作線程設定 setUncaughtExceptionHandler,在 uncaughtException 方法中處理異常
  4. 可以重寫 afterExecute(Runnable r, Throwable t) 方法,拿到異常 t

3)共享線程池問題。整個服務共享一個全局線程池,導緻任務互相影響,耗時長的任務占滿資源,短耗時任務得不到執行。同時父子線程間會導緻死鎖的發生,今兒導緻 OOM

4)跟 ThreadLocal 配合使用,導緻髒資料問題。我們知道 Tomcat 利用線程池來處理收到的請求,會複用線程,如果我們代碼中用到了 ThreadLocal,在請求處理完後沒有去 remove,那每個請求就有可能擷取到之前請求遺留的髒值。

5)ThreadLocal 線上程池場景下會失效,可以考慮用阿裡開源的 Ttl 來解決

以上提到的線程池動态調參、通知告警在開源動态線程池項目 DynamicTp 中已經實作了,可以直接引入到自己項目中使用。

關于 DynamicTp

DynamicTp 是一個基于配置中心實作的輕量級動态線程池管理工具,主要功能可以總結為動态調參、通知報警、運作監控、三方包線程池管理等幾大類。

經過多個版本疊代,目前最新版本 v1.0.8 具有以下特性

特性 ✅

  • 代碼零侵入:所有配置都放在配置中心,對業務代碼零侵入
  • 輕量簡單:基于 springboot 實作,引入 starter,接入隻需簡單4步就可完成,順利3分鐘搞定
  • 高可擴充:架構核心功能都提供 SPI 接口供使用者自定義個性化實作(配置中心、配置檔案解析、通知告警、監控資料采集、任務包裝等等)
  • 線上大規模應用:參考美團線程池實踐,美團内部已經有該理論成熟的應用經驗
  • 多平台通知報警:提供多種報警次元(配置變更通知、活性報警、容量門檻值報警、拒絕觸發報警、任務執行或等待逾時報警),已支援企業微信、釘釘、飛書報警,同時提供 SPI 接口可自定義擴充實作
  • 監控:定時采集線程池名額資料,支援通過 MicroMeter、JsonLog 日志輸出、Endpoint 三種方式,可通過 SPI 接口自定義擴充實作
  • 任務增強:提供任務包裝功能,實作TaskWrapper接口即可,如 MdcTaskWrapper、TtlTaskWrapper、SwTraceTaskWrapper,可以支援線程池上下文資訊傳遞
  • 相容性:JUC 普通線程池和 Spring 中的 ThreadPoolTaskExecutor 也可以被架構監控,@Bean 定義時加 @DynamicTp 注解即可
  • 可靠性:架構提供的線程池實作 Spring 生命周期方法,可以在 Spring 容器關閉前盡可能多的處理隊列中的任務
  • 多模式:參考Tomcat線程池提供了 IO 密集型場景使用的 EagerDtpExecutor 線程池
  • 支援多配置中心:基于主流配置中心實作線程池參數動态調整,實時生效,已支援 Nacos、Apollo、Zookeeper、Consul、Etcd,同時也提供 SPI 接口可自定義擴充實作
  • 中間件線程池管理:內建管理常用第三方元件的線程池,已內建Tomcat、Jetty、Undertow、Dubbo、RocketMq、Hystrix等元件的線程池管理(調參、監控報警)

項目位址

目前累計 1.7k star,感謝你的 star,歡迎 pr,業務之餘一起給開源貢獻一份力量

官網:https://dynamictp.cn

gitee位址:https://gitee.com/dromara/dynamic-tp

github位址:https://github.com/dromara/dynamic-tp