面試官:你給我說一下執行緒池裡面的幾個鎖吧。

why技術發表於2021-11-01

你好呀,我是歪歪。

最近有個讀者給我說,面試聊到執行緒池的時候,相談甚歡,基本都回答上來了,但是其中有一個問題直接把他幹懵逼了。

面試官問他:你說一下執行緒池裡面的鎖吧。

結果他關於執行緒池的知識點其實都是在各個部落格或者面經裡面看到的,沒有自己去翻閱過原始碼,也就根本就沒有注意過執行緒池裡面還有鎖的存在。

他還給我抱怨:

他這麼一說,我也覺得,好像大家聊到執行緒池的時候,都沒有怎麼聊到裡面用到的鎖。

確實是存在感非常低。

要不我就安排一下?

mainLock

其實執行緒池裡面用到鎖的地方還是非常的多的。

比如我之前說過,執行緒池裡面有個叫做 workers 的變數,它存放的東西,可以理解為執行緒池裡面的執行緒。

而這個物件的資料結構是 HashSet。

HashSet 不是一個執行緒安全的集合類,這你知道吧?

所以,你去看它上面的註釋是怎麼說的:

當持有 mainLock 這個玩意的時候,才能被訪問。

就算我不介紹,你看名字也能感覺的到:如果沒有猜測的話,那麼 mainLock 應該是一把鎖。

到底是不是呢,如果是的話,它又是個什麼樣子的鎖呢?

在原始碼中 mainLock 這個變數,就在 workers 的正上方:

原來它的真身就是一個 ReentrantLock。

用一個 ReentrantLock 來保護一個 HashSet,完全沒毛病。

那麼 ReentrantLock 和 workers 到底是怎麼打配合的呢?

我們還是拿最關鍵的 addWorker 方法來說:

用到鎖了,那麼必然是有什麼東西需要被被獨佔起來的。

你再看看,你加鎖獨佔了某個共享資源,你是想幹什麼?

絕大部分情況下,肯定是想要改變它,往裡面塞東西,對不對?

所以你就按照這個思路分析,addWorker 中被鎖包裹起來的這段程式碼,它到底在獨佔什麼東西?

其實都不用分析了,這裡面的共享資料一共就兩個。兩個都需要進行寫入操作,這兩共享資料,一個是workers 物件,一個是 largestPoolSize 變數。

workers 我們前面說了,它的資料結構是執行緒不安全的 HashSet。

largestPoolSize 是個啥玩意,它為什麼要被鎖起來?

這個欄位是用來記錄執行緒池中,曾經出現過的最大執行緒數。

包括讀取這個值的時候也是加了 mianLock 鎖的:

其實我個人覺得這個地方用 volatile 修飾一下 largestPoolSize 變數,就可以省去 mainLock 的上鎖操作。

同樣也是執行緒安全的。

不知道你是不是也是這樣覺得的?

如果你也是這樣想的話,不好意思,你想錯了。

線上程池裡面其他的很多欄位都用到了 volatile:

為什麼 largestPoolSize 不用呢?

你再看一下前面 getLargestPoolSize 方法獲取值的地方。

如果修改為 volatile,不上鎖,就少了一個 mainLock.lock() 的操作。

去掉這個操作,就有可能少了一個阻塞等待的操作。

假設 addWorkers 方法還沒來得及修改 largestPoolSize 的值,就有執行緒呼叫了 getLargestPoolSize 方法。

由於沒阻塞,直接獲取到的值,只是那一瞬間的 largestPoolSize,不是一定是 addWorker 方法執行完成後的

加上阻塞,程式是能感知到 largestPoolSize 有可能正在發生變化,所以獲取到的一定是 addWorker 方法執行完成後的 largestPoolSize。

所以我理解加鎖,是為了最大程度上保證這個引數的準確性。

除了前面說的幾個地方外,還是有很多 mainLock 使用的地方:

我就不一一介紹了,你得自己去翻一翻,這玩意介紹起來也沒啥意思,都是一眼就能瞟明白的程式碼。

說個有意思的。

你有沒有想過這裡 Doug Lea 老爺子為什麼用了執行緒不安全的 HashSet,配合 ReentrantLock 來實現執行緒安全呢?

為什麼不直接搞一個執行緒安全的 Set 集合,比如用這個玩意 Collections.synchronizedSet?

答案其實在前面已經出現過了,只是我沒有特意說,大家沒有注意到。

就在 mainLock 的註釋上寫著:

我撿關鍵的地方給你說一下。

首先看這句:

While we could use a concurrent set of some sort, it turns out to be generally preferable to use a lock.

這句話是個倒裝句,應該沒啥生詞,大家都認識。

其中有個 it turns out to be,可以介紹一下,這是個短語,經常出現在美劇裡面的對白。

翻譯過來就是四個字“事實證明”。

所以,上面這整句話就是這樣的:雖然我們可以使用某種併發安全的 set 集合,但是事實證明,一般來說,使用鎖還是比較好的。

接下來老爺子就要解釋為什麼用鎖比較好了。

我翻譯上這句話的意思就是我沒有亂說,都是有根據的,因為這是老爺子親自解釋的為什麼他不用執行緒安全的 Set 集合。

第一個原因是這樣說的:

Among the reasons is that this serializes interruptIdleWorkers, which avoids unnecessary interrupt storms, especially during shutdown. Otherwise exiting threads would concurrently interrupt those that have not yet interrupted.

英文是的,我翻譯成中文,加上自己的理解是這樣的。

首先第一句裡面有個 “serializes interruptIdleWorkers”,這兩個單片語合在一起還是有一定的迷惑性的。

serializes 在這裡,並不是指我們 Java 中的序列化操作,而是需要翻譯為“序列化”。

interruptIdleWorkers,這玩意根本就不是一個單詞,這是執行緒池裡面的一個方法:

在這個方法裡面進來第一件事就是拿 mainLock 鎖,然後嘗試去做中斷執行緒的操作。

由於有 mainLock.lock 的存在,所以多個執行緒呼叫這個方法,就被 serializes 序列化了起來。

序列化起來的好處是什麼呢?

就是後面接著說的:避免了不必要的中斷風暴(interrupt storms),尤其是呼叫 shutdown 方法的時候,避免退出的執行緒再次中斷那些尚未中斷的執行緒。

為什麼這裡特意提到了 shutdown 方法呢?

因為 shutdown 方法呼叫了 interruptIdleWorkers:

所以上面啥意思呢?

這個地方就要用一個反證法了。

假設我們使用的是併發安全的 Set 集合,不用 mainLock。

這個時候有 5 個執行緒都來呼叫 shutdown 方法,由於沒有用 mainLock ,所以沒有阻塞,那麼每一個執行緒都會執行 interruptIdleWorkers。

所以,就會出現第一個執行緒發起了中斷,導致 worker ,即執行緒正在中斷中。第二個執行緒又來發起中斷了,於是再次對正在中斷中的中斷髮起中斷。

額,有點像是繞口令了。

所以我打算重複一遍:對正在中斷中的中斷,發起中斷。

因此,這裡用鎖是為了避免中斷風暴(interrupt storms)的風險。

併發的時候,只想要有一個執行緒能發起中斷的操作,所以鎖是必須要有的。有了鎖這個大前提後,反正 Set 集合也會被鎖起來,索性就不需要併發安全的 Set 了。

所以我理解,在這裡用 mainLock 來實現序列化,同時保證了 Set 集合不會出現併發訪問的情況。

只要保證這個這個 Set 操作的時候都是被鎖包裹起來的就行,因此,不需要併發安全的 Set 集合。

即註釋上寫的:Accessed only under mainLock.

記住了,有可能會被考哦。

