- Node 节点的设计
-
前驱、后继节点,分别保存当前节点在队列中的前驱节点和后继节点
-
节点状态:节点拥有不同的状态可以帮助我们更好的管理队列中的线程。在本文中我们只讨论 SIGNAL 和 CANCEL 状态。当前驱节点的状态为 SIGNAL 时,表示当前节点可以被安全挂起,锁释放时当前线程会被唤醒去尝试重新获取锁;CANCEL 状态表示当前线程被取消,无需再尝试获取锁,可以被移除队列
// 线程被取消
static final int CANCELLED = 1;
// 后续线程在锁释放后可以被唤醒
static final int SIGNAL = -1;
// 当前线程在 condition 队列中
static final int CONDITION = -2;
// 没有深入体会,表示下一次共享式同步状态获取将会无条件被传播下去
static final int PROPAGATE = -3;
-
AQS 中的双向线程队列 由于 Node 前驱和后继节点的存在。这里保存 Node 的队列实际上是一个双向队列。在这个队列里前驱节点的存在会更重要些:
当前新节点被插入到队列中时,如果前驱节点状态为取消状态。我们可以通过前驱节点不断往前回溯,完成一个类似滑动窗口的功能,跳过无效线程
,从而帮助我们更有效的管理等待队列中线程。而且上面也提过了,等待线程都放在队列中,一方面可以管控等待线程,另一方面也可以减少饥饿现象发生的概率。 -
HEAD 和 TAIL HEAD 和 TAIL 节点分别指向队列的首尾节点。当第一次往队列中塞入一个新的节点时会构造一个虚拟节点作为 HEAD 头节点。为什么需要虚拟的 HEAD 头节点呢?因为在 AQS 的设计理念中,当前节点能够安心自我阻塞的前提条件是前驱节点在释放锁资源时,能够唤醒后继节点。
而插入到第一个队列中的节点,没有前驱节点怎么办,我们就构造一个虚拟节点来满足需求
。
同时 HEAD 和 TAIL 节点的存在加上双向队列的设计,整体的队列就显的非常灵活。
这一章节开始我们将结合源码对 AQS 获取锁的流程进行讨论。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
acquire 方法用于获取锁,这里可以拆解为三步:
-
tryAcquired: 看名字就知道用于尝试获取锁,并不保证一定可以获取锁,具体逻辑由子类实现。如果在这一步成功获取到了锁,后面的逻辑也就没有必要继续执行了。
-
addWaiter:
尝试竞争锁资源失败后,我们就要考虑将这个线程构造成一个节点插入到队列中了
。这里的 addWaiter() 方法会将当前线程包装成一个 Node 节点后,维护到 FIFO 双向队列中。
private Node addWaiter(Node mode) {
// 将当前线程包装成一个 Node 节点
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
// 如果 tail 节点不为空:新节点的前驱指向 tail,原尾节点的后继指向当前节点,当前节点成为新的尾节点
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 第一次往队列中新增节点时,会执行 enq 方法
enq(node);
return node;
}
private Node enq(final Node node) {
for (;😉 {
// head 和 tail 在初始情况下都为 null
Node t = tail;
if (t == null) { // 初始化一个空节点用于帮助唤醒队列中的第一个有效线程
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 这段逻辑用于考虑多线程并发的场景,如果此时队列中已经有了节点
// 再次尝试将当前节点插至队尾
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
这段逻辑不复杂:
-
当我们处理第一个节点时,此时 tail 节点为 null,因此会执行 enq() 方法。可以看到 enq 方法实际是一个死循环,只有当节点成功被插入到队列后,才能跳出去循环。那这么做的目的是什么呢?
其实不难看出,这里是为了应对多线程竞争而采取的妥协之策
。多个线程同时执行这段逻辑时,只有一个线程可以成功调用 compareAndSetHead() 并将 head 头指向一个新的节点,此时的 head 和 tail 都指向一个空节点。这个空节点的作用前面已经提过了,用于帮助后继节点可以在合适的场景下自我阻塞等待被唤醒。其它并发执行的线程执行 compareAndSetHead() 方法失败后,发现 tail 已经不为 null 了,依次将自己插入到 tail 节点后。 -
当 tail 节点不为空时,表示此时队列中有数据。因此我们借助 CAS 将新节点插入到尾节点之后,同时将 tail 指向新节点
- acquireQueued
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;😉 {
final Node p = node.predecessor();
// 前驱节点为 head 时,尝试获取锁
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这里又是一个死循环
-
这里需要注意的是只有前驱节点为 head 时,我们才会再次尝试获取锁
。也就是在当前队列中,只有队首节点才会尝试获取锁。这里也体现了如何降低饥饿现象发生的概率
。如果成功获取到了锁:将 node 节点设置为头节点,同时将前驱节点的 next 设置为 null 帮助 gc。 -
如果 node 节点前驱节点不为 head 或者获取锁失败,执行 shouldParkAfterFailedAcquire() 方法判断当前线程是否需要阻塞,如果需要阻塞则会调用 parkAndCheckInterrupt() 方法挂起当前线程
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
-
当前驱节点状态为 SIGNAL 时,表示调用 release 释放前驱节点占用的锁时,
-
前驱会唤醒当前节点,可安全挂起当前线程等待被唤醒
*/
return true;
if (ws > 0) {
/*
- 前驱节点处于取消状态,我们需要跳过这个节点,并且重试
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// waitStatus 为 0 或 PROPAGATE 走的这里。后文会分析下什么时候 waitStatus 可能为 0
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
当节点状态为 SIGNAL 时,表示当前线程可以被安全挂起。waitStats 大于0表示当前线程已经被取消,我们需要往前回溯找到有效节点。
在开始阅读这段代码时,一直想不通在哪些场景下 waitStatus 的状态可能为 0,在参阅了其它笔者分析的文章再加上自己的理解后,总结出以下两种场景:
-
当我们往队列中新插入一个节点时。队尾节点的 waitStatus 值应为初始状态 0
。此时执行 shouldParkAfterFailedAcquire() 方法会执行最后一个判断条件将前驱 waitStatus 状态更新为 SIGNAL,同时方法返回 false 。然后会继续执行一次 acquireQueued() 中的死循环,此时前驱节点的状态已经被更新为 SIGNAL,再次执行 shouldParkAfterFailedAcquire() 方法会返回 true,当前线程即可放心的将自己挂起,等待被线程唤醒。 -
当调用 release() 方法释放锁时,会将占用锁的节点的 waitStatus 状态更新为 0
,同时会调用 LockSupport.unpark() 方法唤醒后继节点。当后继节点被唤醒之后,会继续执行被挂起之前执行的 acquireQueued() 方法中的 for 循环再次尝试获取锁。但是被唤醒并不代表一定可以获取到锁
,如果获取不到锁则会再次执行 shouldParkAfterFailedAcquire() 方法。
为什么说被唤醒的线程不一定可以获取到锁呢?
对于基础的 acquire 方法来说,没有任何规则规定队首节点一定可以获取到锁。当我们在唤醒队列中的第一个有效线程时,此时如果出现了一个线程 A 尝试获取锁,那么该线程会调用 acquire() 方法尝试获取锁,如果运气不错,线程 A 完全有可能会窃取当前处于队列头中的线程获取锁的机会。因此基础的 acquire 方法实际上是不公平的
。那么为什么这么做?
如果队列头处于解除阻塞过程中,这一段时间实际上没有线程可以获取资源,属于一种资源浪费。所以这里只能认为是有一定概率的公平。
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
// 当状态小于 0 时,更新 waitStatus 值为 0
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 如果后继节点为 null 或者状态为取消,从尾结点向前查找状态不为取消的可用节点
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 唤醒被阻塞的线程
if (s != null)
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)
总结
阿里伤透我心,疯狂复习刷题,终于喜提offer 哈哈~好啦,不闲扯了
1、JAVA面试核心知识整理(PDF):包含JVM,JAVA集合,JAVA多线程并发,JAVA基础,Spring原理,微服务,Netty与RPC,网络,日志,Zookeeper,Kafka,RabbitMQ,Hbase,MongoDB,Cassandra,设计模式,负载均衡,数据库,一致性哈希,JAVA算法,数据结构,加密算法,分布式缓存,Hadoop,Spark,Storm,YARN,机器学习,云计算共30个章节。
2、Redis学习笔记及学习思维脑图
3、数据面试必备20题+数据库性能优化的21个最佳实践
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
设计模式*,负载均衡,数据库,一致性哈希,JAVA算法,数据结构,加密算法,分布式缓存,Hadoop,Spark,Storm,YARN,机器学习,云计算共30个章节。
[外链图片转存中…(img-7GyUqLXl-1713552937282)]
2、Redis学习笔记及学习思维脑图
[外链图片转存中…(img-9fVIzp3D-1713552937283)]
3、数据面试必备20题+数据库性能优化的21个最佳实践
[外链图片转存中…(img-yYlTujyi-1713552937283)]
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!