AQS的设计思想,少谈源码,只谈思想

1,690 阅读12分钟

1. 前言

废话不多说,直入主题。

在JUC(java.lang.concurrent包)中,有成吨的好用的并发控制工具,能实现不同的功能,帮助我们解决不同程度的问题。比如:

  • 重入锁:java.lang.concurrent.locks.ReentrantLock,功能类似synchronized,更加灵活
  • 读写锁:java.lang.concurrent.locks.ReentrantReadWriteLock,支持读写锁分离,读锁为共享锁,支持不同线程同时持有锁资源,写锁与其他写锁和读锁都互斥,属于独占资源,可解决“读时共享不阻塞,写时阻塞更新”的功能。
  • CountDownLatch:多线程倒计数阻塞器,支持主线程等待所有子线程完成某一项任务并倒计数后,主线程再从阻塞状态唤醒执行后续业务,适用于主线程等待获取多个子线程执行结果后的应用场景
  • Semaphore:信号量控制器,支持指定信号量为N后,同一时间只允许有不超过N的线程获得执行权限,后续线程无法获得执行权限。

这些工具能够实现不同的各种各样的功能。虽然在功能和实现上都不一样,但他们的能力都源于一处,那就是老生常谈的AQS。

所谓万变不离其宗,水之呼吸也好,风之呼吸也好,本质上都是日志呼吸。

1.1 AQS是个什么东西呢

AQS其实是我们对它的一个简称,人家全名叫做:AbstractQueuedSynchronizer,翻译过来就是抽象的基于队列的同步器

它提供的功能,从字面意思上就能够略窥一二,无非就是用来实现锁嘛,多线程竞争嘛,多线程来争抢资源时,只允许一个线程获得权限,没获得权限的就阻塞,获得了锁的线程释放锁之后,就唤醒被阻塞的线程,仅此而已。

那如何实现呢?基于队列呗,人家都说了,是基于基于队列的同步器。但是光看这个名字还是挺抽象的,不过话都说到这了,大家作为有经验的开发人员,我们理解和学习一个知识的时候,最好要先去学会猜测,然后通过看文档和源码来一步步印证自己的猜想,比如,在你不知道有AQS的情况下,假如你是Duag Lee,你将如何设计呢?

面试中,也经常被面试官问到这个问题,aqs不管在日常开发工作中,还是在决定我们面试是否通过的层面,都是极为重要的。

2. 思考如果让你来设计aqs的核心功能,你将如何设计

如果是我,我肯定想,都基于队列了,那队列是干嘛的,用来排队的呗,排啥队呢?那必然是没有抢到锁的要排队等待呗,所以这个队列,就是为了给没有抢到锁的去等待的。

假如有一段代码,业务上可能存在多线程并行调用的场景,此时有5个请求,每个请求是一个独立的线程,此时都来调这段代码,但在调用之前,需要先争抢同步锁,毕竟这段代码要保证线程安全,只能同时被一个线程调用,所以它必须满足以下特征或条件:

  1. 首先抢到锁的获得执行权,返回true
  2. 后续没有抢到的,不管多少线程,统统排队等待
  3. 抢到锁的释放锁之后,依次唤醒其它处于等待队列的线程
  4. 将上述步骤一直循环

好了,看上去能够满足我们的需求了,再画个图简单表示一下:

image.png

差不多就是这个意思。

但这还不够,里面其实有很多可以优化的点,或者说需要解决的问题,比如:

  • 在AQS还没有现成占用时,刚开始的并发竞争如何解决
  • 线程的阻塞和唤醒如何实现
  • 处于同步队列中的线程的唤醒策略是什么,结构如何实现,要有哪些元素
  • 获得AQS执行权限的线程在释放之前想要等待如何实现
  • ...

似乎这并不是一件容易的事情,上面我们自行推测并实现的看来是一个只有部分核心功能的,充满bug的版本,当然,一款好用的工具并不是一朝一夕就实现的出来的,好在我们有了一个初步的猜想,有了这个基础,接下来可以去研究一下真正的AQS是如何实现的

2.1 一开始如何抢占资源呢

有经验的开发者很容易就能想到使用CAS,其实使用什么并不重要,重要的是思想,无非就是要保证原子性嘛,多个线程都同时去抢占资源,只要在一个线程操作时,阻塞其它线程就可以了,所以CAS是最佳选择。

