AQS 加锁自旋几次?排队的线程修改前一个线程?一般人真不知道。
其实之前在学习 Lock 的时候,学得比较粗糙,我也相信很多人都知道,像 ReentrantLock,ReadWriteLock 都是基于 AQS,CAS 实现的。
通过一个状态位(或者说标志位)来 CAS 抢锁,通过一个 AQS 链表队列,来实现线程的排队,LockSupport 来实现线程的阻塞与唤醒,通过模板方法设计模式,来对代码进行封装。
甚至,可以说基于这些思想,手写一个简化版 lock。
确实,如果能理解、学习到这些知识,已经足够去面试一些中小企业。只不过,相信很多人都有一颗积极向上的心,想要更好的就业平台,那么,这些知识还是比较 表层 的。
很多大厂的面试官只要一问,就知道,你是随便背了几篇博客,还是真正从 源码 角度去研究过。这些源码中的每一处细节,都是写作者的 思维的结晶,体现出 高度严谨、缜密 的编程思想。
学习忌浮躁
下面举几个 ReentrantLock 的几道题目,你来简单检测一下自己的学习情况。
- 非公平锁和公平锁的 底层实现 区别
- 线程交替执行(未出现争抢),执行效率如何
- 公平锁第 2 个线程加锁,是否会自旋(如果会,自旋几次)
- 队列 何时 初始化
- 队列 如何 初始化
- 持有锁的线程是否在队列中
- 队列中保存的 线程状态 如何记录
- 队列中首节点存放什么
- 队列中结点的 waitStatus 是怎样修改的
- lock() 方法为什么无法被打断
- 可中断的 lock 方法是如何做到的
- 超时的 lock 方法又做了什么优化
下面我会将程序的每一步执行的代码贴出来,先从大的方面,再到小的细节,每一点都写出详细的注释,抽象之处划出结构图像帮助理解,来一点一点分析其中的原由。
如果你还不了解大体层面,那可以阅读我之前的文章,先了解有关的基础知识,这样从源码学习才不至于过于费劲
本文的代码来自于 JDK 11(可能很多同学都是 1.8 的 JDK,代码会有差异,但过程、设计思想都是一样的)
学习完 ReentrantLock,那么可以去接着学习 ReentrantReadWriteLock,因为很多知识是相通的,所以我很建议,先从我的这一篇 ReentrantLock 打 AQS 基础,然后学习 ReentrantReadWriteLock 就会很轻松。
共享锁重入次数怎么记录都不知道,谁敢给你涨薪(AQS源码阅读之读写锁)
文章目录
- 第一个线程加锁(公平锁)
- 第二个线程加锁(公平锁)
- 第三个线程加锁(公平锁)
- 非公平锁加锁(和公平锁类似,只是可以插队)
- 线程释放锁
- 线程唤醒
- interrupt 打断加锁
- lock() 方法 interrupt 结果
- interrupt 原理
- lock() 无法被打断的原因
- interrupt 过了,lock() 方法后来被正常唤醒后的结果
- lock() 被 interrupt 小结
- lockInterruptibly 可打断的 lock 方法
- 失效结点的调整
- 情况过于复杂(画图分不同情况探讨)
- 先把 thread 置空
- 找未失效的前置结点
- 把自己 ws 改成 1(说明自己失效)
- 如果自己是最后一个,就 CAS 出队
- 后面有结点,前面不是头结点且没失效
- 前置为头,或者突然失效
- 被唤醒的有效线程自己去寻找前面的有效结点
- 下面整体看一下这些代码
- 带超时的 tryLock
- 作者的话
第一个线程加锁(公平锁)
调用 AQS 的方法加锁
首先是调用 sync.acquire() 方法,传入参数是 1
Sync 是 AQS(AbstractQueuedSynchronizer)的一个子类,然后派生出公平和非公平的 Sync 子类(FairSync、NonfairSync),它们都是 ReentrantLock 的内部类
(这里可以先不去喜细究,因为它没有重写 AQS 的 acquire 方法)
public void lock() {
// 调用AQS的acquire方法实现加锁
sync.acquire(1);
}
下面进入 AQS 的 acquire 方法
进入 if 判断,有两个条件,先调用 tryAcquire() 方法尝试加锁
不过这里是第一个线程加锁,所以肯定会获取成功,后面的代码不会执行
public final void acquire(int arg) {
// if判断 有两个条件
// 1、先尝试获取锁
// 不过这里是第一个线程加锁,所以肯定会获取成功、后面的代码不会执行
if (!tryAcquire(arg) &&
// 2、如果获取不到,则调用下面这行去 排队
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 这里先不用管下面这行代码,后文讲到打断时会解释
selfInterrupt();
}
尝试加锁(此时一定会成功)
所以这时会进入 tryAcquire() 方法中(代码是由 FairSync(公平锁) 重写的)
首先是获取当前线程和 state 状态值,如果状态为0,表示没有锁;大于0,表示加锁,锁重入的次数。
如果为0(未加锁)
这时是第一次加锁,所以会进入第一个方法:
- !hasQueuedPredecessors()
是否有线程正在队列中排队等待
(方法前加了 !,所以在队列没有线程等待的情况下返回 true,可以向后执行) - 用 CAS 进行加锁
加锁成功后设置持有锁的线程为当前线程,然后方法层层返回
一直到 lock.lock() 方法返回,则可以执行持有锁的代码块了
如果没有加锁成功,当前方法返回false
// 调用公平锁的tryAcquire()方法获取锁
protected final boolean tryAcquire(int acquires) {
// 获取当先线程和状态值
final Thread current = Thread.currentThread();
/*
* 在这里要说明一下状态值
* 在没有加锁时为0,加锁之后,会变成1,
* 如果当前线程继续加锁,会继续+1,表示重入次数
*/
int c = getState();
// 如果为0,表示没有锁
if (c == 0) {
// 两个判断,分别执行
/*
* 判断是否有线程在队列前面等待
* 此时第一次来加锁,所以肯定没有
* (再后文详细描述该方法)
* 这时的 !tryAcquire 就能返回true
* 就会进行下一步 CAS 加锁
*/
if (!hasQueuedPredecessors() &&
/*
* CAS将状态由0改为1
*
* 如果成功,则加锁成功,
* 则可以将持有锁的线程改为当前线程
* 方法层层返回,一直到lock.lock()然后向下执行
*
* 如果加锁失败,这一步返回false
*/
compareAndSetState(0, acquires)) {
// 设置持有锁的线程为当前线程
setExclusiveOwnerThread(current);
return true;
}
}
// else表示不为0,说明已经加锁
// (在后文,第二次加锁详细说明)
else if (current == getExclusiveOwnerThread()) {
// ... 省略无关代码
}
return false;
}
判断自己前面有没有线程排队 hasQueuedPredecessors
这里有个小细节
比如有这么一个问题,一个线程去加锁,它先看 state 是不是 0,如果是 0,你是不是首先会想到,要用 CAS 加锁啊。
但是,这里,又体现出 Doug Lee 大神的功底。对于公平锁,它不能看到 0 就直接上去加锁,
它要先看一下队伍里有没有人在排队,要是有就不行 ,不然就插队了(可见其缜密)
看源码发现更是如此:
首先有两个临时变量存放 头结点、第二个结点
为什么代码里不能直接写 head,不直接写 head.next,而要放在另外 两个变量里??
你好好想一想,好看??还是闲着没事??
锁的作用,肯定是保证多线程的安全。
第一行有一个 head,但是下一行运行的时候,head 还是之前的那个 head 吗??
会不会运行到后面几行,head 已经被改成其他节点了?
所以用局部变量装载它,保证线程安全 !!!!!
public final boolean hasQueuedPredecessors() {
// 首先要有两个临时变量来存一下 头结点、第二个结点
// 为什么代码里不能直接写 head,不直接写 head.next,
// 要把它放在另一个变量里???
Node h, s;
// 如果头不空
if ((h = head) != null) {
// 第二个也不空
if ((s = h.next) == null || s.waitStatus > 0) {
// 把s置空,用来装排在最后面的等待节点
s = null;
for (Node p = tail; p != h && p != null; p = p.prev) {
if (p.waitStatus <= 0) // waitStatus(ws)在后文介绍
s = p;
}
}
// 如果有线程在等,且不是自己,则返回true
if (s != null && s.thread != Thread.currentThread())
return true;
}
return false;
}
CAS
这里用到了 CAS,大家肯定很熟悉。
首先在目前比较流行的 1.8 版本有 Unsafe 工具类反射获取可以执行 CAS 操作
以及封装过的 Automatic 原子类可以执行 CAS
不过笔者这里用了 JDK11 版本了,在 9 版本就出现了一个类,叫:
VarHandle
这个类可以进行 CAS 操作,在这里的 ReentrantLock 中的 CAS 也是用的 VarHandle
无竞争加锁小结
到这里第一次加锁就完成了,大家可能觉得到目前为止这些代码都很简单的样子
确实如此,在没有线程竞争锁的情况下,ReentrantLock 的加锁过程就是:
- 判断是否有线程在队列中排队
- CAS 加锁
整个执行过程就是用了一个 CAS 改了一个状态值来完成加锁,所以它的 执行效率 很高。
所以 ReentrantLock 的第一个优点就是,在没有线程竞争的情况下,加锁效率很高
这也就解答了我在开头列举的一个题目:
线程交替执行(未出现争抢),执行效率如何
第二个线程加锁(公平锁)
同样尝试加锁(第一个线程获取锁了,所以这里肯定失败)
首先进入到尝试加锁方法,获取 state 值
但这是不会是 0 了,是 1,表示有人加锁了
所以会进入 else 方法块
判断当前线程是否为持有锁的线程,如果是,state 则加 1
可以看出,这个方法是用来重入的
所以这里不会进入锁重入方法,会到最后一行,返回 false,表示尝试加锁失败
protected final boolean tryAcquire(int acquires) {
// 和第一次一样
final Thread current = Thread.currentThread();
// 获取 state
int c = getState();
if (c == 0) {
// ... 省略无关代码
}
// 这一次会进入 else 方法块
// 然后判断当前线程是否为持有锁的线程
//(也就是是否是来重入的,这里是第二个线程来加锁,显然不是来重入的)
else if (current == getExclusiveOwnerThread()) {
// ... 省略无关代码
}
// 不是来重入的,自然不会运行里面的代码,所以返回false
return false;
}
尝试加锁失败后,开始排队
这时调用 acquireQueued 方法,判断是否需要
不过里面先调用了 addWaiter 方法,将当前线程加入 AQS 等待队列
// AQS 加锁方法
public final void acquire(int arg) {
// 尝试加锁
if (!tryAcquire(arg) &&
// 这里第二个线程来,尝试加锁失败
// 所以来到第二个方法,判断是否需要排队
// addWaiter方法是将线程加入等待队列
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
AQS 队列
必须简单介绍一下 AQS 的队列,后文马上就会出现
因为 AQS 实现了一个链表队列,所以自然要有链表的结点
内容比较多,我这里只列举重点 prev、next、thread 这些都不用解释
waitStatus 等待状态,简称 ws
class Node {
Thread thread;
Node prev;
Node next;
// 等待状态(简称 ws)
int waitStatus;
}
AQS 队列有两个变量 head、tail(首尾结点),一开始没有初始化时都为空
addWaiter 入队方法(重点细节)
首先创建一个节点
然后进入死循环,我们先看第一次循环
获取队尾,判断是否为空
此时因为还没有人来过队列,我们从上面一点点分析到现在,还没看到过队列有关代码
所以队尾一定为空,
那么进入 else 方法块,、、、初始化队列 !!!!!
private Node addWaiter(Node mode) {
// 先创建一个节点
Node node = new Node(mode);
// 死循环(重点)
// 第一次循环
for (;;) {
// 获取队尾
Node oldTail = tail;
// 如果不为空,不过这时还肯定为空
// 因为还没有人来过队列
// 所以到 else
if (oldTail != null) {
// ... 省略无关代码
} else {
// 初始化队列(重点)
initializeSyncQueue();
}
}
}
所以这里就可以回答前面提到的问题:
队列 何时 初始化
我们进入初始化的方法
拿到队首,然后用新建的一个节点用 CAS 放置到队首,成功后将队尾也指向这个节点
所以初始化方法就是 new 出一个新节点,然后首尾都指向它
// 初始化队列方法
private final void initializeSyncQueue() {
// 拿到队头
Node h;
// CAS 创建出一个队头,然后队尾也指向它
if (HEAD.compareAndSet(this, null, (h = new Node())))
tail = h;
}
接下来,初始化完成,我们进入刚才 addWaiter 入队方法的第二次循环
这时队尾就不为空了,所以队列就不会再初始化。(所以以后的线程来加锁也不再会去初始化队列)
这时死循环里面做的,就是用 CAS 将队尾替换为 新的节点
替换成功就返回这个新节点
private Node addWaiter(Node mode) {
Node node = new Node(mode);
// 第二次循环
for (;;) {
// 这是获取队尾,肯定能获取到了,不可能再为空
Node oldTail = tail;
if (oldTail != null) {
// 设置node的前一个结点为当前的队尾
node.setPrevRelaxed(oldTail);
// CAS设置将 之前的队尾结点设置为 新的结点
//(意思就是将新节点线程安全地插到队尾)
// 如果失败,继续循环,反复尝试,直到将它插到队尾为止
if (compareAndSetTail(oldTail, node)) {
// 将结点连接
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue();
}
}
}
重点笔记
这里我必须重点提一下
我们通过这个入队方法可以发现:
- 在第一次调用入队方法的时候还没有队列,所以会初始化
- 初始化的时候就是 new 了一个新节点,这个新节点里面啥都没,Thread = null
- 这里我还要提一点,就是 AQS 队列的队首永远是 null。
- 从此以后再也不会调用队列的初始化方法
acquireQueued 在队列中阻塞方法
这里传了两个参数,一个刚刚入队的含有当前线程的结点,
还有一个 加锁时的参数 1。
你好不好奇,这里为什么要把加锁时的 1 传过来,我们这里就排个队,要这个 1 干嘛?????
这里很关键
我们看方法
- 首先设置一个 false 的 interrupt 的值
- 死循环 !!!
- 获取前一个结点
(我们知道传过来的结点就是我们这个刚刚加入队列排队的线程结点,而队列刚刚才初始化,初始化的时候有一个空的结点,所以我们的这个节点就是在这个空节点后面排队,所以前一个一定是这个空节点,也就是队头) - 这时是队头,所以 再次 尝试加锁 tryAcquire !!!
(看到没,知道传一个加锁时的 1 有什么用了吧,因为这里还有一次加锁) - 加锁失败 然后进入判断要不要阻塞的方法
你仔细想一想,这里是不是很神奇
在之前是先 tryAcquire 方法尝试加锁失败过了,所以它才初始化队列入队
按照道理说,是不是它要进了队列之后睡觉啊,它是不是应该要 park 阻塞住啊
它不阻塞,它不睡觉,它又 tryAcquire 干嘛
所以说 AQS 的源码是有很多细节需要我们去思考分析的
你仔细想一想,这是不是就是我们的自旋锁、
自旋锁,自旋锁,平时说的很多,你可不要看到了代码却还认不出它
前面尝试加锁一次,然后不是直接阻塞,而是又尝试了一次,这不正是一个自旋了一次的过程 !!!
然后这时候因为线程 1 还拿着锁,所以肯定还是失败
所以不进这个 if,到下一个 if
看到 if 里面是一个 在加锁失败后应不应该睡 的方法
是不是很神奇,它还不睡,还在执行判断要不要睡的方法
// 这里传过来刚才添加在队尾的node结点
// 加锁时的参数 1 也传了过来
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
// 又是死循环
for (;;) {
// 获取node的前一个结点
final Node p = node.predecessor();
// 判断是不是头结点
// 如果是,再次尝试加锁 tryAcquire
// 我们这里是第二个线程来,才刚刚初始化队列,
// 才刚刚添加了一个节点进去,所以肯定是是队头结点
// 所以继续执行 && 后面的代码 尝试加锁!!
if (p == head && tryAcquire(arg)) { //这里加不到锁,所以不进这个if
// ... 省略无关代码
}
// 这个方法的名字翻译过来叫 在尝试加锁失败后应该park
// 就是一个 应不应该park在尝试加锁失败后 的方法
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);
if (interrupted)
selfInterrupt();
throw t;
}
}
我们下面进入这个判断要不要睡的方法
传入 前一个结点(prev)和当前结点 后
获取前一个结点(prev)的 ws 属性,这个时候肯定是 0
因为我一行一行代码写到这里,从来就没没出现过这么个 ws 属性,所以肯定是 node 结点 new 出来的时候的默认初始值 0
所以我们会进最后一个 else,将 ws 用 CAS 改成 -1
// 传入 前一个结点 和 当前结点
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前一个结点的 ws 属性(等待属性)
// 在这里首先我们可以肯定的是,这里肯定是 0
// 因为我写到这里,一行一行代码写过来,根本就没出现过这么个东西
// 所以只可能是 new 的时候的 默认值 0
int ws = pred.waitStatus;
// 如果这个 ws 是 信号-1
if (ws == Node.SIGNAL) // 这个是 -1
return true;
if (ws > 0) {
// ... 省略无关代码
} else {
// 所以这时一定进这个方法
// 把上一个结点的 ws 写成 -1
pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
}
return false;
}
接下来好玩的要来了
刚刚是在死循环里还记得吧(不然往上再翻一下),现在是第二次循环
获取前一个结点,看看是不是头,然后再尝试加锁
第三次加锁了,看到没 !!!!
所以现在是自旋了 3 次!!!!!!(以后别再老叫它重量级锁。。)
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
// 第二次循环来了
for (;;) {
// 仍然是获取前一个结点
final Node p = node.predecessor();
// 是队首,继续尝试加锁
// 第三次加锁了吧,记得不??????
// 当然还是加锁失败
if (p == head && tryAcquire(arg)) {
// ... 省略无关代码
}
// 又来这个方法 判断是不是要park
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
// ... 省略无关代码
}
}
由于上一次循环,把 ws 改成 -1 了,记得吧
这一次,就会进第一个 if
就会返回 true 了
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
// 这一次 ws = -1 了,就返回 true
if (ws == Node.SIGNAL)
return true;
// ... 省略后边代码
}
然后就开始 park(睡)。。。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 开始睡了
return Thread.interrupted();
}
看到这里大家好好想一想,这是个什么神仙操作??
死循环里面 tryAcquire 是多尝试加锁,来自旋,这个我们知道了
但是又有个 是不是应该睡 的方法
第一次先把 0 改成 -1,下一次循环进来看到是 -1 了,就开始睡
就是为了多一次循环吗?这样就多 tryAcquire 多自旋一次??
还有,为什么 t2 要改前一个结点的 ws 为 -1,为什么不是改自己的状态??
它要阻塞,不是应该把自己的状态改成阻塞状态??它去改别人的干什么??
留个疑问,我们后文继续
第二个线程加锁小结
- 在第二个线程加锁时,由于第一个线程已经加锁了,所以第一次尝试加锁一定失败
- 由于没有队列,它要入队的时候,会初始化一个队列
- 它加入队列后还有两次循环,每次循环都会尝试加锁一次,也就是额外自旋了 2 次
- 这两次循环,第一次会把前一个结点的 ws(等待状态)改为 -1,第二次才阻塞自己
第三个线程加锁(公平锁)
同样先尝试加锁 tryAcquire
和第二个线程一样,肯定是先尝试加锁失败
public final void acquire(int arg) {
// 先尝试加锁 失败
if (!tryAcquire(arg) &&
// 所以会进第二个条件的方法
// 又是入队列
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
addWaiter 入队
这次入队列就和上次有那么一小点不一样
第二个线程要入队列的时候,是没有队列的,所以第一次循环是初始化队列,第二次开始才是 CAS 入队
而现在第三个线程来入队,此时已经有队列存在了,所以直接就是 CAS 入队操作
// 和上一次的代码没什么区别
private Node addWaiter(Node mode) {
Node node = new Node(mode);
// 第一次循环就是 CAS 入队
// 死循环 直到CAS入队成功
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
node.setPrevRelaxed(oldTail);
// CAS将当前线程结点放入队尾
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
// 成功后返回当前结点
return node;
}
} else {
initializeSyncQueue();
}
}
}
acquireQueued
这里和第二个线程又不一样
我们回顾一下,第二个线程会两次循环,第一次尝试加锁,改前一个结点(头结点)为 -1;
第二次再尝试加锁,然后睡觉
这时,第三个线程也会先拿到头结点,但是!!!
前一个结点不是头结点了,它是排在线程 2 后面的,拿不到开头的结点了,所以就不会尝试加锁
想想,是不是很有道理
第二个线程,因为接下来轮到的就是它,它可以自旋,多尝试几次(如果成功了,就节省了阻塞带来的开销)
第三个线程,它排在线程二后面,它要是去尝试加锁,那就是插队,那就不公平了
// 又来到了这个方法
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
// 死循环
for (;;) {
// 获取前一个结点
final Node p = node.predecessor();
// 但是,它不是头部了
// 所以不会尝试加锁
// 所以直接到后面的 if
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return interrupted;
}
// 我们记得很清楚,
// 第一次循环修改上一个结点的 晚上 为 -1
// 第二次循环 ws 是 -1 了,所以就阻塞自己
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);
if (interrupted)
selfInterrupt();
throw t;
}
}
第 3 个线程加锁小结
- 首先由于队列已经存在,所以不用再初始化队列了,而是直接进入队列
- 在两次循环中,由于第一个不是头结点(也就是在前面还有其他线程在等),所以不会再尝试加锁
- 两次循环和之前相同,第一次将前一个结点 ws 改为 -1,第二次阻塞自己。
非公平锁加锁(和公平锁类似,只是可以插队)
进方法 tryAcquire
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
开始不一样了,进了非公平锁的 tryAcquire 方法
继续进去
// 内部类 继承Sync Sync继承AQS
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
protected final boolean tryAcquire(int acquires) {
// 进非公平加锁方法
return nonfairTryAcquire(acquires);
}
}
尝试加锁方法 nonfairTryAcquire(与公平锁的差异)
公平锁和非公平锁加锁时唯一不同点(tryAcquire 方法,调用了非公平的 nonfairTryAcquire 方法)
这个方法获取 当前线程、state 和公平锁一样
唯一不同的,就是在尝试加锁时:
如果 c==0,就直接 CAS 抢锁(大家应该还记得公平锁在尝试加锁时要先判断要不要排队,队伍没人才抢锁)
而非公平锁少了这么一步,只要来了发现锁没有人持有,不管队伍排得多长,有多少人排队,它都直接 CAS 抢锁
// 非公平加锁方法 传入加锁参数 1
final boolean nonfairTryAcquire(int acquires) {
// 前两行都一样,获取 当前线程 和 state值
final Thread current = Thread.currentThread();
int c = getState();
// 如果 c==0
// 发现不一样了
// 公平锁的时候,要先判断要不要排队,再 CAS
// 这里直接 CAS(可见其不公平了,可以插队)
if (c == 0) {
// 如果 CAS 成功
if (compareAndSetState(0, acquires)) {
// 设置加锁线程为自己
setExclusiveOwnerThread(current);
return true; // 层层返回 加锁成功
}
}
// 和公平锁一样,是自己就重入 +1
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires; // 重入次数 + 1
if (nextc < 0) // overflow // 小于 0 的异常
throw new Error("Maximum lock count exceeded");
setState(nextc); // 将重入次数写到 state
return true;
}
return false;
}
相同点
同样的,如果是第二个线程来获取非公平锁,后面入队之后也会 额外两次自旋
只不过自旋的两次尝试加锁的方法是非公平的
不过它由于本来就是排在第一个线程后面,马上轮到的就该是它,所以加锁方法公平不公平都是无所谓的
但是再之后有线程来,只会在最开始抢一次锁,之后的两次循环在队伍中同样不会去尝试加锁
这和公平锁也是相同的
所以,非公平锁的唯一区别就是在 刚来 lock() 的时候,那一次尝试加锁可以争抢(c==0时不管队伍有没有人)。
非公平效率
为什么平时都说非公平锁效率高,主要是因为:
如果在 t1 去唤醒 t2 的期间,t4 来了,然后很快拿到锁执行完代码然后释放了锁
t2 才醒过来,那么原本同样长的时间之内,多一个线程执行了任务,那么提高了效率
(线程的阻塞与唤醒是比较耗时的)
线程释放锁
首先进 sync 的 release 方法
public void unlock() {
sync.release(1);
}
释放锁的方法 release
进入到了 释放锁的方法,每次释放锁传入 1,用来把 state - 1,减去重入次数,直到 0 完成释放锁。
首先用 tryRelease 方法尝试释放锁(因为可能有重入,再重入情况下一次不会释放成功)
重入的话就会把 state-1,然后直接 返回 false,解锁失败
如果已经解锁到最后一层了,那么这一次就会解锁成功,就要去看我们的队列
!!!注意 !!!
它是先解锁,然后去看我们的队列 !!!
这会有什么问题???
假如,锁是非公平的,锁被释放了,接下来它才去队列里面叫人。
要是这个时候,如果有其他人来了,就能直接插队进去加锁(这个时候队列里的人还在呼呼大睡)
它加锁干完了事,解锁走了。
然后队列里的那个人才被叫醒,那它开始加锁开始工作,是不是完全都不知道有人插过队干完事又跑了
非公平锁的效率就是在这里体现出来的
释放锁之后,怎么知道要不要去队列里叫人???
好好想一想。
之前记不记得,后面的线程进队列要阻塞的时候,做了什么??
记不记得,把前面的结点,ws 改成了 -1
所以它怎么知道要不要去叫人??
看一下自己的 ws,如果不是 0 了,说明后面有人了 !!!!
// 传入 1,用来 减state
public final boolean release(int arg) {
// tryRelease 尝试释放锁
// 因为锁可能重入过,一次不一定能释放掉
// 只有释放了重入的次数,才能完全释放锁
if (tryRelease(arg)) {
// 释放锁之后,就开始对排队的队列操作
// 注意,锁是先释放,再去管队列里的人(所以非公平就可能被别人插队抢到)
Node h = head; // 找到头结点
// 只要不空,并且 ws 不是 0
// !!现在是不是发现 ws 的作用了
// 因为被后面的人改了,所以知道后面有人在排队,所以才要去叫醒后面的人
if (h != null && h.waitStatus != 0)
// 开始叫醒后面排队的人
unparkSuccessor(h);
return true;
}
return false;
}
尝试释放锁 tryRelease
总体流程看完了,我们再看释放锁的细节
首先获取 state-1 的值(我们现在不用看代码都能知道,如果为 0 就释放,不为 0 就不释放)
如果 c==0 了,就把当前持有锁的线程置空
最后把 -1 的值写回 state 里面
(感觉和前面的相比这里好简单)
protected final boolean tryRelease(int releases) {
// 获取 state-1 的值
int c = getState() - releases;
// 不是加锁的人来解锁。。肯定抛异常嘛,不解释
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// free设为false,目前锁还不自由
boolean free = false;
// 只有等到 c==0 了,说明锁才自由
if (c == 0) {
free = true;
// 把持有锁的线程置空
setExclusiveOwnerThread(null);
}
// 把 0 写进 state 里,锁就被完全释放了
setState(c);
return free;
}
唤醒后面的线程 unparkSuccessor(h)
看过之前的代码(我甚至觉得你完全可以不看代码,只看我的描述。。),释放锁了之后,如果队列里有人在排队等待(阻塞住了),就要去唤醒它
头结点传过来,获取 ws
这时候,由于之前有线程来排队阻塞了(后一个线程阻塞前把前一个线程 ws 改成 -1),所以 ws 为 -1;
这时候就会进第一个 if 方法,CAS 把 ws 从 -1 改成 0;
然后开始操作队列
获取排在自己后边那一个结点,只要不是空,就 unpark 叫醒它
// 头结点被传了过来
private void unparkSuccessor(Node node) {
// 如果 ws 小于 0,就CAS改回 0
// 前面分析过,后一个来排队的人在阻塞前,会把前一个结点 ws 改成 -1
int ws = node.waitStatus;
if (ws < 0)
// 所以这里会被改回来
node.compareAndSetWaitStatus(ws, 0);
Node s = node.next;
/**
* ...省略部分代码
* 这些其它不正常的情况我在后文统一讨论
* 我们先研究常规情况
*/
// 换醒线程
if (s != null)
LockSupport.unpark(s.thread);
}
解锁小结
解锁的代码很简单,只要你把之前加锁的过程认真看了,那么就能很容易理解。
- 解锁要注意的第一个点,就是重入。
如果重入过了,一次是解不开的。要解多次才能解锁成功 - 还有,就是,解锁成功之后,才会去管队列里的排队的线程(而不是,先确认排队的人叫醒了,再释放锁)
所以体现出的就是非公平锁的效率。
(叫人的过程中,可能有其他人来了又走了。这样,执行次数可能不知不觉就增加了) - 怎么知道有线程在队列中阻塞
后来的线程,阻塞之前,把前一个结点 ws 改成 -1。
它解锁的时候发现 ws 不是 0 了,变成 -1 了,说明有人在后面等
线程唤醒
正常唤醒
回顾刚才的加锁过程,之后来的线程都会在队列中阻塞。那么之后它被唤醒后,又会如何
private final boolean parkAndCheckInterrupt() {
// 上次运行到这 然后阻塞了
LockSupport.park(this);
// 这时被唤醒,可以接着执行了
return Thread.interrupted();
}
这时 parkAndCheckInterrupt 返回(现在是正常唤醒,不是 interrupt 打断终止),所以返回 false,继续来到下一次循环
这时候因为前一个线程执行完了,将下一个唤醒,所以这时判断前一个结点是不是头结点返回的结果一定是 true
所以可以去 tryAcquire 尝试加锁
所以一般情况就在这里加锁成功,然后 lock() 方法就能继续往后执行了。
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
// 这时候获取前一个结点 一定是头结点了
final Node p = node.predecessor();
// 是头结点然后尝试加锁,这时变会加锁成功
if (p == head && tryAcquire(arg)) {
// 把自己设为头结点
setHead(node);
p.next = null; // help GC
return interrupted; // false
}
if (shouldParkAfterFailedAcquire(p, node))
// 这时代码从这里运行出来了。。然后回到循环开头。。
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
// ...省略其它无关代码
}
}
小细节 setHead
不过这里有个小细节,我们进 setHead 方法
可以发现:
将自己设置为头结点之后,会把 thread 置空
也就是说,AQS 的队列的头结点,里面的 thread 永远是 null
也就是说,当前持有锁的线程,不会保存在队列里 !!!!
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
正常返回就不会调用 selfInterrupt 方法
这时候由于我们的 acquireQueued 方法返回的是 false
(要是不记得了往前翻一点点。。。)
所以不会调用最后一行方法 selfInterrupt
(放心,下面马上就要说到它了)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 返回了 false ,所以不会执行 下面的代码
selfInterrupt();
}
interrupt 打断加锁
lock() 方法 interrupt 结果
首先你知道在执行 lock.lock() 的过程中被 interrupt 打断会是什么结果吗
当调用 lock() 方法阻塞时,如果被别的线程 interrupt,那么会怎么样?
// 其他线程已经持有了锁
// lock() 阻塞
lock.lock();
// 这里的代码在当前线程被别人interrupt后是否会运行?
答案是 lock() 继续阻塞,不会有反应
interrupt 原理
要理解这些,不光得看源码,首先你得对 Java 并发编程的基础掌握。
我们先了解 interrupt 方法。
首先,这个方法一般我们的用途,是某个方法有一个显示抛出的被 interrupt 打断的异常(InterruptedException),
然后我们在使用这个方法的时候,用 try catch 捕获这个异常,然后做出相应的逻辑处理
比如:
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
// 打断后捕获异常,执行相应的程序代码
System.out.println("我被interrupt了");
e.printStackTrace();
}
这个是使用层面,在底层原理角度:
interrupt 仅仅只是在线程中添加了一个标记,表示自己被 interrupt 了
这个标记可以被 interrupted 方法捕获,然后返回 true,同时把标记去除。
(这里给你看源码是没有太大作用的,因为里面有 native 方法)
public static void main(String[] args) throws InterruptedException {
Thread.currentThread().interrupt();
System.out.println(Thread.interrupted());
System.out.println(Thread.interrupted());
}
发现:可以捕获到 打断标记,同时标记被清除
lock() 无法被打断的原因
之前线程阻塞在 parkAndCheckInterrupt 方法处
里面用 LockSupport 方法阻塞住了线程
打断后,返回 Thread.interrupted() 方法 !!!
记得之前我演示的吗,如果被 interrupt 打断过,这个方法会捕获到打断标记,就会返回 true
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 阻塞在这
// 捕获 interrupt 打断标记,返回 true
return Thread.interrupted();
}
过了那么久,你还记得之前代码在循环里面吗
这时会回到循环的开头
由于代码是被 interrupt 打断后继续执行的,不是线程释放锁将其唤醒,所以这一次循环不会成功加锁
所以仍旧会循环回到之前那一行代码,继续阻塞住。
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
// 因为循环回到开头
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return interrupted;
}
// 因为是被打断醒来,锁并没有被其他线程释放,所以还是回到这里继续阻塞
if (shouldParkAfterFailedAcquire(p, node))
// 之前线程阻塞在这里
// 如果被interrupt(不管是阻塞前还是阻塞后被interrupt,标记都会产生)
// 所以方法就会 返回true
// interrupted 变量就等于 true
// 运算符 |= 保证这个 true标记 不会被循环给覆盖掉
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
// ...省略其它无关代码
}
}
小细节 |= 运算符
这里又是一个非常有趣的小细节,Doug Lee 大神不是直接 用 = 符号,而是用 |= 去接收变量
想想看,假设用 = 会出现什么问题?
首先线程被打断后 返回 true,
然后第二次循环,由于拿不到锁,在同样的位置阻塞住,
然后前一个线程释放锁,唤醒了它,
这时候注意 !!由于这一次是被正常唤醒的,所以 返回的是 false,就会覆盖掉前一次被打断的记录。
那么被 interrupt 的记录就没了 !!!
interrupted |= parkAndCheckInterrupt();
interrupt 过了,lock() 方法后来被正常唤醒后的结果
我们又来到了这个方法。。。。。
(不是我想讲那么多遍,是因为情况有那么多种,我总不能偷工减料,悄悄去掉点内容,让你们学个半吊子水平)
上一个持有锁的线程执行完,释放锁,开始唤醒当前线程
这时候,我们的线程,被正常唤醒。
这时候 返回的 interrupted() 值一定为 false
但是,由于之前已经记录过了 true,所以 |= 之后还是为 true(保证了没有被覆盖掉)
这时候,再一次循环,加锁就成功,然后就不会再阻塞了,就会返回 true
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
// 又回到了循环开头
// (不过没关系,这一次总算能出去了)
// (但是非公平锁可能会被插队,再多循环几次。。。。。)(不要紧,总归快出去了)
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
// 加锁成功后把这个 interrupted=true 返回回去
return interrupted;
}
// 因为被 interrupt 过了,所我们的 interrupted 变量就位 true
// |= 运算符保证了 这个true 一直存在
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
// ...省略无关代码
}
}
这时候被返回 true 了,我们总算可以执行第三行代码了
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 前面返回了 true,执行到下一行代码
selfInterrupt();
}
不要懵逼,就是把当前线程再打断一次(把它 interrupt 一下)。。。。
就是调用一下 当前线程的 interrupt() 方法
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
为什么要这么写?
看到这里你可能会觉得很奇怪,这都什么神仙代码
为什么,它要在之前先调一次 interrupted() 方法,得到了被打断的结果
然后又在这里,把之前 interrupted() 抹去的打断标记再给添加上去
这看起来怎么这么无聊?为什么要这么做?
是不是完全可以,那里就不用判断什么是不是被 interrupt 过,这样就不会抹去标记,这样就不用再调用 interrupt 方法再把 打断标记 给添加上去
或者更简单点,调用 Thread.currenThread.isInterrupted() 方法来判断是否被打断,这样就不会抹去标记,那岂不是更好
更加不可思议的是,他判断了是否被打断,但是,判断了之后,并没有做任何的事情
那他干嘛要判断是否被打断???
、、
、、
(你要自己好好思考,这些代码为什么这么设计,如果你想不出来,那很可能就是,你的基础有欠缺,你的一部分知识没有掌握,所以不知道里面会出现的问题。
才最终导致你不明白代码为什么这么写)
、、
下面我来给大家解释
首先你要知道,这段代码本来是阻塞在 LockSupport.park() 那里的
但是,你去 interrupt 了之后,它就继续运行了。
也就是说,带有 interrupt 标记的线程,将无法再被 park() 阻塞 !!!!
示例:
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for(;;) {
// 死循环 每一次都会park自己
System.out.println("我将park自己");
LockSupport.park(Thread.currentThread());
System.out.println("我醒了");
}
});
t.start();
// 2秒后打断 线程t
Thread.sleep(2000);
t.interrupt();
}
开始,线程 t 打印了 “我将park自己”,然后就没反应了(因为它把自己 park 住了)
然后,interrupt 它。
然后它就懵逼了,它每一次循环,都要 park 自己。
但是它 park 不住啊,它死活 停不下了,它带了一个 interrupt 标记,它停不下来,就会疯狂循环
再看这里
(又看到这段代码了,所以说,这段代码很重要)
在 parkAndCheckInterrupt 方法这里,本来应该 park 阻塞起来,但是 !!!
停不下来啊,怕是嚼了炫迈
然后就疯狂循环。。
你想,要是几百个线程不做事,在这里疯狂循环,你这机子还怎么做事情 !!!
所以,他先获取 interrupt 标记,把它抹去,是为了让它继续阻塞起来。最后抢到锁了,那就可以把标记还给它了。
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node))
// 本来要阻塞在里面的,但是,阻塞不了了
// 然后就会疯狂循环
// 你想,要是几百个线程不做事,在这里疯狂循环,你这机子不是分分钟挂掉
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
// ...省略部分无关代码
}
}
lock() 被 interrupt 小结
- 首先,在阻塞之中,如果被 interrupt,自然会醒过来
- 但是,这个时候属于非正常唤醒(前面线程还没释放锁,它醒过来就会自旋疯狂加锁,浪费 CPU),所以先获取并抹去这个线程的 interrupt 标记,让它在下一个循环过后继续阻塞。
- 用 |= 运算符保证了 标记一但被获取就无法覆盖
- 最后线程正常唤醒拿到锁之后,用 interrupt() 方法重新给它再添加回标记
lockInterruptibly 可打断的 lock 方法
理解了上面的 lock 方法,以及为什么无法被打断,那么接下来你要理解 lockInterruptibly 是如何被打断的就会非常轻松了
因为 lockInterruptibly 的方法和传统 lock 方法并没有太大的差异,只是在几个地方,在 interrupted() 获取到 interrupt 标记之后,抛出 InterruptedException 异常让用户定义处理逻辑而已
我们开始点进方法中
发现调用了 sync 的 acquireInterruptibly(1) 方法
(见名知意,其实学习到现在,应该不用看源码也能大致知道实现过程了)
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
acquireInterruptibly 加锁前打断
我们继续点进方法
发现:
在加锁之前,先判断是否被打断,如果被打断了,就不用加锁了,直接抛异常
要是这时没被打断,就开始执行后面的加锁代码
public final void acquireInterruptibly(int arg)
throws InterruptedException {
// 这时候还没开始加锁
// 在加锁前就判断是否被打断,如果被打断了,直接抛异常
if (Thread.interrupted())
throw new InterruptedException();
// 然后就是加锁方法了
if (!tryAcquire(arg)) // 熟悉的tryAcquire(这里就可以不用看了,前面已经学地很扎实了)
// 我们点进下一行方法
doAcquireInterruptibly(arg);
}
眼熟的 doAcquireInterruptibly
点进来之后,怎么看怎么不对劲
这代码怎么那么眼熟??
说白了,就是 lock 方法稍微改一改,整体思路就没变
所以学会了前面的基础,这里我都不用带你们看,你们也应该能自己看懂了
和 lock 方法相比,也就最后 parkAndCheckInterrupt 阻塞的时候改了一下
不是返回是否被打断的 布尔值,而是打断直接抛异常
private void doAcquireInterruptibly(int arg)
throws InterruptedException {
// 熟悉的 addWaiter 方法(不记得回到上面复习)
final Node node = addWaiter(Node.EXCLUSIVE);
try {
// 熟悉的 for 循环
for (;;) {
// 熟悉的加锁过程
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return;
}
// 熟悉的阻塞过程
// 诶? 等等,这里不一样了
// 这里不返回是否状态了,如果被打断,直接抛异常
// 这么多代码,也就这里不一样。。。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
catch 异常
你不会以为就只有这么简单吧,其实还没有结束。。
(其实我在一开始看源码的时候,以为这样已经结束了,但后来一想才发现不对)
(你要是也这么想,那还是老老实实继续跟着我往下分析)
不知道你有没有注意到一个问题,在 doAcquireInterruptibly 的方法里,我们的循环方法,是被 try catch 给包起来的,所以,
抛出的异常会被捕获。
这里你可能很头疼,我们的 interrupt 中断,就是为了让它抛异常,我们才能自己手动捕获,去处理中断逻辑。
但是它自己把异常捕获了,是不是很神奇??
想想看,为什么他要这么写??
现在我们就要好好分析一下。
本来,我们的线程是在 AQS 队列中排队的,
如果,它突然被打断了,我们直接捕获了异常,去处理相应逻辑,
但是,这个线程还在队列里啊,它还在里面排队啊,这个时候,它已经被打断做自己的事了,
等到上一个线程执行完了,然后回来叫它,它会有反应吗?
显然没有,所以,这样我们的 AQS 队列就挂了,后面的线程会永远在排队,再也不会被叫醒了。
这样的话,我们的 Doug Lee 就会被人吐槽,这什么垃圾锁,老是一打断就死锁。
所以,在这里用 catch 先捕获了 这个异常,把这个被打断的线程,在队列里处理一下,保证队伍健在
然后再把这个异常重新抛出去,给我们的用户
但是 !!!!!
在这个 catch 异常之后的处理逻辑非常复杂,我在后文要花很多时间去分析
lockInterruptibly 小结
你只要跟着我一行一行代码看过来,你就会发现,实际上这里就改了两个地方。
- 加锁前先判断是否被打断
打断直接抛异常 - 要是加锁前没有,就开始加锁。
要是加锁没成功,执行到了 park 阻塞的那个地方,要是被打断了,直接抛异常 - 抛出异常后,自己先 catch,然后调整队列,再将异常重新抛出
失效结点的调整
调整的思路,应该是要让前面的线程能够去通知后面的线程
但是,由于多线程的情况下,不加锁的情况下,没有原子操作能够将其完好的踢出去,所以得有一个非常复杂的分析,存在很多情况去处理。
情况过于复杂(画图分不同情况探讨)
- 首先会把自己的 thread 变量设置为空
- 它会先一个一个往自己前面找,找到第一个可用的结点(没失效的)让自己指向它,作为自己的前置结点
- 然后把 自己的 ws 改成 1,说明自己没用了(失效了)
(把自己剔除队列有很多过程,防止自己还没有出队,其他线程就来干各种事情,所以先把自己标记起来) - 然后,如果自己后面没有结点,那么直接 CAS 把自己移除队列
- 如果后面还有结点,并且前置结点不是头结点 、并且没有失效,那就要先往后找,找到第一个有效结点为止,然后把自己前面的结点和后面的结点连接起来
- 如果自己前面的就是头结点,或者前置结点已经失效,就去唤醒后面的线程,然后将自己的后置结点设为自己(就是不能从自己这里再往后面找结点了)
- 如果后面的线程被叫醒了,就会往前去寻找到可用结点,把它指向自己(能够从前往后连起来)
先把 thread 置空
node.thread = null;
找未失效的前置结点
// 这里就是不断往前寻找,找到一个 ws 不大于 0 的结点做前一个结点
// 我们之前看到的,都是 ws 等于 0 或者 -1
// -1 表示后面有人排队阻塞,
// 这里的 大于0 是指 1,是指那些被打断了的、已经没用的,还在队列里的结点、
// 这时候把自己的前置结点设置为找到的这个节点
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// 这是个小细节
// 它只把自己的前一个结点设置成找到的第一个有效结点
// 但是不把这个节点的后结点改成自己
// 只是获取了它的后置结点
Node predNext = pred.next;
突然发现我的 4、5 两个结点忘记连起来了。。。。。
(画太多了,眼睛不好了)
把自己 ws 改成 1(说明自己失效)
node.waitStatus = Node.CANCELLED;
如果自己是最后一个,就 CAS 出队
看起来如果是在最后一个,操作是很简单的
if (node == tail && compareAndSetTail(node, pred)) {
pred.compareAndSetNext(predNext, null);
}
后面有结点,前面不是头结点且没失效
// 如果后面还有人(这时要把自己移除就比较复杂)
// 先看前一个是不是头结点,如果不是,并且 ws=-1(就算不是也给它改成-1)
// (上面的操作已经保证了它 ws 不可能 >0,它是个可用结点)
// 然后下面的方法 会把自己前面和后面连接起来,那自己就不在队列内了
// (但如果后面那个恰巧不可用,那就不连接,于是剔除自己失败)
int ws;
if (pred != head && // 不是头部
((ws = pred.waitStatus) == Node.SIGNAL || // ws = -1
// 如果可用就改成 -1
(ws <= 0 && pred.compareAndSetWaitStatus(ws, Node.SIGNAL))) &&
pred.thread != null) { // 不是头,又不是不可用的,所以thread肯定也不空
Node next = node.next; // 找到下一个结点
// 如果下一个结点不空,并且 ws<=0(说明可用)
// 就把前一个结点CAS指向后一个(这样就把前后连接起来)
if (next != null && next.waitStatus <= 0)
pred.compareAndSetNext(predNext, next);
}
前置为头,或者突然失效
else {
// 进入else方法(说明前一个是头结点、或者突然失效了)
// 为什么头结就不能够直接把前后连起来???(好好思考)
// 因为下一个线程就是自己,但是又不知道它什么时候会释放锁去唤醒线程。
// 很可能这个时候还没有连接前后,但是上一个线程(头)已经释放了锁,并且唤醒当前线程(失效线程)
// 这样,等你连接好了前后,但是前面的头结点已经不会再去唤醒下一个线程了,下一个线程就永远阻塞
// 所以,这个方法就是去叫醒后面的线程
// (头结点只是一种情况,假设前置结点在多线程环境下突然失效了,就不能连接失效结点)
// 而唤醒后面的线程,它发现前一个结点失效了,它会自己去找前面的结点连接
unparkSuccessor(node);
}
前置为头结点
假设在改引用前,t1 去唤醒 t2(这一步是没有任何作用的),
然后,t2 才把 头结点 的引用改成 t3,
这时,虽然 头结点 的引用被正确指向了 t3,但是已经晚了,t1 已经去唤醒过了,不会重新去唤醒 t3
所以,如果 前置结点 是头结点,就不去改引用,而是去唤醒后面的结点
前置节点突然失效
如果前置结点突然失效,那么此时就算修改了前置结点的引用指向了后面的结点,
拿一个失效的结点去指向它,仍然没有用处
唤醒后面的有效线程
// 调用这个方法会把自己这个节点作为参数传进来
private void unparkSuccessor(Node node) {
// 获取自己的 ws,如果 <0 就改成 0
// ws <0 表示后边有线程在等待,所以自己的状态才会 <0
// 这时后将 ws 改为 0,然后后面调用 unpark 方法去唤醒它
// (一般在这里 ws 都是 1,因为失效才搞了一大堆事情)
int ws = node.waitStatus;
if (ws < 0)
node.compareAndSetWaitStatus(ws, 0);
// 从最后往前找,直到找不到了或者找到了自己为止,
// 找出最前面的 有效节点
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node p = tail; p != node && p != null; p = p.prev)
if (p.waitStatus <= 0)
s = p;
}
// 只要不是空,就将它唤醒
if (s != null)
LockSupport.unpark(s.thread);
}
被唤醒的有效线程自己去寻找前面的有效结点
现在又回到你们熟悉的代码段了,仔细看
记得当时这个 判断应不应该阻塞 的方法,它被执行了两次,第一次把 前一个结点的 ws 从 0 改成 -1,第二次 发现是 -1 了,这个时候就阻塞自己
现在我们的这个线程来到这个方法,发现前一个结点是 1(一个失效结点)
这时候它就会再继续往前不断地找,直到找到一个有效结点为止
让这个有效结点指向自己
// 还记得这个判断要不要排毒的方法吗
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
// 我们只要关注这里
if (ws > 0) {
// 当刚才的失效结点唤醒了后面的这个节点之后,就会用一个循环
// 只要 ws > 0,就不断往前找
// 这样最终h会找到一个 有效结点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
// 然后将那个有效结点的后置结点设为自己
// 这样,队伍从前往后就是连起来了(可以从前往后找)
pred.next = node;
} else {
pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
}
return false;
}
下面整体看一下这些代码
cancelAcquire 试图将自己剔除出队列
主要目的是把自己从队列里踢出去(因为自己被打断了,不用再排队上锁了)
这里有一个 ws 值:CANCELLED 为 1,表示已经失效了(相当于队列里不存在它了,已经没用了)
- 首先会把自己的 thread 变量设置为空
- 它会先一个一个往自己前面找,找到第一个可用的结点(没失效的)让自己指向它,作为自己的前置结点
- 然后把 自己的 ws 改成 1,说明自己没用了(失效了)
(把自己剔除队列有很多过程,防止自己还没有出队,其他线程就来干各种事情,所以先把自己标记起来) - 然后,如果自己后面没有结点,那么直接 CAS 把自己移除队列
- 如果后面还有结点,并且前置结点不是头结点 、并且没有失效,那就要先往后找,找到第一个有效结点为止,然后把自己前面的结点和后面的结点连接起来
- 如果自己前面的就是头结点,或者前置结点已经失效,就去唤醒后面的线程,然后将自己的后置结点设为自己(就是不能从自己这里再往后面找结点了)
// 传入当前线程结点
private void cancelAcquire(Node node) {
// 如果空,说明自己不在队列里,就不会影响到队列,就不必做其他操作
// (我目前的分析是不会出现这种情况,加这一行虽然可能多余,但万一有也可以保证安全不出错)
// (如果大家有找到这种情况可以给我留言)
if (node == null)
return;
// 首先把自己 thread变量 置空
// (一开始我以为是为了让这个节点以后成为头结点的时候 thread 要为空)
// (不过把整体分析了之后,这个节点不会有成为头结点的机会,应该只是为了 help gc)
node.thread = null;
// 这里就是不断往前寻找,找到一个 ws 不大于 0 的结点做前一个结点
// 我们之前看到的,都是 ws 等于 0 或者 -1
// -1 表示后面有人排队阻塞,
// 这里的 大于0 是指 1,是指那些被打断了的、已经没用的,还在队列里的结点、
// 这时候把自己的前置结点设置为找到的这个节点
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// 这是个小细节
// 它只把自己的前一个结点设置成找到的第一个有效结点
// 但是不把这个节点的后结点改成自己
// 只是获取了它的后置结点
Node predNext = pred.next;
// 把自己的 ws 写为 1
// 看到这,也就很容易理解上面的操作
// 1 表示自己已经被打断了(不再有效)不用在队列里理会我
// 就像上一步操作,直接跳过 1 的结点,往前找不是 1 的结点
node.waitStatus = Node.CANCELLED;
// 之前的操作是将前面的无效结点剔除,重新连接起一条队列
// 这里设置自己无效,可以让自己前面、后面的结点能知道要跳过自己
// 要是自己就在尾部,后面没有结点了,那就可以很简单,CAS直接把自己移除
if (node == tail && compareAndSetTail(node, pred)) {
pred.compareAndSetNext(predNext, null);
} else {
// 如果后面还有人(这时要把自己移除就比较复杂)
// 先看前一个是不是头结点,如果不是,并且 ws=-1(就算不是也给它改成-1)
// (上面的操作已经保证了它 ws 不可能 >0,它是个可用结点)
// 然后下面的方法 会把自己前面和后面连接起来,那自己就不在队列内了
// (但如果后面那个恰巧不可用,那就不连接,于是剔除自己失败)
int ws;
if (pred != head && // 不是头部
((ws = pred.waitStatus) == Node.SIGNAL || // ws = -1
// 如果可用就改成 -1
(ws <= 0 && pred.compareAndSetWaitStatus(ws, Node.SIGNAL))) &&
pred.thread != null) { // 不是头,又不是不可用的,所以thread肯定也不空
Node next = node.next; // 找到下一个结点
// 如果下一个结点不空,并且 ws<=0(说明可用)
// 就把前一个结点CAS指向后一个(这样就把前后连接起来)
if (next != null && next.waitStatus <= 0)
pred.compareAndSetNext(predNext, next);
} else {
// 进入else方法(说明前一个是头结点、或者突然失效了)
// 为什么头结就不能够直接把前后连起来???(好好思考)
// 因为下一个线程就是自己,但是又不知道它什么时候会释放锁去唤醒线程。
// 很可能这个时候还没有连接前后,但是上一个线程(头)已经释放了锁,并且唤醒当前线程(失效线程)
// 这样,等你连接好了前后,但是前面的头结点已经不会再去唤醒下一个线程了,下一个线程就永远阻塞
// 所以,这个方法就是去叫醒后面的线程
// (头结点只是一种情况,假设前置结点在多线程环境下突然失效了,就不能连接失效结点)
// 而唤醒后面的线程,它发现前一个结点失效了,它会自己去找前面的结点连接
unparkSuccessor(node);
}
// 如果出队成功了,这时才能如同作者所说的 help gc
// 这一步是非关键的,只有前面的操作让队列中将自己踢出了,才行
node.next = node; // help GC
}
}
unparkSuccessor 唤醒后面的线程
// 把自己这个节点传过来
private void unparkSuccessor(Node node) {
// 获取自己的 ws,如果 <0 就改成 0
// ws <0 表示后边有线程在等待,所以自己的状态才会 <0
// 这时后将 ws 改为 0,然后后面调用 unpark 方法去唤醒它
int ws = node.waitStatus;
if (ws < 0)
node.compareAndSetWaitStatus(ws, 0);
// 从最后往前找,直到找不到了或者找到了自己为止,
// 找出最前面的 有效节点
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node p = tail; p != node && p != null; p = p.prev)
if (p.waitStatus <= 0)
s = p;
}
// 只要不是空,就将它唤醒
if (s != null)
LockSupport.unpark(s.thread);
}
shouldParkAfterFailedAcquire 被唤醒的线程自己去向前找有效结点
还记得那个判断自己要不要排队的方法吗??(毕竟隔了很久)
当时我们只是研究了 ws=0 和 ws<0 的情况,用于做两次自旋
而这时就是 > 0 的情况了(=1)
所以我们这里只需要看 大于0 的那段代码
这时,这个线程就是被前面的那个失效的线程唤醒的后面的结点里的线程(前面的失效结点,发现条件不满足,自己不能够去连接队列,所以在 else 中唤醒后面的线程),
它要自己去找前面的有效结点做连接,
这个代码就是为了将前面的有效结点,连到后面的有效结点(就是刚刚被唤醒的自己)来
// 还记得这个判断要不要排毒的方法吗
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
// 我们只要关注这里
if (ws > 0) {
// 当刚才的失效结点唤醒了后面的这个节点之后,就会用一个循环
// 只要 ws > 0,就不断往前找
// 这样最终h会找到一个 有效结点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
// 然后将那个有效结点的后置结点设为自己
// 这样,队伍从前往后就是连起来了(可以从前往后找)
pred.next = node;
} else {
pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
}
return false;
}
带超时的 tryLock
其实学完了上面的内容之后,这一部分我完全可以不用提点,你们自己也该能看的懂
因为这个方法中的关键代码,调用的也都是我之前已经带你们分析过的方法了
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
// 带超时的加锁
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
似曾相识的方法
我们点进方法看一下
是不是很熟悉?
和 lockInterruptedly 方法 在这里没有什么差别
同样的,是先在加锁前判断是否被打断,如果是,就不用加锁,直接抛出异常
然后,尝试加锁,
如果没有成功加锁,进入超时加锁方法
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
// 如果加锁前被打断,直接抛异常
if (Thread.interrupted())
throw new InterruptedException();
// 否则,尝试加锁一次
return tryAcquire(arg) ||
// 尝试加锁失败就进入超时加锁方法
doAcquireNanos(arg, nanosTimeout);
}
是不是总感觉这一段一段代码都和之前的非常相似
这里面和之前不同的是:
多了一个判断时间的地方
如果超时了,就用 之前打断后调整队列的方式,让自己失效
如果没超时,并且剩余时间还大于 1秒,就阻塞到时间结束
如果时间不够 1秒,就不阻塞了,直接自旋 !!!
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
// 在入队前判断是否超时
// (这里用户定的时间 <= 0,那就相当于不允许等待,tryLock 没成功就直接返回 false)
if (nanosTimeout <= 0L)
return false;
// 计算剩余时间
final long deadline = System.nanoTime() + nanosTimeout;
// 如出一辙的入队操作(不记得了回到上面复习)
final Node node = addWaiter(Node.EXCLUSIVE);
try {
// 熟悉的 for 循环
for (;;) {
// 熟悉的前面是队头就尝试加锁
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return true;
}
// 需要注意的是
// 多了一层时间的判断
// 如果等待时间达到了,就调用之前讲过的 cancelAcquire 方法
// 调整队列,让自己失效
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L) {
cancelAcquire(node);
return false;
}
// 这里也需要注意
// 不仅仅是前面的结点 改成-1 就直接 park
// 你看这个 if 判断 后面还有一个 剩余时间要 大于 1秒
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > SPIN_FOR_TIMEOUT_THRESHOLD)//这个常量是1秒
// 然后才允许阻塞到剩余时间结束
// 也就是如果时间不到一秒钟 是不让阻塞的
// 这个时候就是最长自旋 1秒 的自旋锁,不阻塞!!!!
LockSupport.parkNanos(this, nanosTimeout);
// 如果被中断了 就throw异常 让catch处理后 再抛出
if (Thread.interrupted())
throw new InterruptedException();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
带超时的 lock 小结
- 首先和 可以被打断的 lock 一样,在加锁前先判断是否被打断,如果被打断了,就不用加锁,直接抛出异常让用户处理打断逻辑
(因为待超时的 lock 也是一种可以被打断的 lock) - 然后同样先尝试加锁一次
- 加锁不成功的话,先判断时间有多少,如果给的时间 本来就不大于 0,那就直接返回 false 表示加锁失败
- 否则就入队
- 入队后进入熟悉的循环,每一次循环都会判断是否前一个是队头要去尝试加锁
- 循环中如果时间过了,就可以 返回 false 加锁失败了,但是返回前要先老样子调整队列,让自己失效
- 如果循环中时间还没过,那就看一下当前是否可以阻塞,并且 如果剩余时间 > 1秒 才阻塞,否则在循环中自旋
- 如果被打断了,老样子,catch 捕获,处理了队列之后,再重新抛给用户
作者的话
到这里,整个 ReentrantLock 的加解锁你应该已经理解得十分透彻了。
(毕竟我是一行一行给你读过来的)
毕竟现如今网络上的博客,大部分是比较 简单 的,我并没有找到能把这些加锁解锁的过程从 代码 层面写清楚的人,而且里面很多 关键的点,这些 重要的思想,很少有去 分析 的。
我们阅读源码,一方面,是了解它的实现过程。
但是还有一方面,我们是要学习作者的 优秀的编码思想,和源码中每一处这么写的 原由。
不过由于源码 博大精深,我也只是 略有窥探,想来很多地方我也许是有 遗漏 的。所以,如果读者发现什么不全、或者错误的地方,也 恳请指正。
如果你仅仅只是把它读了一遍,但是不知道,为什么这个地方要写这样一段代码,为什么要写这么多看似无关的复杂的逻辑。那我认为,你从源码学习到的也只是一些 皮毛。
就比如我这篇文章中写到的,先获取抹去 interrupt 标记,再给它添加上;当一个线程自己被打断失效了,却又去唤醒了后面的线程;进入 tryAcquire 方法后,如果没线程持有锁,而是先判断要不要排队……
等等等等,有很多很多需要 思考 和 分析 的地方。
这篇博客我写了两天多,大概二十多个小时,我是从源码中,一个方法一个方法进去,去阅读里面的代码,然后复制过来,写上我自己的注释,以及对这些代码的 分析 和 思考,去研究 为什么 作者要这么去写,否则 是否会出现错误。
如果你只是和阅读其他博客一样,花个几分钟,或者,我给你多算一点时间,花个半或一小时,来阅读。那么我觉得,你实际上仍是 一知半解。
其实,学习源码的最好方式,就是从头开始,一行一行代码去往里面分析,先阅读当时执行相关的代码,忽略其他那些无关代码。
就比如我先用第一个线程加锁,就可以忽略 AQS 的队列(单线程执行不会初始化队列);然后第二个线程加锁,就要去阅读队列有关的代码,然后了解 ws 的有关的状态值;在后面研究 interrupt 打断失效的时候,才研究 ws 的失效状态值的含义。
当你能把我的文章里的内容看明白,不管你学习得有多深入了,至少可以保证,里面的大致流程你已经清楚,在这样的情况下,如果你再去阅读、研究源码,在我给你的基础之上,你可以比较轻松的理解出源码中的代码。
假设你从来没有接触过,直接扎进去阅读,而没有一个大体概览的话,那阅读便如同天书一般。
(允许我小调皮一下,研究那么久那么多我也是很头大)
当然大部分人应该不会看到我的这一行,如果你觉得我这篇博客能真正给你带来益处,就 素质三连。。。
转载:https://blog.csdn.net/weixin_44051223/article/details/104903424