Marco's Java【面試系列篇之 面試官:你瞭解線程池麼?講講它的原理唄!】

前言

或許大家在日常開發中直接使用線程池的場景不會特別多,但是很多開源框架的底層都會使用到它來管理線程,進而降低資源消耗,提高線程的可管理性。因爲線程的創建和銷燬都是非常耗費系統資源的,如果不妥善的管理線程(例如,我創建了一個線程,但是忘了釋放一些資源,那麼很可能會造成資源回收不了,同時也帶來一些不必要的系統性能損耗),很容易導致系統問題。其實打從一開始接觸線程池,我覺得很簡單… 因爲使用起來沒難度,無非就是聲明並創建一個線程池對象,加一些七七八八的參數,然後execute或者submit執行就over了。
但是,談及它的原理,以及底層的實現時,就傻眼了。



從本質上解釋爲什麼需要線程池

剛纔我們提到了使用線程池可以提高線程的可管理性降低資源消耗,這個回答其實很籠統,應付的了自己,可應付不了面試大佬啊!

因此,若是被談及線程池的問題時,我們可以先從這上面兩方面去分析。
首先我們要知道一點,創建線程使用是直接向系統申請資源的,Java線程的線程棧所佔用的內存是在Java堆外的,而不受Java程序控制,之受限於系統資源,默認一個的線程的線程棧大小爲1M(JDK1.5之後爲1M,我們可以通過設置-Xss屬性設置線程棧大小,但是要注意棧溢出問題),如果每個用戶的請求來了,我都去創建一個線程,那麼1000個用戶瞬間就可以佔用我1個G的內存,並且每創建一個都去申請系統資源。可想而知,在沒有統一管理線程的情況下,肆意去創建線程,很容易會造成堆棧溢出,進而導致系統崩潰。
並且1000個線程的創建就會執行1000次系統資源的請求,可想而知,這消耗得有多大!再說了,線程切換時會消耗CPU的工作時間,創建過多的線程後,當線程數達到一個閾值,CPU就會什麼都不用做了,所有的時間都消耗在線程切換上,而線程根本都沒有真正去執行,白忙活!

因此,爲了提高線程的可管理性,保證系統的穩定性,我們需要使用線程池來管理線程!


線程池的工作原理

第一關算是過了,面試官連點頭,小夥子分析的不錯!那我問你,線程池具體由哪幾個部分組成?它們的功能是什麼?爲什麼要這麼設計… 你給我講講它的工作原理吧…

ok… 給我一隻筆,給你講到天亮!誰讓你問這麼多。

線程池的組成

1. 線程池管理器(ThreadPool):用於創建並管理線程池,包括創建線程池,銷燬線程池,添加新任務。

2. 工作線程(PoolWorker):線程池中的線程,用於執行任務。執行任務的線程爲忙碌狀態,無正在執行的任務的線程爲空閒狀態。

3. 任務接口(Task):要使用線程池的線程執行某個任務,該任務就必須實現任務接口,它規定了一個任務的入口,狀態等。

4. 任務隊列(TaskQueue):當有新任務進入線程池,如果此時線程池中沒有空閒線程可以用來執行任務,則任務會被暫存在任務隊列中。

一個完整的線程池包含上面幾個組件,舉個很簡單的栗子,線程池管理器就是銀行行長(管事兒的),工作線程就是辦理銀行業務的小姐姐(真正執行任務的),任務接口就相當於一個頭銜(員工歸屬於哪個銀行下的哪個支行,閒雜人等不得入內),任務隊列就是在銀行辦理業務的我們排起的隊列。

線程池的是如何工作的

啥也不說了,先給咱們的面試官畫張圖,接着娓娓道來線程池的工作原理…
在這裏插入圖片描述
首先,一個請求過來了,此時線程池需要創建一個線程去執行任務,這裏執行任務的線程就是Worker(實現了Runable接口),在創建線程的這個過程中歷經了下面幾個步驟。
1)獲取當前線程池的狀態,確認當前的線程池是否shutdown
2)若線程池沒有被關閉,且當前的線程數量小於coreSize核心線程數,則創建一個新的線程
3)若超過coreSize,且當前的線程還處於運行狀態,且workQueue還沒滿的情況下,將任務添加到阻塞隊列中(本質上是一個BlockingQueue<Runnable>)
4)再次進行檢查,如果線程池停止工作且當前任務可以被移除,則移除任務並且拒絕新的任務,否則繼續添加任務,若等待隊列被佔滿,則繼續創建新的線程直到maxSize最大線程數
5)若當前工作線程數超過maxSize,則直接執行RejectExecutionHandler的拒絕策略
在ThreadPoolExecutor的execute方法源碼中也能體現出這一點。

