十分钟带你搞懂 Java AQS 核心设计与实现!

  1. 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;

  1. AQS 中的双向线程队列 由于 Node 前驱和后继节点的存在。这里保存 Node 的队列实际上是一个双向队列。在这个队列里前驱节点的存在会更重要些:当前新节点被插入到队列中时,如果前驱节点状态为取消状态。我们可以通过前驱节点不断往前回溯,完成一个类似滑动窗口的功能,跳过无效线程,从而帮助我们更有效的管理等待队列中线程。而且上面也提过了,等待线程都放在队列中,一方面可以管控等待线程,另一方面也可以减少饥饿现象发生的概率。

  2. 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;

}

}

}

}

这段逻辑不复杂:

  1. 当我们处理第一个节点时,此时 tail 节点为 null,因此会执行 enq() 方法。可以看到 enq 方法实际是一个死循环,只有当节点成功被插入到队列后,才能跳出去循环。那这么做的目的是什么呢?其实不难看出,这里是为了应对多线程竞争而采取的妥协之策。多个线程同时执行这段逻辑时,只有一个线程可以成功调用 compareAndSetHead() 并将 head 头指向一个新的节点,此时的 head 和 tail 都指向一个空节点。这个空节点的作用前面已经提过了,用于帮助后继节点可以在合适的场景下自我阻塞等待被唤醒。其它并发执行的线程执行 compareAndSetHead() 方法失败后,发现 tail 已经不为 null 了,依次将自己插入到 tail 节点后。

  2. 当 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,在参阅了其它笔者分析的文章再加上自己的理解后,总结出以下两种场景:

  1. 当我们往队列中新插入一个节点时。队尾节点的 waitStatus 值应为初始状态 0。此时执行 shouldParkAfterFailedAcquire() 方法会执行最后一个判断条件将前驱 waitStatus 状态更新为 SIGNAL,同时方法返回 false 。然后会继续执行一次 acquireQueued() 中的死循环,此时前驱节点的状态已经被更新为 SIGNAL,再次执行 shouldParkAfterFailedAcquire() 方法会返回 true,当前线程即可放心的将自己挂起,等待被线程唤醒。

  2. 当调用 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开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

总结

阿里伤透我心,疯狂复习刷题,终于喜提offer 哈哈~好啦,不闲扯了

image

1、JAVA面试核心知识整理(PDF):包含JVMJAVA集合JAVA多线程并发,JAVA基础,Spring原理微服务,Netty与RPC,网络,日志,ZookeeperKafkaRabbitMQ,Hbase,MongoDB,Cassandra,设计模式负载均衡数据库一致性哈希JAVA算法数据结构,加密算法,分布式缓存,Hadoop,Spark,Storm,YARN,机器学习,云计算共30个章节。

image

2、Redis学习笔记及学习思维脑图

image

3、数据面试必备20题+数据库性能优化的21个最佳实践

image
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
设计模式*,负载均衡数据库一致性哈希JAVA算法数据结构,加密算法,分布式缓存,Hadoop,Spark,Storm,YARN,机器学习,云计算共30个章节。

[外链图片转存中…(img-7GyUqLXl-1713552937282)]

2、Redis学习笔记及学习思维脑图

[外链图片转存中…(img-9fVIzp3D-1713552937283)]

3、数据面试必备20题+数据库性能优化的21个最佳实践

[外链图片转存中…(img-yYlTujyi-1713552937283)]
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值