思想确定了,那么实现是如何做的呢?

先直接看看AQS的实现吧:

// 初始化UNSAFE对象
private static final Unsafe U = Unsafe.getUnsafe();
// 获取字段在直接内存中相对当前对象的偏移量
private static final long STATE
    = U.objectFieldOffset(AbstractQueuedSynchronizer.class, "state");
// 基于CAS修改state变量
protected final boolean compareAndSetState(int expect, int update) {
    return U.compareAndSetInt(this, STATE, expect, update);
}

没错,肯定得是CAS,这里也直接用到了Java中的UNSAFE类来进行cas操作,目的就是能够基于当前对象,在直接内存中基于相对偏移量去修改数据。

但是,只有这一种方式吗,AQS中是基于state变量cas修改去处理刚开始的并发安全问题的,AQS里面在成功修改了state变量后,还会将当前抢占到资源的线程,设置到exclusiveOwnerThread中

protected final void setExclusiveOwnerThread(Thread thread) {
    exclusiveOwnerThread = thread;
}

那,能不能不要state变量,直接基于CAS去设置这个exclusiveOwnerThread呢?比如,利用Java中的原子类AtomicReference来进行实现

image.png 伪代码如下:

public class AtomicReferenceTest {

    private AtomicReference<Thread> ar = new AtomicReference<>();

    public void atomicReference() {
        if(ar.compareAndSet(null, Thread.currentThread())) {
            // do your business
        } else {
            // 失败,加入同步队列
        }
    }
}

这样能够满足我们最原始的需求,那为什么AQS还要用state呢,自然有他的用处,因为AQS是一个基础的同步器工具,很多上层工具比如ReentrantLock,ReentrantReadWriteLock等都是基于它来实现的,需要分别用到state和exclusiveOwnerThread,这里不进行展开,只是为了拓展大家的思考方式,要思考为什么要这样做,如果用另外的方式做是否可以。

2.2 抢占失败如何进行阻塞等待呢

首先要思考,为什么抢占失败的要基于队列来阻塞呢?不基于队列,直接阻塞行不行,当然有效果,但是不要忘了,我们除了阻塞以外,抢到执行权的线程之外完业务代码,释放资源后,要去唤醒,那我去哪里找被阻塞的这些线程呢?在一个池子里找吗?

image.png

那就得维护一个池子,最合理的选择就是一个Set集合,但是我要唤醒哪一个呢,看来只能随机了。不是不行,但仔细想想,好像有失公平,如果我一个比较重要的业务线程第一个被阻塞,但由于后面不断有现成进来被阻塞,由于随机算法的阻碍,我一开始那个重要的线程迟迟无法被唤醒,岂不是误了大事。

其实在生活中也是,很多人去窗口办事,办事处刚开门的时候,肯定要迎来一波疯抢,但一旦第一个人抢到了位置开始办事了,后面的人就只能等着,那怎么等呢,不用说,肯定是排队,先来先排,所以程序的逻辑设计也是源于生活。

image.png

所以就有了我们的同步队列,这样我在获取到资源的线程释放资源后,就能够根据这个队列,来进行优先级控制了,丝毫不混乱。

AQS中,是通过一个Node对象来封装,然后组成一个双向链表完成的同步队列,同时存在head头结点和tail尾节点,AQS只需要关注这两个节点,并随着阻塞线程唤醒动态调整head和tail的指向即可轻松完成调度任务

image.png

由于本文大多数只谈思想,这里就不单独贴源码了,贴了也是占篇幅,没啥实际意义

2.3 如何实现线程通信唤醒阻塞中的线程呢

前面提到,链表是基于Node节点来封装的,链表的指向实际上是Node对象。

Node对象定义如下:

abstract static class Node {
    volatile Node prev;       // initially attached via casTail
    volatile Node next;       // visibly nonnull when signallable
    Thread waiter;            // visibly nonnull when enqueued
    volatile int status;      // written by owner, atomic bit ops by others