public void execute(Runnable command) {
  // 判斷當前是否有任務
  if (command == null)
       throw new NullPointerException();
   int c = ctl.get();
   // 當小於核心線程數時,添加worker並執行任務
   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);
}
爲什麼線程池要這麼設計

這個問題其實很籠統,因爲要談到的點會比較多,其實如果真的有面試官問到爲什麼線程池要這麼設計的時候,我們可以從下面幾個點去分析

|- 爲什麼會有coreSize、workQueue和maxSize的概念?
|- 爲什麼coreSize滿了之後不繼續創建線程而要放入workQueue?
|- 爲什麼需要有保活機制?

先說第一個點,其實coreSize、workQueue和maxSize只是一種理念,在Java中我們可以看到很多這種栗子,就好比JVM中的堆棧大小會有初始堆棧大小和最大堆棧大小,當你所需要的資源沒那麼多的時候,小一點就夠用了,一旦你的資源量大了,我還有彈性空間,當然如果你對數據的預估已經很精準了,比如說你知道每秒的請求數量並且訪問量非常平穩,那麼你也可以一次性到位,將最小值和最大值設置爲一樣的(當然這種情況不常見)。

我們再談談第二個點,首先我們知道線程的創建是非常消耗資源的,需要內存態到用戶態的切換,這一點顯然已經達成共識,那麼試想,當coreSize滿了之後,我們還繼續創建線程直到maxSize,那麼這就沒有coreSize存在的意義了!直接設置個maxSize得了!顯然workQueue就是爲了解決這個問題,因爲我們知道,每秒的請求量其實是不均衡的,時多時少,那麼當請求多的時候(不考慮請求多到超過阻塞隊列大小),我將任務放入阻塞隊列,等不忙的時候,我再從隊列取出任務,執行任務,那麼此時我用有限的資源(coreSize)解決了多request的問題(相對)。是不是很棒!

其實最後一個問題也很好理解,不僅線程的創建消耗資源,存在着的線程更是佔用資源(內存方面),因此當任務沒有那麼多的時候,線程池中的線程就會空閒下來,當線程等待任務超過了keepAliveTime之後,沒有任務可收就會被remove掉,從而節省一部分開銷,此時的線程就好比公司裏的"閒人",公司沒有那麼多活,你也不做事,就被開除了…
在這裏插入圖片描述
當然,在線程池中,對於核心線程超時也可以設置爲回收,需要執行下邊這個方法,確保核心線程超時之後也被回收。

threadPoolExecutor.allowCoreThreadTimeOut(true);

手寫一個功能完整的線程池

正所謂好記性不如爛筆頭… 可能源碼看了一大堆,覺得自己已經懂了,那還不夠!凡事得自己做一遍、寫一遍,纔會記得清!這樣再有關於線程池方面的問題就完全不虛了!當然了,本次手寫的線程池只追求功能完整,核心邏輯一致,屬於精簡版啦!
在這裏插入圖片描述

線程池主要參數

通過上面的一頓分析,相信大家對下面的線程池參數應該也不陌生了(略有修改,換了個名字…)

// 線程池主鎖
private static final ReentrantLock mainLock = new ReentrantLock();
// 核心線程數
private volatile int coreTheadSize;
// 最大線程數
private volatile int maxSize;
// 線程最長存活時間(保活時間)
private long keepAliveTime;
// 時間單位
private TimeUnit unit;
// 等待隊列
private BlockingQueue<Runnable> workQueue;
// 存放線程池
private volatile Set<Worker> workers;
// 線程池中的總任務數
private AtomicInteger totalTask = new AtomicInteger(0);
// 線程池是否關閉
private AtomicBoolean isShutDown = new AtomicBoolean(false);
// 拒絕線程通知器
private volatile RejectHandler handler;
// 線程池被shutdown功能中,喚醒線程的鎖
private Object shutDownNotify = new Object();

