AQS介绍和原理分析(上)

129 阅读11分钟

强烈建议先读一下参考文章,基本上说的很清楚了。

Lock模型

Nuam0U.png 一些前置知识:

  1. unsafe提供的两个关键机制
    • CAS,CPU提供的原子指令
    • park/unpark, 调用这个native函数会将线程调度和停止调度
  2. unsafe不能被直接调用,可被虚拟机代码调用-> LockSupport封装了这两个机制。
  3. AbstractQueueSychronizer中间类,继承这个类可以实现各种EOF场景(EOF- 异常控制流程)

AQS(AbstractQuenedSynchronizer抽象队列式同步器)

  • 核心思想&基本框架

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS维护了一个volatile语义(支持多线程下的可见性)的共享资源变量state和一个FIFO线程等待队列CLH(多线程竞争state被阻塞时会进入此队列)。

NuwNJH.png

理解AbstractQueueSychronizer的关键 - 数据结构,使用的最简单的双向链表 - 操作系统底层,就是上面介绍的LockSupport封装 - 控制逻辑,主要是通过state和Node中的waitState控制的

state

共享资源变量state,三种访问方式:

  • getState()
  • setState(int newState)
  • compareAndSetState(int expect, int update)

资源共享的方式,两种:

  • 独占式(Exclusive) 只有单个线程能够成功获取资源并执行,如ReentrantLock。
  • 共享式(Shared) 多个线程可成功获取资源并执行,如Semaphore/CountDownLatch等。

Node节点 - CLH(Craig, Landin, and Hagersten locks)

CLH锁其实就是一种是基于逻辑队列非线程饥饿的一种自旋公平锁(基于单向链表(隐式创建)的高性能、公平的自旋锁),由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。 AQS内部的FIFO线程等待队列,通过内部类Node来实现

static final class Node {

    // 表明节点在共享模式下等待的标记
    static final Node SHARED = new Node();
    // 表明节点在独占模式下等待的标记
    static final Node EXCLUSIVE = null;

    // 表征等待线程已取消的
    static final int CANCELLED =  1;
    // 表征需要唤醒后续线程
    static final int SIGNAL    = -1;
    // 表征线程正在等待触发条件(condition)
    static final int CONDITION = -2;
    // 表征下一个acquireShared应无条件传播
    static final int PROPAGATE = -3;

    /**
     *   SIGNAL: 当前节点释放state或者取消后,将通知后续节点竞争state。
     *   CANCELLED: 线程因timeout和interrupt而放弃竞争state,当前节点将与state彻底拜拜
     *   CONDITION: 表征当前节点处于条件队列中,它将不能用作同步队列节点,直到其waitStatus被重置为0
     *   PROPAGATE: 表征下一个acquireShared应无条件传播
     *   0: None of the above
     */
    volatile int waitStatus;

    // 前继节点
    volatile Node prev;
    // 后继节点
    volatile Node next;
    // 持有的线程
    volatile Thread thread;
    // 链接下一个等待条件触发的节点
    Node nextWaiter;

    // 返回节点是否处于Shared状态下
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    // 返回前继节点
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    // Shared模式下的Node构造函数
    Node() {  
    }

    // 用于addWaiter
    Node(Thread thread, Node mode) {  
        this.nextWaiter = mode;
        this.thread = thread;
    }