    // methods for atomic operations
    final boolean casPrev(Node c, Node v) {  // for cleanQueue
        return U.weakCompareAndSetReference(this, PREV, c, v);
    }
    final boolean casNext(Node c, Node v) {  // for cleanQueue
        return U.weakCompareAndSetReference(this, NEXT, c, v);
    }
    final int getAndUnsetStatus(int v) {     // for signalling
        return U.getAndBitwiseAndInt(this, STATUS, ~v);
    }
    final void setPrevRelaxed(Node p) {      // for off-queue assignment
        U.putReference(this, PREV, p);
    }
    final void setStatusRelaxed(int s) {     // for off-queue assignment
        U.putInt(this, STATUS, s);
    }
    final void clearStatus() {               // for reducing unneeded signals
        U.putIntOpaque(this, STATUS, 0);
    }

    private static final long STATUS
        = U.objectFieldOffset(Node.class, "status");
    private static final long NEXT
        = U.objectFieldOffset(Node.class, "next");
    private static final long PREV
        = U.objectFieldOffset(Node.class, "prev");
}

里面除了prev和next变量来完成双向链表的功能以外,还有一个waiter变量,这是一个Thread对象,也就是说,获得资源的线程能够基于head或者tail获取Node中的Thread,既然拿到了Thread,自然就能够轻易的通过api去唤醒和阻塞了

通过LockSupport类的park实现阻塞,unpark实现唤醒,本质还是基于UNSAFE类来进行操作,不贴源码水文章了,有兴趣自行看源码

2.4 被唤醒的线程如何继续抢占呢

首先要思考一个问题,唤醒的线程再去抢占,还需要经过CAS吗,有必要吗?

乍一看,我基于队列依次唤醒,每次只唤醒一个,是不是就直接给资源了,不用再重新去CAS了呢?好像是,但是真的是这样吗?有没有啥问题呢?好像没问题,真的没问题吗,暂时想不到有啥问题,那不如先来看源码

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        signalNext(head);
        return true;
    }
    return false;
}

private static void signalNext(Node h) {
    Node s;
    if (h != null && (s = h.next) != null && s.status != 0) {
        s.getAndUnsetStatus(WAITING);
        LockSupport.unpark(s.waiter);
    }
}

哦!没想到真的就是这样,真的不需要再去CAS了,就是基于head去遍历,依次唤醒,但!唤醒之后就是直接抢占到资源了吗?再来看,要看一开始这些鲜橙阻塞到哪里,被唤醒后才会从下面的地方开始执行逻辑

final int acquire(Node node, int arg, boolean shared,
                  boolean interruptible, boolean timed, long time) {
    Thread current = Thread.currentThread();
    byte spins = 0, postSpins = 0;   // retries upon unpark of first thread
    boolean interrupted = false, first = false;
    Node pred = null;                // predecessor of node when enqueued

    /*
     * Repeatedly:
     *  Check if node now first
     *    if so, ensure head stable, else ensure valid predecessor
     *  if node is first or not yet enqueued, try acquiring
     *  else if node not yet created, create it
     *  else if not yet enqueued, try once to enqueue
     *  else if woken from park, retry (up to postSpins times)
     *  else if WAITING status not set, set and retry
     *  else park and clear WAITING status, and check cancellation
     */

    for (;;) {
        if (!first && (pred = (node == null) ? null : node.prev) != null &&
            !(first = (head == pred))) {
            if (pred.status < 0) {
                cleanQueue();           // predecessor cancelled
                continue;
            } else if (pred.prev == null) {
                Thread.onSpinWait();    // ensure serialization
                continue;
            }
        }
        if (first || pred == null) {
            boolean acquired;
            try {
                if (shared)
                    acquired = (tryAcquireShared(arg) >= 0);
                else
                    acquired = tryAcquire(arg);
            } catch (Throwable ex) {
                cancelAcquire(node, interrupted, false);
                throw ex;
            }
            if (acquired) {
                if (first) {
                    node.prev = null;
                    head = node;
                    pred.next = null;
                    node.waiter = null;
                    if (shared)
                        signalNextIfShared(node);
                    if (interrupted)
                        current.interrupt();
                }
                return 1;
            }
        }
        if (node == null) {                 // allocate; retry before enqueue
            if (shared)
                node = new SharedNode();
            else
                node = new ExclusiveNode();
        } else if (pred == null) {          // try to enqueue
            node.waiter = current;
            Node t = tail;
            node.setPrevRelaxed(t);         // avoid unnecessary fence
            if (t == null)
                tryInitializeHead();
            else if (!casTail(t, node))
                node.setPrevRelaxed(null);  // back out
            else
                t.next = node;
        } else if (first && spins != 0) {
            --spins;                        // reduce unfairness on rewaits
            Thread.onSpinWait();
        } else if (node.status == 0) {
            node.status = WAITING;          // enable signal and recheck
        } else {
            long nanos;
            spins = postSpins = (byte)((postSpins << 1) | 1);
            if (!timed)
                LockSupport.park(this);
            else if ((nanos = time - System.nanoTime()) > 0L)
                LockSupport.parkNanos(this, nanos);
            else
                break;
            node.clearStatus();
            if ((interrupted |= Thread.interrupted()) && interruptible)
                break;
        }
    }
    return cancelAcquire(node, interrupted, interruptible);
}

