JUC并发编程(三):并发编程基石之AQS

1.概述

AQS: AbstractQueuedSynchronizer ,顾名思义,翻译过来叫抽象队列同步器 ,它是JUC并发编程的基石,定义了一套多线程访问共享资源的同步器框架,众多同步类底层都是基于AQS实现的,如常用的ReentrantLock、Semaphore、CountDownLatch等。

AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的先进先出的CLH队列来完成获取资源线程的排队工作,将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改

抢到资源的线程直接使用,处理业务逻辑,抢不到资源的必然涉及一种排队等候机制。抢占资源失败的线程继续去等待(类似银行业务办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),但等候线程仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。

既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node),通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果。

项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用

Github地址github.com/plasticene/...

Gitee地址gitee.com/plasticene3...

微信公众号Shepherd进阶笔记

交流探讨群:Shepherd_126

2.AQS结构

AQS就是一个抽象类,其主要维护一个资源同步状态变量的值(state) 和一个存放排队线程的CLH双向队列 ,同时线程阻塞等待以及被唤醒时锁分配的机制,当state = 0 表示无锁状态,资源不被占有, 当state>0 表示有锁状态,资源被占有。其源码大概如下:

scala 复制代码
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
      private transient volatile Node head;
​
      private transient volatile Node tail;
      /**
       * The synchronization state.
       * 同步状态变量
       */
      private volatile int state;
  
      .......
    }

这里主要展示了同步状态变量state和将请求共享资源的线程封装成队列的结点(Node),只有相关逻辑方法后续会讲到,敬请期待~~

AQS其思想逻辑实现架构图如下:

AQS有一个内部类Node,Node结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。

arduino 复制代码
static final class Node {
    volatile int waitStatus;
​
    volatile Node prev;
​
    volatile Node next;
​
    volatile Thread thread;
​
    Node nextWaiter;
  
    .......
}

这里可以直观地看出该node节点会存储当前被阻塞的请求资源线程,变量waitStatus则表示当前Node结点的等待状态,共有5种取值如下:

  • CANCELLED(1):表示当前结点已取消调度。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
  • SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
  • CONDITION (-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
  • 0:新结点入队时的默认状态。

3.从ReentrantLock入手分析AQS实现源码

ReentrantLock是JUC并发包中一个重要常用的同步器,其底层就是依赖AQS实现的。下面我们将从模拟三个客户(线程)到银行窗口(只有一个窗口)办理业务取号排队等候场景入手,基于ReentrantLock实现该逻辑,其固定流程步骤实现代码如下:

csharp 复制代码
class bank {
    
    // ReentrantLock 可重入锁,默认为非公平锁
    Lock lock = new ReentrantLock();
​
    public void handle(){
        lock.lock(); // 加锁
        try {
            // 业务处理代码
            ......
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); // 解锁
        }
    }
}
​

3.1 加锁占位资源

假设三个客户分别为线程A、线程B、线程C ,线程A执行lock.lock()加锁占位,这时候跳转到ReentrantLock的内部类NonfairSync(NonfairSync继承自AQS)lock()方法:

scss 复制代码
final void lock() {
  if (compareAndSetState(0, 1))
    setExclusiveOwnerThread(Thread.currentThread());
  else
    acquire(1);
}

可以看到先通过CAS设置同步状态state值从0变为1,设置成功,就代表线程A获得资源成功(占有窗口,接下来可以办理业务啦),紧接着线程B、线程C也来办理业务,同样会执行lock.lock()方法尝试加锁占位资源,这里假设线程A一直占有窗口处理业务中,半天处理不完(pg:取款几千万...),state的值为1,与此同时线程B尝试加锁占位资源执行上面代码compareAndSetState(0, 1)失败返回false,就会来到acquire(1)方法,该方法执行效果等于在银行取个号然后去大厅排队等候叫号

acquire()方法是AQS中的方法,这里就是线程加锁占位资源不成功,把线程放到CLH队列等待通知唤醒的核心入口