    // 用于Condition
    Node(Thread thread, int waitStatus) {
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

题外话:来做个手动实现锁的小练习吧 简单的非公平自旋锁以及基于排队的公平自旋锁的实现 blog.csdn.net/dm_vincent/… CLH锁的原理和实现 blog.csdn.net/dm_vincent/…

实现

下面通过AbstractQuenedSynchronizer(同步器)和ReentrantLock(锁)来详细说明一下AQS的原理。除了共享和独占的特点外,重点关注它的几个特性:

  • 公平和非公平
  • 超时可中断
  • 条件中断

API

同步器是实现锁的关键,利用同步器将锁的语义实现,然后在锁的实现中聚合同步器。 可以这样理解:

  • 锁的API是面向使用者的,它定义了与锁交互的公共行为,而每个锁需要完成特定的操作也是透过这些行为来完成的(比如:可以允许两个线程进行加锁,排除两个以上的线程),但是实现是依托给同步器来完成
  • 同步器面向的是线程访问和资源控制,它定义了线程对资源是否能够获取以及线程的排队等操作。

锁和同步器很好的隔离了二者所需要关注的领域,严格意义上讲,同步器可以适用于除了锁以外的其他同步设施上(包括锁)

AQS内部定义(实现)的方法

独占式

  • acquire(int)
  • acquireInterruptibly(int)
  • tryAcquireNanos(int,long)
  • release(int)

共享式

  • acquireShared(int)
  • acquireSharedInterruptibly(int)
  • tryAcquireSharedNanos(int,long)
  • releaseShared(int)

需要继承的锁自定义实现的方法

  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

AQS需要子类复写的方法均没有声明为abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。当然,AQS也支持子类同时实现独占和共享两种模式,如ReentrantReadWriteLock。

另外可以看到,一般try开头的都是需要锁实现的,但是tryAcquireNanos方法例外,它的作用使用实现可超时中断的锁

源码分析

独占锁的实现

/**
 * Acquires in exclusive mode, ignoring interrupts.  Implemented
 * by invoking at least once {@link #tryAcquire},
 * returning on success.  Otherwise the thread is queued, possibly
 * repeatedly blocking and unblocking, invoking {@link
 * #tryAcquire} until success.  This method can be used
 * to implement method {@link Lock#lock}.
 *
 * @param arg the acquire argument.  This value is conveyed to
 *        {@link #tryAcquire} but is otherwise uninterpreted and
 *        can represent anything you like.
 */
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

上述逻辑主要包括:

  1. 尝试获取(调用tryAcquire更改状态,需要保证原子性); 在tryAcquire方法中使用了同步器提供的对state操作的方法,利用compareAndSet保证只有一个线程能够对状态进行成功修改,而没有成功修改的线程将进入sync队列排队。
  2. 如果获取不到,将当前线程构造成节点Node并加入sync队列; 进入队列的每个线程都是一个节点Node,从而形成了一个双向队列,类似CLH队列,这样做的目的是线程间的通信会被限制在较小规模(也就是两个节点左右)。
  3. 再次尝试获取(acquireQueued),如果没有获取到那么将当前线程从线程调度器上摘下,进入等待状态。

tryAcquire在ReentrantLock中的实现是在FairSync中

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;
}
//对于非公平锁也一样
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

addWaiter

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
private final boolean compareAndSetHead(Node update) {
    return unsafe.compareAndSwapObject(this, headOffset, null, update);
}
private final boolean compareAndSetTail(Node expect, Node update) {
    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

上述逻辑主要包括:

  1. 使用当前线程构造Node; 对于一个节点需要做的是将当节点前驱节点指向尾节点(current.prev = tail),尾节点指向它(tail = current),原有的尾节点的后继节点指向它(t.next = current)。
  2. 先行尝试在队尾添加; 如果尾节点已经有了,然后做如下操作: (1)分配引用T指向尾节点; (2)将节点的前驱节点更新为尾节点(current.prev = tail); (3)如果尾节点是T,那么将当尾节点设置为该节点(tail = current,原子更新); (4)T的后继节点指向当前节点(T.next = current)。 注意第3点是要求原子的。 这样可以以最短路径O(1)的效果来完成线程入队,是最大化减少开销的一种方式。
  3. 如果队尾添加失败或者是第一个入队的节点。 如果是第1个节点,也就是sync队列没有初始化,那么会进入到enq这个方法,进入的线程可能有多个,或者说在addWaiter中没有成功入队的线程都将进入enq这个方法。 可以看到enq的逻辑是确保进入的Node都会有机会顺序的添加到sync队列中,而加入的步骤如下: (1)如果尾节点为空,那么原子化的分配一个头节点,并将尾节点指向头节点,这一步是初始化; (2)然后是重复在addWaiter中做的工作,但是在一个while(true)的循环中,直到当前节点入队为止。 进入sync队列之后,接下来就是要进行锁的获取,或者说是访问控制了,只有一个线程能够在同一时刻继续的运行,而其他的进入等待状态。而每个线程都是一个独立的个体,它们自省的观察,当条件满足的时候(自己的前驱是头结点并且原子性的获取了状态),那么这个线程能够继续运行。
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            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);
    }
}


private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         */
        return true;
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

上述逻辑主要包括:

  1. 获取当前节点的前驱节点; 需要获取当前节点的前驱节点,而头结点所对应的含义是当前站有锁且正在运行。
  2. 当前驱节点是头结点并且能够获取状态,代表该当前节点占有锁; 如果满足上述条件,那么代表能够占有锁,根据节点对锁占有的含义,设置头结点为当前节点。
  3. 否则进入等待状态。 如果没有轮到当前节点运行,那么将当前线程从线程调度器上摘下,也就是进入等待状态。

小总结

  1. 状态的维护; 需要在锁定时,需要维护一个状态(int类型),而对状态的操作是原子和非阻塞的,通过同步器提供的对状态访问的方法对状态进行操纵,并且利用compareAndSet来确保原子性的修改。
  2. 状态的获取; 一旦成功的修改了状态,当前线程或者说节点,就被设置为头节点。
  3. sync队列的维护。 在获取资源未果的过程中条件不符合的情况下(不该自己,前驱节点不是头节点或者没有获取到资源)进入睡眠状态,停止线程调度器对当前节点线程的调度。 这时引入的一个释放的问题,也就是说使睡眠中的Node或者说线程获得通知的关键,就是前驱节点的通知,而这一个过程就是释放,释放会通知它的后继节点从睡眠中返回准备运行。 GDatbq5HVFS42oL

锁的释放

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

上述逻辑主要包括:

  1. 尝试释放状态; tryRelease能够保证原子化的将状态设置回去,当然需要使用compareAndSet来保证。如果释放状态成功过之后,将会进入后继节点的唤醒过程。
  2. 唤醒当前节点的后继节点所包含的线程。

