并发之AQS的双向链表

谈谈 AQS

  AQS(AbstractQueuedSynchronizer)JUC包下的一个抽象类。虽然是抽象类,但没有抽象方法,即便子类集成,也无法直接使用锁功能。AQS中关于锁的判断TryAcquireTryRelease方法,默认都是报错,需要子类集成后进行重写,才能使用锁功能。
  JUC包的一些并发功能都是基于AQS实现的,例如:ReentrantLockThreadPoolExecutor、并发集合等。
  AQS的实现包含:一属性,两队列。
   一属性:state。表示锁的状态。初始化为0表示无锁,获取锁后+1,释放锁时-1。一般在TryAcquireTryRelease中实现+、-功能。
  两队列:双向队列与单向队列。

双向队列

双向链表,也称作等待队列、阻塞队列。是在线程获取锁失败后的等待处理操作。AQS中存在<Node> head<Node> tail属性用于组成双向队列。
重点关注的几个重点词:

  1. AbstractQueuedSynchronizer存在headtail属性,所以其本身就是一个链表。并没有使用集合

  2. 双向链表(等待队列)

    1. head 永远都是伪节点(thead = null)
    2. tail 初始化时是伪节点(初始化时, head == tail),之后就不是了。
  3. node的作用就是封装线程信息,然后并放到链表中排队

  4. node节点有5种状态:

    1. 用于双向链表(CANCELLED、SIGNAL、0)
    2. 用于单向链表(CONDITION、CANCELLED)
    3. 用于共享锁(PROPAGATE)
  5. 双向链表中节点状态

    1. tail的节点状态永远是 0
    2. head初始化为0,之后变为-1
    3. 中间节点的状态为 -1
    4. 被取消(无效/中断)的节点状态为 1。
  6. 挂起线程: LockSupport.pack(thread)

  7. 唤醒线程: LockSupport.unpack(thread)

  8. 获取锁操作:
       acquire -> tryAcquire -> addWaiter -> acquireQueued(死循环)
       -> shouldParkAfterFailedAcquire -> parkAndCheckInterrupt -> LockSupport.park
       -> setHead(可以认为,删除唤醒节点)

  9. 获取锁异常操作:
       cancelAcquire

  10. 取消锁操作:
       release -> tryRelease -> unparkSuccessor -> LockSupport.unpark

  11. 双向链表中是否可以不用head节点?
      可以不用。

    1. 在设计之前就提出了伪节点的存在,
    2. head节点的使用可以简化
  12. 为什么是双向链表,而不是用单线链表?
      因为在使用单线链表时,删除中间节点时,无法将node.prev.next 指向node.next。解决方法只能不断遍历,增加了很多无用操作。
      而使用双向链表就没有这个问题

锁的获取与释放 - 获取锁代码步骤

1. acquire 开启锁入口

  所有调用锁功能的入口

