关于后端:面试官你给我说一下线程池里面的几把锁

42次阅读

共计 6231 个字符,预计需要花费 16 分钟才能阅读完成。

最近有个读者给我说,面试聊到线程池的时候,相谈甚欢,根本都答复上来了,然而其中有一个问题间接把他干懵逼了。
面试官问他:你说一下线程池外面的锁吧。
后果他对于线程池的知识点其实都是在各个博客或者面经外面看到的,没有本人去翻阅过源码,也就基本就没有留神过线程池外面还有锁的存在。
他还给我埋怨:

他这么一说,我也感觉,如同大家聊到线程池的时候,都没有怎么聊到外面用到的锁。
的确是存在感非常低。
要不我就安顿一下?

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。

最初说一句
好了,看到了这里了,点赞安顿一个吧。写文章很累的,须要一点正反馈。
给各位读者敌人们磕一个了:

正文完
 0