并发编程:AQS 源码分析

AQS

  • AQS 简介
  • state 变量
  • CLH同步队列
  • 独占式
  • 共享式
  • 自定义同步组件

AQS 简介

在Java并发包中很多锁都是通过AQS来实现加锁和释放锁的过程的,AQS就是并发包基础。

AQS 的全称 AbstractQueuedSynchronizers抽象队列同步器。

AQS的设计模式采用的模板方法模式,子类通过继承的方式,实现它的抽象方法来管理同步状态,对于子类而言它并没有太多的活要做,AQS提供了大量的模板方法来实现同步。

在AQS 中的模板方法如:

  • acquire(int arg)
  • release(int arg)
  • acquireShared(int arg)
  • releaseShared(int arg)

这些方法都接收一个整形参数,其实这个参数就是我们下面要讲的 state 变量。并且在这些模板方法中会调用类似 tryAcquire( ) 这种由子类继承AQS,去自定义的方法去做自己的逻辑。而把对于CLH同步队列中的操作(出队、入队、节点唤醒等)全都封装在acquire()这种模板方法中。

看看acquire( ) 方法感受一下

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&  // 这里的 tryAcquire() 由子类自定义
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

源码中 tryAcquire( ) 没有用abstract 修饰符,强制子类继承,是因为这些自定义方法有两种类型为了让子类灵活重写这些方法,所以没有用 abstract 。

  • 独占式
  • 分享式

有些锁的应用场景只需要独占式如:ReentrantLock

有些锁的应用场景只需要共享式如:CountdownLatch

也有些既需要独占式也需要共享式:ReentrantReadWriteLock

state变量

在AQS中维持了一个单一的共享状态state,来实现同步器同步。state 其实可以理解为你需要控制的并发资源数量。通过这个state 变量,子类在使用模板方法的时候传入这个值比如 acquire(state) ,在 tryAcquire(int arg) 中接收到这个值对其相应的 state 增减,来达到自定义控制并发量的作用。在最后的自己实现自定义同步组件中可以清楚的感受到这一点。

看一下 state 源码,很接地气:

    private volatile int state;

    protected final int getState() {
        return state;
    }

    protected final void setState(int newState) {
        state = newState;
    }

    protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
  • state用volatile修饰,保证多线程中的可见性。
  • compareAndSetState()方法采用乐观锁思想的CAS算法保证线程安全,采用final修饰的,不允许子类重写。

CLH

​ 有了state变量可以对线程能否立即获得同步状态进行控制,比如我当前的 state 为 3,只要 state >= 0 线程就可以获得同步状态相当于拿到了锁进入同步代码块,4个线程都进去之后,state = -1 ,第五个线程进不去了,那该怎么办?

​ 找个地方给它存起来,等同步代码块里的线程出来再把它唤醒不就行了。CLH同步队列就是这个存放线程的地方,为了实现高效,这里面做了很多的事情。这也是为什么需要使用模板方法模式在 acquie( ) 这些方法中将这个队列的操作封装起来,让依赖AQS的工具类可以不用关注CLH的相关操作。

并发编程:AQS 源码分析_第1张图片

CLH(Craig, Landin, and Hagersten locks) 同步队列 是一个FIFO双向队列,其内部通过节点head和tail记录队首和队尾元素,队列元素的类型为Node。

CLH同步队列结构图:

并发编程:AQS 源码分析_第2张图片

看一下Node的源码和 head、 tail 指针:

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;
  
        static final int CONDITION = -2;
    
        static final int PROPAGATE = -3;
        
        volatile int waitStatus;

        volatile Node prev;

        volatile Node next;

        volatile Thread thread;

        Node nextWaiter;

        final boolean isShared() {
            return nextWaiter == SHARED;
        }
  
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }
    }

 		private transient volatile Node head;

    private transient volatile Node tail;

CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),condition队列的后续节点(nextWaiter)。

waitStatus 为当前节点的状态:

  • Signal = -1 当前节点被唤醒后,要通过 unpark 解除后继节点的 park 阻塞状态
  • Canceled = 1 当前节点因为超时或者中断被取消了
  • Condition = -2 当前节点在Conditon 队列中
  • Propagate = -3 共享模式的头结点可能处于这种状态,往下传播,唤醒后续的共享节点
  • 0 初始状态

注意这里只有 Canceled状态 waitStatus > 0 ,其余都是小于0,在对这些节点的操作中,会用到这个条件判断。

这个CLH 队列为什么可以叫同步队列,线程安全体现在哪?它对于并发下高效的地方在哪?