線程池的構造函數如下,注意這裏的核心線程數、最大線程數以及保活時間不能小於0且阻塞隊列不能爲空哦。另外存儲工作線程的線程池workers使用的是ConcurrentHashSet,因爲在 j.u.c 源碼中workers是一個 HashSet ,並且對他所有的操作都是需要加鎖(使用ReetrantLock)。因此需要一個線程安全的Set。
在這裏插入圖片描述
當然ConcurrentHashSet在jdk中是沒有的,通過自定義類集成AbstractSet,其內部本質上就是使用ConcurrentHashMap進行存儲啦!因爲ConcurrentHashMap 的 size() 函數並不準確,所以這裏單獨利用了一個 AtomicInteger來統計容器大小,從而保證線程安全。源碼如下。
在這裏插入圖片描述

線程池的資源分配

execute(Runnable runnable)作爲線程池的核心方法,主要爲是爲了合理安排線程池中的線程執行請求,進行線程的分配、調度(下圖的邏輯),注意這裏的totalTask(線程池中的總任務數)爲AtomicInteger,保證線程安全。addWorker()方法本質上就是創建一個Worker對象去執行請求。workQueue.offer(runnable)用於判斷當前的workQueue是否可再添加,如果不可加則返回false,可加則添加到workQueue(下面多處會涉及到BlockingQueue的操作,因此我總結了常見的操作,及其相應返回值類型)。

操作 拋出異常 特殊值 阻塞 超時
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove(e) poll(e) take(e) poll(time, unit)
檢查 element() peek() - -

在這裏插入圖片描述
在這裏插入圖片描述

線程池的拒絕策略

這裏的reject(runnable)本質上是調用了我們傳進來的DefaultRejectHandler的rejectedExecution()方法,該類實現了RejectHandler接口,並實現rejectedExecution(Runnable runnable, CustomThreadPool customThreadPool)方法。內部實現比較簡單,主要是判斷線程池中的阻塞隊列到底還能不能放任務進去,如果不能放了,就拋出異常,拒絕接收請求。customThreadPool.isShutDown()部分也是相當有必要的,因爲我們的一系列操作都是基於線程池正常工作的前提下進行的。
在這裏插入圖片描述

線程池的任務執行

如上面所講,addWorker() 就是創建了一個worker線程(可以添加ThreadFactory來創建worker),利用它的 startTask() 方法來執行任務,不要忘了將這個線程加入線程池複用。
在這裏插入圖片描述
說了半天,我們來看看 Worker 對象裏面有些啥(在ThreadPoolExecutor中Worker是他的一個內部類)。上面我們提到了Worker本身也是一個線程,將接收到需要執行的任務存放到成員變量 task 處。
在這裏插入圖片描述
而其中最爲關鍵的則是執行任務 worker.startTask() 這一步驟。它調用了Worker重寫Thread的run()方法,這裏的邏輯相對較複雜,首先我們要知道將要執行的任務是不是新的任務,如果是則直接執行新的任務,若沒有新的任務了,就去阻塞隊列裏面取任務,這裏有個注意點,就是我們方法中的任務task對象其實就是實際執行任務對象,只不過做了一個引用而已,因此任務執行完成之後,最好是手動的給設置爲null,讓GC清理掉這個無用的對象。任務執行完成之後記得剔除執行完成的線程,並獲取剩餘的任務量。這裏的shutDownNotify.notify()我們後面講到線程關閉的部分會詳細講解。

@Override
public void run() {
    Runnable task = null;
    // 如果是新的任務,那麼將當前傳遞過來的task對象的引用給到局部變量task
    if(isNewTask) {
        task = this.task;
    }
    // 是否編譯
    boolean success = true;
    try {
        // 如果沒有新的任務,則直接調用getTask()從workQueue中獲取任務
        while (null != task || null != (task = getTask())) {
            try {
                // 執行當前任務
                task.run();
            } catch (Exception e) {
            	// 如果任務執行發生異常,設置標誌位爲false
                success = false;
                logger.error("the task was executed with wrong, error stack is {}", e.getStackTrace());
            } finally {
                // 任務結束記得將局部變量task置爲null,等待GC回收
                task = null;
                // 剔除執行完成的線程,並獲取剩餘的任務量
                int restTaskNum = totalTask.decrementAndGet();
                if(restTaskNum == 0) {
                    // 如果當前任務列表中的任務爲0,則通知線程池shutdown
                    synchronized (shutDownNotify) {
                        shutDownNotify.notify();
                    }
                }
            }
        }
    } finally {
        // 當線程行完任務之後釋放線程資源
        boolean remove = workers.remove(this);
        logger.info("current thread is removed, the rest number of workers is {}", workers.size());
        if(!success) {
        	// 可以根據需求拓展一些操作
        }
        tryClose(true);
    }
}