scss 复制代码
 public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

核心流程步骤如下:

  1. tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而CLH队列中可能还有别的线程在等待);
  2. addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

3.1.1 tryAcquire(int)

此方法定义在AQS中由子类实现尝试获取锁独占资源,如果获取成功,则直接返回true,否则直接返回false

arduino 复制代码
protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

子类NonfairSync实现如下:

arduino 复制代码
protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
java 复制代码
final boolean nonfairTryAcquire(int acquires) {
    // 获取当前线程
    final Thread current = Thread.currentThread();
    // 获取当前同步状态state值
    int c = getState();
    // state=0 代表当前资源没有被锁住,此时当前线程可以尝试加锁占有资源
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // state值不等于0 判断当前线程和持有当前资源线程是不是同一个线程,是,那就是可重入锁逻辑,就累加
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    // 加锁占有资源失败
    return false;
}

按照上面的核心入口代码逻辑,如果tryAcquire()返回false,那么就会执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法

3.1.2 addWaiter(Node)

此方法用于将当前线程加入到等待队列CLH的队尾,并返回当前线程所在的结点

ini 复制代码
private Node addWaiter(Node mode) {
    //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
    Node node = new Node(Thread.currentThread(), mode);
​
    //尝试快速方式直接放到队尾,第一个节点(这里是客户线程B)插入队列,这时候尾节点是空,会跳到下面的enq()入队,自此后面的tail节点不      再为空
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
​
    //上一步失败则通过enq入队。
    enq(node);
    return node;
}
ini 复制代码
private Node enq(final Node node) {
    //CAS"自旋",直到成功加入队尾
    for (;;) {
        Node t = tail;
        if (t == null) { // 队列为空,创建一个空的标志结点(哨兵节点)作为head结点,并将tail也指向它。
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {//正常流程,放入队尾
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

3.1.3 acquireQueued(Node, int)

通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。这时候线程需要被挂起进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。是不是跟银行取号排队等待有点相似~~acquireQueued()就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键

java 复制代码
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;//标记是否成功拿到资源
    try {
        boolean interrupted = false;//标记等待过程中是否被中断过
​
        //又是一个"自旋"!
        for (;;) {
            final Node p = node.predecessor();//拿到前驱
            //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
            if (p == head && tryAcquire(arg)) {
                setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
                p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
                failed = false; // 成功获取资源
                return interrupted;//返回等待过程中是否被中断过
            }
​
            //如果自己可以休息了,就通过park()进入waiting状态,直到被unpark()。如果不可中断的情况下被中断了,那么会从park()中醒过来,发现拿不到资源,从而继续进入park()等待。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
        }
    } finally {
        if (failed) // 如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
            cancelAcquire(node);
    }
}

这里先来看看shouldParkAfterFailedAcquire(),主要是设置当前节点的前驱节点的waitStatusSIGNAL,其意义就是让前驱拿完号后通知自己一下

arduino 复制代码
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;//拿到前驱的状态
    if (ws == Node.SIGNAL)
        //如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
        return true;
    if (ws > 0) {
        /*
         * 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
         * 注意:那些放弃的结点,由于被自己"加塞"到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
         //如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

客户线程B、线程C经过上面操作之后节点状态就如下图所示了:

如果前驱节点的 waitStatus 是 SIGNAL状态,即 shouldParkAfterFailedAcquire 方法会返回 true 程序会继续向下执行 parkAndCheckInterrupt 方法,用于将当前线程挂起:

arduino 复制代码
 private final boolean parkAndCheckInterrupt() {
2     LockSupport.park(this);//调用park()使线程进入waiting状态
3     return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
4 }

以上就是线程B、线程C执行lock.lock()尝试加锁占位失败,进入CLH队列,被挂起,需要等待后面通知唤醒的全部流程。

3.2 解锁释放资源

当线程A处理完业务之后,就会执行lock.unlock()解锁释放资源。又会来到NonfairSync(NonfairSync继承自AQS)unlock()方法:

csharp 复制代码
    public void unlock() {
        sync.release(1);
    }
java 复制代码
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;//找到头结点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);//唤醒等待队列里的下一个线程
        return true;
    }
    return false;
}

这里调用tryRelease(arg)释放资源:该方法也是定义在AQS中,需要子类去实现,典型的模版方法设计模式的应用

arduino 复制代码
 protected boolean tryRelease(int arg) {
        throw new UnsupportedOperationException();
    }
ini 复制代码
protected final boolean tryRelease(int releases) {
    // 同步状态值变更
    int c = getState() - releases;
   // 释放线程和占有线程不是同一线程,报错
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 如果同步状态值减为0,说明当前资源不再被占有,如果不是就是可重入锁逻辑,需要造次被释放
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

如果上面释放资源成功返回true,那么就会执行unparkSuccessor()唤醒等待队列里的下一个线程

ini 复制代码
private void unparkSuccessor(Node node) {
    //这里,node一般为当前线程所在的结点。
    int ws = node.waitStatus;
    if (ws < 0)//置零当前线程所在的结点状态,允许失败。
        compareAndSetWaitStatus(node, ws, 0);
​
    Node s = node.next;//找到下一个需要唤醒的结点s
    if (s == null || s.waitStatus > 0) {//如果为空或已取消
        s = null;
        // 为什么从后向前找,而不是从前往后找?
        // 由于并发问题,addWaiter()入队操作和cancelAcquire()取消排队操作都会造成next链的不一致,而prev链是强一致的,所以这时从后往前找是最安全的。
        for (Node t = tail; t != null && t != node; t = t.prev) // 从后向前找。
            if (t.waitStatus <= 0)//从这里可以看出,<=0的结点,都是还有效的结点。
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);//唤醒
}

这时候线程B被唤醒,又回到上面线程B被挂起在acquireQueued()方法自旋逻辑中执行tryAquire()加锁占位资源

3.3 公平与非公平锁

我们知道ReentrantLock分为公平锁和非公平锁,默认构造方法是非公平锁,如果我们需要构建公平锁,只需要传参true即可:

ini 复制代码
Lock lock = new ReentrantLock(true);

其加锁占有资源和解锁释放资源的流程和上面几乎一样,只是有一些细节区别:

java 复制代码
 static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;
​
        final void lock() {
            acquire(1);
        }
​
        /**
         * Fair version of tryAcquire.  Don't grant access unless
         * recursive call or no waiters or is first.
         */
        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

这里lock()没有和非公平锁的一样先使用CAS尝试加锁占有资源,是直接调用acquire(1)进行入队操作,这很容易理解,公平锁就是要求后面请求资源的线程必须乖乖入队、排队等候,别老想着插队~~,其次就是这个tryAcquire()方法尝试加锁占有资源逻辑中多了一个hasQueuedPredecessors()判断逻辑:

ini 复制代码
 public final boolean hasQueuedPredecessors() {
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

如果在当前线程之前有一个排队的线程,返回true,此时就不再尝试加锁占有资源,因为队列前面还有线程,只能乖乖等着;如果当前线程在队列的头或队列为空,返回false,就可以尝试加锁占有资源啦。

到此,基于以上全部内容,我们把AQS的思想原理和基于ReentrantLock同步器源码实现梳理了一遍。

相关推荐
也无晴也无风雨34 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
憨子周36 分钟前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
霖雨2 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404192 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java
无尽的大道3 小时前
Java 泛型详解:参数化类型的强大之处
java·开发语言
ZIM学编程3 小时前
Java基础Day-Sixteen
java·开发语言·windows
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
P.H. Infinity3 小时前
【RabbitMQ】07-业务幂等处理
java·rabbitmq·java-rabbitmq
爱吃土豆的程序员3 小时前
java XMLStreamConstants.CDATA 无法识别 <![CDATA[]]>
xml·java·cdata