当前线程获取同步状态失败后,会构造为一个节点并尝试加入到队列尾部。因为可能存在多个线程同时获取失败需要加入到队列中,所以这一步需要cas操作 compareAndSetTail,并且不断自旋,直到加入成功。而这种队列的先后次序就决定了线程之间的时间顺序,如果只有一个线程可以获得同步状态,那么让首节点就是获取同步状态成功的节点,当首节点释放同步状态时,直接唤醒后继节点,不会存在线程竞争的情况,因为只有一个后继节点,后继节点获取同步状态把自己设置为头节点 setHead 不需要cas。断开原头节点的连接(让gc处理)。

上述在独占式同步状态获取与释放中体现了。

    private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
    }

    private final boolean compareAndSetTail(Node expect, Node update) {
        return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
    }

这里的setHead( ) 在 acquireQueued()中体现了。compareAndSetTail( ) 在 addWaiter()中体现了。

什么时候需要 compareAndSetHead( ) ?

  private final boolean compareAndSetHead(Node update) {
        return unsafe.compareAndSwapObject(this, headOffset, null, update);
    }

当需要初始化CLH的时候,这时候 tail == null ,可能会有多个线程同时初始化CLH的 头节点这时候需要CAS 操作compareAndSetHead(),在源码 enq( ) 方法中体现了。

独占式同步状态获取与释放

独占式同步状态获取

同一时刻仅有一个线程持有同步状态,如ReentrantLock。又可分为公平锁和非公平锁。

公平锁: 按照线程在队列中的排队顺序,有礼貌的,先到者先拿到锁。

非公平锁: 当线程要获取锁时,无视队列顺序直接去抢锁,不讲道理的,谁抢到就是谁的。

独占式获取同步状态的方法有一下这些:

  • acquire(int arg)
  • acquireInterruptibly (int arg) throws InterruptedException
  • tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException

acquire( ) 与 acquireInterruptibly( ) 的区别就是 acquire 如果打断的话只是标记中断标志 boolean interrupted = true,而 acquireInterruptibly( ) 会直接抛出异常。

tryAcquireNanos(int arg,long nanos)。该方法为acquireInterruptibly方法的进一步增强,它除了响应中断外,还有超时控制。即如果当前线程没有在指定时间内获取同步状态,则会返回false,否则返回true。超时机制使用的是LockSupport(thread, nanosTimeout)的超时机制。

重点看acquire( ) 方法:

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

先执行用户自定义逻辑的 tryAcquire(arg),去尝试获取同步状态,获取成功则返回true。后面的逻辑就不用看了,直接走完acquire的

代码,去执行同步代码块的业务代码了。

如果失败就执行 :

  • addWaiter 将当前线程加入到CLH同步队列尾部。

  • acquireQueued 方法 根据公平性原则来进行阻塞等待(自旋),直到获取锁为止;并且返回当前线程在等待过程中有没有中断过。

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;
                }
            }
        }
    }

addWaiter( ) 先快速插入一下CLH尾部,前面说了这一步是需要线程安全的,如果插进去了就没事了。如果失败了就要调用enq,

enq插入的逻辑还是一样的,就是要不断自旋,直至你插入成功,而且还负责CLH的初始化。

当插入到CLH尾部后,该节点就要执行 acquireQueued 进行自旋,看看啥时候轮到自己执行,怎么看? 就是看前面的节点是不是头节点,如果是的话,那我就去执行tryAcquire( ) 尝试获取同步状态。万一真给我等到了,我也获取到同步状态了,别急着去执行同步代码块的业务逻辑,先把CLH给维护好:

  1. 所以先把自己设置为头结点 setHead(node)(不需要cas 操作,前面说了)

  2. 老头结点的指针给他断开,让gc把这部分内存回收了

    我就从acquireQueued 自旋中出来了,可以去执行业务代码喽!

    有一个问题:如果在CLH中的各个节点都自旋查看前面是不是头节点,那如果队列比较长,节点比较多,岂不是很浪费CPU资源?

    所以这里做了一个判断 shouldParkAfterFailedAcquire(p, node)

    这个方法会去看我们前面的节点的waitStatus 是不是 signal,如果是 signal 的话那我们就执行 parkAndCheckInterrupt( ) ,安心阻塞吧,前面的节点会负责唤醒我们的。

    如果前面的节点不是signal,前节点可能不想获取同步状态了为Canceled 状态,那么 shouldParkAfterFailedAcquire(p, node) 会往前面找,找到最近的为 signal 的节点让它的后继节点指向我们。

    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)
            return true;
        if (ws > 0) {

            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }


    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

独占式同步状态释放

AQS提供了release(int arg)方法释放同步状态。

头节点的线程跑完了同步代码块,这个时候需要释放同步状态了。根据上面的同步状态获取如果让你自己实现状态释放你会怎么做?

直接唤醒CLH队列头节点的下一个节点不就行了吗,反正当下一个节点获取到同步状态后,会自己设置为头结点,把我断开的。