public final void acquire(int arg) { // 尝试获取锁 if (!tryAcquire(arg) && // 没有拿到锁,走此方法 // addWaiter(Node.EXCLUSIVE) 封装层一个node节点, 并放入到双向链表中 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 当前线程中断 selfInterrupt(); }

2. tryAcquire 尝试获取锁

  尝试获取锁。需要子类方法去重写此类。返回true,表示获取到锁,返回false。表示未获取到锁,代码才会往后走。
大体逻辑

  1. CAS,如果AQS.state = 0,则将 AQS.state = 1
  2. 修改 aqs.thread = 当前线程

  AQS中对于TryAcquire需要子类重新,这里,我们使用ThreadPoolExecutor.WorkerTryAcquire方法(主要是代码简单)

protected boolean tryAcquire(int unused) { // cas 方式获取锁,就是尝试修改 state 参数 if (compareAndSetState(0, 1)) { // 获取到锁(独占锁),将方法头 markwork 中线程改为当前线程 setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; }

3. addWaiter 将线程放入等待队列中

  没有获取到锁,创建一个node节点,并放入双向队列的中,队列遵循FIFO。enq()是head与tail初始化方法。可以细看一下,代码简单,在这里不做描述。

private Node addWaiter(Node mode) { // 创建一个节点,设置其同步队列中的下一个节点。 // 此处可以看出,node其实就是线程的封装 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; // 如果tail != null,说明队列中有值,将node放入到队列的末尾 if (pred != null) { node.prev = pred; // cas的经典实用 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // 如果 tail 为null时,说队列没有值,需要初始化 head 与 tail // 以死循环的方式,一定将node放入到队列的末尾 enq(node); return node; }

4. acquireQueued(死循环) 将刚加入线程,挂起

  方法体中存在死循环,线程被挂起后,基本上两圈半后,就会返回 true,结束循环。

  • 第一圈,将前置节点状态改为 -1。
  • 第二圈,将线程挂起。
  • 半圈,线程被唤醒后成为head,变为伪节点。

  而被唤醒的线程一定是Head.next节点。当然如果,线程本身就是head.next,则直接过去到锁。不会挂起。

final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { // 获取node的前置节点 final Node p = node.predecessor(); // 如果 node.pre节点,也就是 p == head,说明node就是第一顺位节点。 // 这样,就尝试获取锁资源 // 如果不是,就放到队列里面 if (p == head && tryAcquire(arg)) { // tryAcquire方法,会被不同的子类重写,可以查看 ReentrantLock、ThreadPoolExecutor.Worker // 获取锁资源成功,node就是变成 head,也变成 伪节点 setHead(node); p.next = null; // help GC failed = false; return interrupted; } // shouldParkAfterFailedAcquire(p, node) // 功能:将 p节点状态改为 -1,并返回false。当返回true后,才会执行 parkAndCheckInterrupt方法,挂起 // 所以这个死循环,一般会走两边, // 第一次循环,将p节点状态改为 -1 if (shouldParkAfterFailedAcquire(p, node) && // 第二次循环,将 node 节点挂起。LockSupport.pack(this) parkAndCheckInterrupt()) interrupted = true; } } finally { // 在 acquireQueued 基本上不会走这个方法 if (failed) cancelAcquire(node); } }

5. shouldParkAfterFailedAcquire 挂起前准备,将前置节点状态改为 -1

  将 node.prev节点状态改为 -1,并返回false。当返回true后,才会执行 parkAndCheckInterrupt方法,挂起。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // ws == Node.SIGNAL 表示 pred节点可以唤醒后面的节点 // 只有 ws = -1,node才会挂起,并返回 true if (ws == Node.SIGNAL) return true; // ws > 0 ,只会是 1,表示此节点已被取消。 if (ws > 0) { // 当前node节点的 pre节点的pre节点,作为 node的pre节点 // 知道找到 pred.waitStatus != 1的节点 do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { /* * cas 将 pred 节点的状态改为 -1。表示可以唤醒后续线程 */ compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }

6. parkAndCheckInterrupt 挂起线程(LockSupport.park)

  挂起线程,查看线程的唤醒,是因为异常报错,还是正常唤醒。

private final boolean parkAndCheckInterrupt() { // 挂起线程方法 LockSupport.park(this); // 这个方法可以确认,当前线程是中断唤醒的,还是正常唤醒的 return Thread.interrupted(); }

7. setHead 被唤醒的节点,变为head(可以认为,删除唤醒节点)

  能走到setHead方法,说明已经获取锁。并将node代替head节点。
  需要注意的事:head.next 不能设置为空。释放锁时用。

image

锁的获取与释放 - 释放锁代码步骤

1. release 释放锁入口

  释放锁的两个动作:1.将AQS.state--,2.唤醒 head.next线程

public final boolean release(int arg) { // tryRelease 如果锁释放,则返回true,否则false if (tryRelease(arg)) { // 锁已经放掉,走此逻辑 Node h = head; // h != null,说明有排队的 // h.waitStatus != 0,说明有排队的,并且线程已经挂起 if (h != null && h.waitStatus != 0) // 唤醒下一个线程等待节点 unparkSuccessor(h); return true; } return false; }

2. tryRelease 尝试释放锁

  与TryAcquire相同,需要子类重写此方法。
大体逻辑:

  1. 判断 AQS.state == 0。只有等于0,才说明完全释放锁,才会有后面动作。大于0,说明锁未完全释放。
  2. 设置 AQS.thread =null

  为了能体现,AQS.state > 0的情况,我们查看ReentrantLockTryRelease方法

protected final boolean tryRelease(int var1) { // 获取 AQS.state值。var1必然是1 int var2 = this.getState() - var1; // 如果当前线程不是 拥有锁的线程,则报错 if (Thread.currentThread() != this.getExclusiveOwnerThread()) { throw new IllegalMonitorStateException(); } else { boolean var3 = false; // 判断 AQS.state == 0 if (var2 == 0) { var3 = true; // AQS.thread =null this.setExclusiveOwnerThread((Thread)null); } // 如果是重入锁,此时调整锁的次数 // 实际上就是 state-- this.setState(var2); return var3; } }

3. unparkSuccessor 唤醒下一个节点 LockSupport.unpark(head.next.thread);

  唤醒 head.next线程。被唤醒的节点,会继续执行acquireQueued的方法。
  假如head.next.waitStatus =1,也就是说,下一个节点被取消了,不能唤醒。则tail上前寻找距离head最近的有效节点,并唤醒。
  通过acquireQueued方法,可以知道被唤醒的节点最终会成为head

private void unparkSuccessor(Node node) { int ws = node.waitStatus; // ws<0,则 ws =1 或者 =-1 if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; // 假如 s 节点被中断了。是null // 将从 tail 向前找,找到距离head最近的有效节点,并赋值给s,进行唤醒 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; } // 如果不是 null,则将直接唤醒线程 if (s != null) LockSupport.unpark(s.thread); }

锁的获取与释放 - 获取锁异常代码步骤

1. cancelAcquire 取消在AQS(双向链表)中的node

  代码一般被写到finally块内,保证获取锁异常时一定执行
取消节点的操作流程:

  1. 将node.thread = null
  2. 往前找到有的节点作为node.prev
  3. 将 node.waitStatue = 1,表示节点取消
  4. 将node 脱离 双向链表,分三种情况
    4.1 当node 是tail,直接删除
    4.2 当node 是 head.next,删除 并唤醒下一个节点
    4.3 当node 是中间节点,删除 并确保node.prev.waitStatus一定为-1
private void cancelAcquire(Node node) { // Ignore if node doesn't exist if (node == null) return; // 1. 将 thread 设置为 null node.thread = null; // 2. 往前找到 节点状态有效的节点,就是 !=1 Node pred = node.prev; while (pred.waitStatus > 0) node.prev = pred = pred.prev; Node predNext = pred.next; // 3. node节点状态,改为已删除 node.waitStatus = Node.CANCELLED; // 4.1 如果node节点是 tail,则将 prev 作为 tail节点 if (node == tail && compareAndSetTail(node, pred)) { // 将 tail.next = null // 感觉没有必要,直接 == null,就可以了 // compareAndSetTail可以保证,下一步操作的唯一 compareAndSetNext(pred, predNext, null); } else { // 代码走到这,说明node不是 tail int ws; // 4.3 node是中间节点,确保prev节点状态是-1,这样才可以指向next节点 if (pred != head && // 下面代码: 就是确保 prev的状态一定是 -1 ,这样就可以唤醒后续节点 ((ws = pred.waitStatus) == Node.SIGNAL || // ws 并发情况下,有可能是0,但也要改为-1 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { // 二次校验,prev是一个有效节点 Node next = node.next; if (next != null && next.waitStatus <= 0) // 下面代码的意思: prev.next = node.next compareAndSetNext(pred, predNext, next); } else { // 4.2 如果 prev == head,说明node是head.next // 则直接唤醒node.next节点 unparkSuccessor(node); } // 将自己指向自己,可以 gc 删除 node.next = node; // help GC } }

__EOF__

本文作者之士咖啡
本文链接https://www.cnblogs.com/zz-1q/p/17840513.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   之士咖啡  阅读(334)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示