这段代码有点长,但是是AQS中获取资源的核心代码,其中有一个LockSupport.park方法,这个就是阻塞这些线程的地方,所以被唤醒的线程将要从LockSupport.park方法下方开始再执行,也就是说会再去抢占资源,因为外层是一个for自旋逻辑。

再次抢占,需要去检测AQS中的exclusiveOwnerThread是否与当前线程一致,看看state是否为0等,否则将再次进入同步队列等待。

2.5 假如抢占到锁的线程暂时需要等待如何实现呢

对标jdk中synchronized关键字中的wait,AQS中也有一个wait的功能,也就是抢占到资源的线程,在真正地释放资源之前,如果需要临时让出资源,则能够基于另外一个Condition条件队列进行等待。

来看一下AQS中的await函数源码

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    ConditionNode node = new ConditionNode();
    int savedState = enableWait(node);
    LockSupport.setCurrentBlocker(this); // for back-compatibility
    boolean interrupted = false, cancelled = false, rejected = false;
    while (!canReacquire(node)) {
        if (interrupted |= Thread.interrupted()) {
            if (cancelled = (node.getAndUnsetStatus(COND) & COND) != 0)
                break;              // else interrupted after signal
        } else if ((node.status & COND) != 0) {
            try {
                if (rejected)
                    node.block();
                else
                    ForkJoinPool.managedBlock(node);
            } catch (RejectedExecutionException ex) {
                rejected = true;
            } catch (InterruptedException ie) {
                interrupted = true;
            }
        } else
            Thread.onSpinWait();    // awoke while enqueuing
    }
    LockSupport.setCurrentBlocker(null);
    node.clearStatus();
    acquire(node, savedState, false, false, false, 0L);
    if (interrupted) {
        if (cancelled) {
            unlinkCancelledWaiters(node);
            throw new InterruptedException();
        }
        Thread.currentThread().interrupt();
    }
}

源码解释起来比较枯燥,无非就是,在调用await函数等待时,进行一波校验,看当前线程是否是获取到资源的线程,是否在同步等待队列中,然后仍然通过LockSupport.park阻塞当前线程,并添加到Condition队列中,大概就是像下面这幅图一样

image.png

图中上面是同步队列,下面是Condition队列,这是一个单向链表。

当然,有阻塞就有唤醒,从Condition队列中唤醒的函数,是AQS中的signal函数,对标synchronized中notify函数,当然synchronized中还有个notifyAll,所以AQS中也有一个signalAll

public final void signal() {
    ConditionNode first = firstWaiter;
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    if (first != null)
        doSignal(first, false);
}

public final void signalAll() {
    ConditionNode first = firstWaiter;
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    if (first != null)
        doSignal(first, true);
}

private void doSignal(ConditionNode first, boolean all) {
    while (first != null) {
        ConditionNode next = first.nextWaiter;
        if ((firstWaiter = next) == null)
            lastWaiter = null;
        if ((first.getAndUnsetStatus(COND) & COND) != 0) {
            enqueue(first);
            if (!all)
                break;
        }
        first = next;
    }
}

源码也很容易看懂,无非就是校验一波线程是否匹配,然后从Condition队列中依次唤醒或全部唤醒

3. 总结

AQS是Java并发控制工具中的最核心抽象类,只有懂的其中的实现原理,才能更好的面试和开发,解决遇到的问题。

本文旨在帮助大家理解AQS的核心设计思想,以及为什么要这样去设计。我们在理解掌握一项技术的时候,初学者是学习使用方法,进阶者会去理解其中的原理,高手会去思考为什么要这样去写去设计,并结合自身的经验去理解,融入自己的东西,变成自己的知识。