看看源码还真是这样:

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

共享式同步状态获取与释放

共享式与独占式的最主要区别在于同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。

AQS提供acquireShared(int arg)方法共享式获取同步状态,和独占式类似共享式也有可打断的、超时相应的方法这里就不多说了。重点关注 acquireShared(int arg) 方法。

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg); 
    }


tryAcquireShared(arg) 和前面提过的一样,是给用户自己设置同步逻辑的地方,tryAcquireShared(arg) 返回 >= 0 的数说明当前线程拿到锁了,拿到同步状态了。如果是负数就要执行 doAcquireShared(arg) 放入CLH队列里,并且有一些节点的操作。

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) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

    private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

可以观察共享式 doAcquireShared( ) 与独占式 acquireQueued( ) 方法流程上是差不多的,关键差别就在于这个 setHeadAndPropagate(node, r),里面调用了 doReleaseShared( ) 。奇怪了,这个不是释放线程同步状态的吗?怎么会在 doAcquireShared( )里面会用到这个方法?

因为共享状态下,如果当前节点是共享模式,那么其后面的同样是共享模式的节点也应该可以获得同步状态,可以理解为读锁互不排斥,直到尾结点或者是独占模式的节点(相当于写锁),才停止往后传播。所以在 doAcquireShared()中不止是将当前节点设置为头节点,断开老头节点的指针,还需要往后传播唤醒后续的共享模式的节点。

所以现在看看doReleaseShared() 是如何实现唤醒后续应该被唤醒的共享模式的节点的。

  public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }


    private void doReleaseShared() {
        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;
        }
    }


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);
    }

可以先思考一个问题:位于头节点的线程被唤醒后接下来应该去干嘛?

理所当然的想法当然是既然已经获得了同步状态,唤醒后继节点,然后直接去执行同步代码块里面的业务逻辑不就行了吗。

但是源码并没有采取这样的方式,而是做了优化,采用了唤醒风暴的方式(听起来很酷!)。简单来说就是虽然我已经获得了同步状态,而且也唤醒了后继节点,但是我还要继续帮忙唤醒当前头节点后面的节点。如果发现是独占锁、或者到尾结点了那么我就可以结束 doReleaseShared(),去执行同步代码块的业务逻辑了。这样随着唤醒的共享模式节点线程越多唤醒的速度就越快。线程1唤醒了线程2,线程1和线程2一起尝试唤醒线程3…

关于 doReleaseShared( ) 的详细解析可以看文末的连接。

总结一下:

共享式同步状态的获取与释放,都会调用doReleaseShared( ),将CLH中的后续的共享节点线程唤醒,直到遇到独占式节点或已经到尾部。唤醒的方式做了优化,获取同步状态被唤醒的线程仍然在帮助唤醒后续的共享式节点。唤醒完才退出,执行同步代码块的业务逻辑。

使用AQS自定义并发工具

为《java并发编程的艺术》p134 的例子。

使用共享式方式自定义一个,只允许两个线程同时获得共享状态(锁)的工具。

public class TwinsLock implements Lock {
    private final Sync sync = new Sync(2);

    private static final class Sync extends AbstractQueuedSynchronizer{
        public Sync(int state) {
            if(state <= 0){
                throw new IllegalArgumentException("state must more than zero");
            }
            setState(state);
        }
				
      	
      	// 返回值 > 0 表示获取锁成功
      	// 如果 newState < 0 直接返回 newState 获取锁失败
      	// 如果 newState > 0 ,cas 设置newState 如果成功返回,不成功重新循环、重新计算newState
        @Override
        public int tryAcquireShared(int arg) {
            for(;;){
                  int current = getState();
                  int newState = current - arg;
                  if(newState < 0 || compareAndSetState(current, newState)){
                      return newState;
                  }
            }
        }
				
      	
        @Override
        public boolean tryReleaseShared(int arg) {
            for(;;){
                int current = getState();
                int newState = current + arg;
                if(compareAndSetState(current, newState)){
                    return true;
                }
            }
        }
    }
}

    @Override
    public void lock() {
        sync.acquireShared(1);
    }

    @Override
    public void unlock() {
        sync.releaseShared(1);
    }

为什么这里 tryReleaseShared 不需要判断 newState > 2 的情况,如果多次执行了unlock( )?

因为我们用的时候就是先lock 再unlock,是成对出现的,所以 state 不会超过一开始传入的参数值 2。

学习资料:

《java并发编程的艺术》

【死磕Java并发】-----J.U.C之AQS:同步状态的获取与释放

J.U.C|同步队列(CLH)

AQS解析与实战

读写锁doReleaseShared源码分析及唤醒后继节点的过程分析

AQS深入理解 doReleaseShared源码分析 JDK8

你可能感兴趣的:(多线程,java,开发语言)