tryRelease在ReentranLock-Sync中的实现

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

通过LockSupport的unpark方法将休眠中的线程唤醒,让其继续acquire状态。

private void unparkSuccessor(Node node) {
  // 将状态设置为同步状态
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);


    // 获取当前节点的后继节点,如果满足状态,那么进行唤醒操作 
 // 如果没有满足状态,从尾部开始找寻符合要求的节点并将其唤醒 
    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)
        LockSupport.unpark(s.thread);
}

上述逻辑主要包括,该方法取出了当前节点的next引用,然后对其线程(Node)进行了唤醒,这时就只有一个或合理个数的线程被唤醒,被唤醒的线程继续进行对资源的获取与争夺。 回顾整个资源的获取和释放过程: 在获取时,维护了一个sync队列,每个节点都是一个线程在进行自旋,而依据就是自己是否是首节点的后继并且能够获取资源; 在释放时,仅仅需要将资源还回去,然后通知一下后继节点并将其唤醒。 这里需要注意,队列的维护(首节点的更换)是依靠消费者(获取时)来完成的,也就是说在满足了自旋退出的条件时的一刻,这个节点就会被设置成为首节点

共享锁

简单来讲,读锁和读锁是可以共享的,其它情况,读锁和写锁,写锁和读锁、写锁和写锁,都是互斥的。

public final void acquireShared(int arg) {

    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

这里tryAcquireShared()依然需要自定义同步器去实现。但是AQS已经把其返回值的语义定义好了:负值代表获取失败;0代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里acquireShared()的流程就是:

  • tryAcquireShared()尝试获取资源,成功则直接返回;

  • 失败则通过doAcquireShared()进入等待队列,直到获取到资源为止才返回。

private void doAcquireShared(int arg) {
    final Node node = addWaiter(Node.SHARED);//加入队列尾部
    boolean failed = true;//是否成功标志
    try {
        boolean interrupted = false;//等待过程中是否被中断过的标志
        for (;;) {
            final Node p = node.predecessor();//前驱
            if (p == head) {//如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的
                int r = tryAcquireShared(arg);//尝试获取资源
                if (r >= 0) {//成功
                    setHeadAndPropagate(node, r);//将head指向自己,还有剩余资源可以再唤醒之后的线程
                    p.next = null; // help GC
                    if (interrupted)//如果等待过程中被打断过,此时将中断补上。
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }


            //判断状态,寻找安全点,进入waiting状态,等着被unpark()或interrupt()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}


private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    setHead(node);//head指向自己
     //如果还有剩余量,继续唤醒下一个邻居线程
    if (propagate > 0 || h == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

跟acquireQueued()很相似,流程并没有太大区别。只不过这里将补中断的selfInterrupt()放到doAcquireShared()里了,而独占模式是放到acquireQueued()之外.

跟独占模式比,还有一点需要注意的是,这里只有线程是head.next时(“老二”),才会去尝试获取资源,有剩余的话还会唤醒之后的队友。,假如老大用完后释放了5个资源,而老二需要6个,老三需要1个,老四需要2个。老大先唤醒老二,老二一看资源不够,他是把资源让给老三呢,还是不让?答案是否定的!老二会继续park()等待其他线程释放资源,也更不会去唤醒老三和老四了。独占模式,同一时刻只有一个线程去执行,这样做未尝不可;但共享模式下,多个线程是可以同时执行的,现在因为老二的资源需求量大,而把后面量小的老三和老四也都卡住了。当然,这并不是问题,只是AQS保证严格按照入队顺序唤醒罢了(保证公平,但降低了并发)。

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
private void doReleaseShared() {
    /*
     * Ensure that a release propagates, even if there are other
     * in-progress acquires/releases.  This proceeds in the usual
     * way of trying to unparkSuccessor of head if it needs
     * signal. But if it does not, status is set to PROPAGATE to
     * ensure that upon release, propagation continues.
     * Additionally, we must loop in case a new node is added
     * while we are doing this. Also, unlike other uses of
     * unparkSuccessor, we need to know if CAS to reset status
     * fails, if so rechecking.
     */
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

翻译一下注释:

确保发布传播,即使有其他正在进行中的获取/发布。如果需要信号,这将以通常的方式试图打开头部的后继者。但如果没有,则状态设置为PROPAGATE,以确保在发布时继续传播。此外,我们必须进行循环,以防在此过程中添加了新节点。此外,与unpark后继的其他用法不同,我们需要知道CAS重置状态是否失败,如果失败就要重新检查。

就是说通过循环等待的方式设置头结点,如果是SIGNAL待唤醒的情况,则唤醒,如果是ws=0的状态,则设置为PROPAGATE,让acquiredShared的时候可以继续传播。

跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。而ReentrantReadWriteLock读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值。

小总结

未完待续

  • 可中断(public final void acquireInterruptibly(int arg))
  • 超时控制 (private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException)
  • 公平锁
  • 条件中断

参考

www.cnblogs.com/waterystone…

ifeve.com/introduce-a…