注意,在線程執行完成之後,如果沒有新的任務,且在線程可以被remove的情況下,可以清理沒有在工作的線程。

線程池的任務獲取

我們再回到線程池的任務獲取getTask()方法,它用於從workQueue中獲取任務,整個過程需要加鎖,保證線程安全。當worker工作線程數量大於核心線程數量時需要用保活時間獲取任務,如果當前的隊列中沒有任務,最多等keepAliveTime(單位爲秒或者毫秒),過了規定時間沒有收到任務則返回null,如果workers.size() < coreTheadSize,則一直阻塞並進入等待狀態,直到Blocking有新的對象被加入爲止。

private Runnable getTask() {
	ReentrantLock mainLock = this.mainLock;
	// 關閉標識以及任務是否全部完成了
	if(isShutDown.get() && totalTask.get() == 0) {
		return null;
	}
	// 獲取任務時可能涉及到併發獲取,因此需要對此操作上鎖
	mainLock.lock();
	try {
		Runnable task = null;
		if(workers.size() > coreTheadSize) {
			// 當大於核心線程數量時需要用保活時間獲取任務
			// (取走BlockingQueue裏排在首位的對象,若不能立即取出,則可以等time參數規定的時間,取不到時返回null)
			task = workQueue.poll(keepAliveTime, unit);
		} else {
			// 取走BlockingQueue裏排在首位的對象,若BlockingQueue爲空,阻斷進入等待狀態直到Blocking有新的對象被加入爲止
			task = workQueue.take();
		}
		if(null != task) {
			return task;
		}
	} catch (InterruptedException e) {
		logger.error("current task is null");
		return null;
	} finally {
		mainLock.unlock();
	}
	return null;
}
線程池的關閉

線程池的關閉分兩種情形:正常關閉強制關閉

  • 正常關閉:不接受新的任務,並等待現有任務執行完畢後退出線程池。
  • 強制關閉:執行關閉方法後不管現在線程池的運行狀況,直接結束所有任務,這樣可能會導致任務丟失

在這裏插入圖片描述
我們可以對比這兩種關閉方式的區別,強制關閉很簡單,無非就是設置線程池的ShutDown標誌爲true,並且調用tryClose(false),當isTry爲false時,無論是否有線程在工作,都直接關閉。
在這裏插入圖片描述
這裏有個細節,也就是worker.closeTask()方法,實質上是調用了線程的thread.interrupt()方法,該方法執行之後並不會立即中斷線程,而是將線程的中斷標誌位設置爲true,此時我們可以通過isInterrupted()方法判斷線程是否終止,做一些邏輯上的處理,當然,如果線程中有阻塞方法,例如sleep()join()等,那麼一旦調用thread.interrupt()方法,則會拋出InterruptedException異常來中止線程。

還有一個細節就是剛纔我們提到的shutDownNotify這個"鎖"的用途,當我們正常ShutDown線程池時,會執行截圖一的方法,判斷是否還有任務在執行中,如果在執行中,則阻塞主線程不要關閉線程池。
在這裏插入圖片描述
截圖二是從run()方法中摳出來的部分,意思是,當任務執行完成並且剩餘的任務數爲0時,則通知主線程可以關閉線程池了。
總的來說,當執行了 shutdown 方法後會將線程池的狀態置爲關閉狀態,此時 worker 線程嘗試從隊列裏獲取任務時就會直接返回空,如果沒有任務,那麼 worker 線程就會被回收。一旦線程池大小超過了核心線程數就會使用保活時間來從隊列裏獲取任務,一旦獲取不到任務返回 null 時也會觸發回收。
在這裏插入圖片描述
接下來我們測試下線程池的效果
在這裏插入圖片描述
不難發現,當請求數量超過了最大可創建線程的數量時,會被拒絕策略器回絕掉,此時的線程數量爲maxSize,當沒有任務的時候,會回收線程,可以看到休眠之後線程數量從5變爲3。
在這裏插入圖片描述
當我把阻塞隊列的數量設置爲7的時候,coreSize仍然爲3,請求的數量爲10,可以看到休眠前後的線程數量都爲3,最終的線程數量也爲3,並沒有被回收。
在這裏插入圖片描述

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