然後,老爺子說的第二個原因:

It also simplifies some of the associated statistics bookkeeping of largestPoolSize etc.

這句話就是說的關於加鎖好維護 largestPoolSize 這個引數,不再贅述了。

哦,對了,這是有個 etc,表示“諸如此類”的意思。

這個 etc 指的就是這個 completedTaskCount 引數,道理是一樣的:

另一把鎖

除了前面說的 mainLock 外,執行緒池裡面其實還有一把經常被大家忽略的鎖。

那就是 Worker 物件。

可以看到 Worker 是繼承自 AQS 物件的,它的很多方法也是和鎖相關的。

同時它也實現了 Runnable 方法,所以說到底它就是一個被封裝起來的執行緒,用來執行提交到執行緒池裡面的任務,當沒有任務的時候就去佇列裡面 take 或者 poll 等著,命不好的就被回收了。

我們還是看一下它加鎖的地方,就在很關鍵的 runWorker 方法裡面:

java.util.concurrent.ThreadPoolExecutor#runWorker

那麼問題就來了:

這裡是執行緒池裡面的執行緒,正在執行提交的任務的邏輯的地方,為什麼需要加鎖呢?

這裡為什麼又自己搞了一個鎖,而不用已有的 ReentrantLock ,即 mainLock 呢?

答案還是寫在註釋裡面:

我知道你看著這麼大一段英文瞬間就沒有了興趣。

但是別慌,我帶你細嚼慢嚥。

第一句話就開門見山的說了:

Class Worker mainly maintains interrupt control state for threads running tasks.

worker 類存在的主要意義就是為了維護執行緒的中斷狀態。

維護的執行緒也不是一般的執行緒,是 running tasks 的執行緒,也就是正在執行的執行緒。

怎麼理解這個“維護執行緒的中斷狀態”呢?

你去看 Worker 類的 lock 和 tryLock 方法,都各自只有一個地方呼叫。

lock 方法我們前面說了,在 runWorker 方法裡面呼叫了。

在 tryLock 方法是在這裡呼叫的:

這個方法也是我們的老朋友了,前面剛剛才講過,是用來中斷執行緒的。

中斷的是什麼型別的執行緒呢?

就是正在等待任務的執行緒,即在這裡等著的執行緒:

java.util.concurrent.ThreadPoolExecutor#getTask

換句話說:正在執行任務的執行緒是不應該被中斷的。

那執行緒池怎麼知道那哪任務是正在執行中的,不應該被中斷呢?

我們看一下判斷條件:

關鍵的條件其實就是 w.tryLock() 方法。

所以看一下 tryLock 方法裡面的核心邏輯是怎麼樣的:

核心邏輯就是一個 CAS 操作,把某個狀態從 0 更新為 1,如果成功了,就是 tryLock 成功。

“0”、“1” 分別是什麼玩意呢?

註釋,答案還是在註釋裡面:

所以,tryLock 中的核心邏輯compareAndSetState(0, 1),就是一個上鎖的操作。

如果 tryLock 失敗了,會是什麼原因呢?

肯定是此時的狀態已經是 1 了。

那麼狀態什麼時候變成 1 呢?

一個時機就是執行 lock 方法的時候,它也會呼叫 tryAcquire 方法。

那 lock 是在什麼時候上鎖的呢?

runWorker 方法裡面,獲取到 task,準備執行的時候。

也就是說狀態為 1 的 worker 肯定就是正在執行任務的執行緒,不可以被中斷。

另外,狀態的初始值被設定為 -1。

我們可以寫個簡單的程式碼,驗證一下上面的三個狀態:

首先我們定義一個執行緒池,然後呼叫 prestartAllCoreThreads 方法把所有執行緒都預熱起來,讓它們處於等待接收任務的狀態。

你說這個時候,三個 worker 的狀態分別是什麼?

那必須得是 0 ,未上鎖的狀態。

當然了,你也有可能看到這樣的局面:

-1 是從哪裡來的呢?

別慌,我等下給你講,我們先看看 1 在哪呢?

按照之前的分析,我們只需要往執行緒池裡面提交一個任務即可:

這個時候,假如我們呼叫 shutdown 呢,會發什麼?

當然是中斷空閒的執行緒了。

那正在執行任務的這個執行緒怎麼辦呢?

因為是個 while 迴圈,等到任務執行完成後,會再次呼叫 getTask 方法:

getTask 方法裡面會先判斷執行緒池狀態,這個時候就能感知到執行緒池關閉了,返回 null,這個 worker 也就默默的退出了。

好了,前面說了這麼多,你只要記住一個大前提:自定義 worker 類的大前提是為了維護中斷狀態,因為正在執行任務的執行緒是不應該被中斷的。

接著往下看註釋:

We implement a simple non-reentrant mutual exclusion lock rather than use ReentrantLock because we do not want worker tasks to be able to reacquire the lock when they invoke pool control methods like setCorePoolSize.

這裡解釋了為什麼老爺子不用 ReentrantLock 而是選擇了自己搞一個 worker 類。

因為他想要的是一個不能重入的互斥鎖,而 ReentrantLock 是可以重入的。

從前面分析的這個方法也能看出來,是一個非重入的方法:

傳進來的引數根本沒有使用,程式碼裡面也沒有累加的邏輯。

如果你還沒反應過來是怎麼回事的話,我給你看一下 ReentrantLock 裡面的重入邏輯:

你看到了嗎,有一個累加的過程。

釋放鎖的時候,又有一個與之對應的遞減的過程,減到 0 就是當前執行緒釋放鎖成功:

而上面的累加、遞減的邏輯在 worker 類裡面通通是沒有的。

那麼問題又來了:如果是可以重入的,會發生什麼呢?

目的還是很前面一樣:不想打斷正在執行任務的執行緒。

同時註釋裡面提到了一個方法:setCorePoolSize。

你說巧不巧,這個方法我之前寫執行緒池動態調整的時候重點講過呀:

可惜當時主要講 delta>0 裡面的的邏輯去了。

現在我們看一下我框起來的地方。

workerCountOf(ctl.get()) > corePoolSize 為 true 說明什麼情況?

說明當前的 worker 的數量是多於我要重新設定的 corePoolSize,需要減少一點。

怎麼減少呢?

呼叫 interruptIdleWorkers 方法。

這個方法我們前面剛剛分析了,我再拿出來一起看一下:

裡面有個 tryLock,如果是可以重入的,會發生什麼情況?

是不是有可能把正在執行的 worker 給中斷了。

這合適嗎?

好了,註釋上的最後一句話:

Additionally, to suppress interrupts until the thread actually starts running tasks, we initialize lock state to a negative value, and clear it upon start (in runWorker).

這句話就是說為了線上程真正開始執行任務之前,抑制中斷。所以把 worker 的狀態初始化為負數(-1)。

大家要注意這個:and clear it upon start (in runWorker).

在啟動的時候清除 it,這個 it 就是值為負數的狀態。

老爺子很貼心,把方法都給你指明瞭:in runWorker.

所以你去看 runWorker,你就知道為什麼這裡上來先進行一個 unLock 操作,後面跟著一個 allow interrupts 的註釋:

因為在這個地方,worker 的狀態可能還是 -1 呢,所以先 unLock,把狀態刷到 0 去。

同時也就解釋了前面我沒有解釋的 -1 是哪裡來的:

想明白了嗎,-1 是哪裡來的?

肯定是在啟動過程中,執行了 workers.add 方法,但是還沒有來得及執行 runWorker 方法的 worker 物件,它們的狀態就是 -1。

最後說一句

好了,看到了這裡了,點贊安排一個吧。寫文章很累的,需要一點正反饋。

給各位讀者朋友們磕一個了:

相關文章