彻底搞懂 - 从源码看JUC的基石: AQS

AQS(AbstractQueuedSynchronizer)抽象队列式同步器,从它的名字中,我们可以看出AQS本身是一个同步器,也就是发生资源竞争时,用来同步的工具。它的内部使用了队列来进行实现,而之所以叫“抽象”,是因为AQS只是一个“框架”,它提供了当发生资源竞争时,竞争失败的线程入队、出队等的方式,但没有描述竞争的具体细节,这些交由其子类来按需具体实现,AQS在JUC中许多大名鼎鼎的类中得到应用,例如ReentrantLock,CountDownLatch,Semaphore等等,可以说,AQS是JUC的基石,搞懂了AQS,就能轻松理解JUC中各种锁的api。

一. 概念

AQS的模型:
在这里插入图片描述

AQS在内部维护定义了一个内部类Node,用它来构建一个FIFO的双向链表,分别有Head和Tail来指向链表头尾,基于这样一套数据结构,形成了基于逻辑队列非线程饥饿的一种自旋公平锁,称为CLH锁。(由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁,CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋)。如果说AQS是JUC的基石,那么CLH就是AQS的基石。

AQS将资源的共享方式定义为两种:1.Exclusive 独占,即只有一个线程可以访问资源。2. Share 共享,即允许同时有X个线程访问资源。AQS使用一个被volatile修饰的int变量state来代表资源。

针对两种共享方式,子类需要继承AQS,并分别实现不同的接口,主要是如下几个:
Exclusive:
tryAcquire: 尝试获取资源,成功返回true,失败返回false。
tryRelease: 尝试释放资源,成功返回true,失败返回false。

Share:
tryAcquireShared: 尝试获取资源,返回负数表示失败,0表示成功,但没有资源可用,正数表示成功,而且有剩余资源。
tryReleaseShared: 尝试释放资源,如果释放后允许唤醒后需等待的线程,则返回true,否则返回false。

以上四个方法,都有一个int参数,AQS没有明确说明这个参数的含义,可以由子类来定义。一般来讲,对于一个继承AQS的锁(同步器)而言,要么实现独占锁,要么实现共享锁,但也有同时实现这两种的,如ReentrantReadWriteLock。为了减轻开发者不必要的工作,上述的四个方法都没有设计成抽象方法,也就是说子类只需要按需实现即可,对于子类未实现,却又被调用了的方法,AQS默认会抛出一个UnsupportedOperationException异常。

典型的独占锁是ReentrantLock,state初始时默认为0,表示资源还没有被获取,当有一个线程调用lock时,lock底层会调用tryAcquire,即独占该资源,并将state + 1,此后这个线程可以重复lock,state也会递增,只有当这个线程调用unlock(底层是tryRelease)至state为0时,其他线程才有机会获得这个资源,这就是可重入的概念,所以ReentrantLock称为可重入锁,它的本质仍是一个独占锁。
下边,我们分别从独占模式和共享模式来描述AQS的执行原理。

二. 独占模式

  1. acquire

    acquire是独占模式下,AQS用来尝试获取资源的方法,可以想象的是,它的实现一定是调用了上文提到的tryAcquire:

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

这段代码的主要流程与含义如下:
1.调用tryAcquire尝试获取资源,如果获取成功,这个方法会直接返回。
2.如果获取失败,则首先调用addWaiter,以独占模式将当前线程包装成一个Node,将它放置到CLH的末尾。
3.再调用acquireQueued,不断自旋尝试获取资源,直到获取成功,当获取成功时,会返回false,此时整个方法也会直接返回。但当自旋过程中发生线程中断时,不会立即响应中断,而是在获取资源成功后,返回一个true,此时selfInterrupt被激活,自行补一个中断。

下面我们细节看一下上边用到的addWaiter,acquireQueued方法:
addWaiter:

	private Node addWaiter(Node mode)
	    {
	       Node node = new Node(Thread.currentThread(), mode);
	
	        Node pred = tail;
	        if (pred != null)
	        {
	            node.prev = pred;
	            if (compareAndSetTail(pred, node))
	            {
	                pred.next = node;
	                return node;
	            }
	        }
	
	        enq(node);
	        return node;
	    }

这里首先将当前线程按照执行模式构造成了一个Node,然后先尝试通过快速的方式将Node加入队尾,当快速加入失败后,则会调用enq方法进行加入,最后返回当前线程所在的Node。下面看enq方法的实现:

	private Node enq(final Node node)
	    {
	        for (;;)
	        {
	            Node t = tail;
	            if (t == null)
	            { 
		        if (compareAndSetHead(new Node()))
	                    tail = head;
	            }
	            else
	            {
	                node.prev = t;
	                if (compareAndSetTail(t, node))
	                {
	                    t.next = node;
	                    return t;
	                }
	            }
	        }
	    }

这是一段非常经典的用法,即CAS自旋volitale变量,首先判断CLH的tail是否为null,如果是null就初始化一个进去。这样做,是因为在AQS中,需要依赖前驱节点将自己唤醒,如果没有前驱,当前节点会永远无法唤醒。再次自旋时,就可以将当前Node放置在新创建的tail后,为防止高并发下tail可能变化的问题,这里通过compareAndSetTail先尝试将Node加入tail后,如果失败了,意味着有线程抢先变化了tail,下一次自旋会再次获取新的tail,再次尝试加入队尾,直到成功。类似的用法可以参考AtomicInteger.getAndIncrement()。compareAndSetTail是Unsafe类里提供的一个方法,底层是native函数,通过CPU原语实现的CAS操作。

通过以上步骤,我们的线程由于没有拿到资源,已经被包装成一 个Node加入了CLH的尾部,下边要做的事就是等待前置线程释放资源、自己被唤醒了,即acquireQueued方法做的事:

	final boolean acquireQueued(final Node node, int arg)
	    {
	        boolean failed = true;// 标记是否成功拿到资源
	        try
	        {
	            boolean interrupted = false;// 标记等待过程中是否被中断过
	
	            for (;;)
	            {
	                final Node p = node.predecessor();// 拿到前驱
	                // 如果前驱是head,即该结点已成老二,那么便可以尝试获取资源(因为此时head可能已经释放了资源)。
	                if (p == head && tryAcquire(arg))
	                {
	                    setHead(node);// 拿到资源后,将head指向该结点,同时会将node.prev置为null。
	                    p.next = null; // 再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了
	                    failed = false;
	                    return interrupted;// 返回等待过程中是否被中断过
	                }
	
	                // 如果自己可以休息了,就进入waiting状态,直到被unpark()
	                if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
	                    interrupted = true;// 如果等待过程中被中断过,就将interrupted标记为true
	            }
	        }
	        finally
	        {
	            if (failed)
	                cancelAcquire(node);
	        }
	    }

在梳理整个流程前,我们还应该了解下shouldParkAfterFailedAcquire方法和parkAndCheckInterrupt方法:

	private static boolean shouldParkAfterFailedAcquire(Node pred, Node node)
	    {
	        int ws = pred.waitStatus;// 拿到前驱的状态
	        if (ws == Node.SIGNAL)
	            // 如果前驱已经是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;
	    }
   private final boolean parkAndCheckInterrupt()
   {
       LockSupport.park(this);// 调用park()使线程进入waiting状态
       return Thread.interrupted();// 如果被唤醒,查看自己是不是被中断的。
   }

可见,这两个方法的作用就是向前寻找一个最近的、状态为SIGNAL的节点,将自己排在它的后边,然后将当前线程置为等待状态,如果被唤醒,会确认下是不是被中断的。关于这个状态,我们目前只需要知道,Node有四种状态,当当前Node的状态为SIGNAL,在他释放资源时,会唤醒其最近的、未放弃的一个后续节点(unpark),这意味着排队中的节点,是可能因为各种原因放弃的,但他们仍在队伍中。

看完这两个方法,我们就可以梳理acquireQueued的流程了:
1.调用tryAcquire尝试获取资源,成功则就直接返回,不成功则调用addWaiter将线程包装成一个Node排在CLH队尾。
2.调用acquireQueued在CLH中找到一个最近的安全点(即状态为SIGNAL的节点),把自己排在它后边,然后将线程状态置为waiting,即开始休息。
3.一旦线程被唤醒(排到第二名了,或者被中断了),就尝试去竞争资源,获取到资源后才会返回。如果整个等待过程中线程被中断过,则返回true,反之返回false。
4.如果线程等待过程中被中断过,拿到资源后会把中断补上。

梳理下acquire整个流程:

实际上,这也正是ReentrantLock.lock()的流程,整个lock函数,其实就是一个acquire(1)。

  1. release
    release是AQS中用于释放资源的操作,他是acquire的反操作,如果资源彻底被释放了(state = 0)它会唤醒后继节点。
    下边是release源码:
	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来实现释放资源,如果返回true,则认为释放成功,尝试唤醒后续节点。需要注意的是,如果子类是可重入锁,那么一定要确认state = 0时才能返回true!否则就会出现,明明可重入锁还没有完全释放,后续节点却被唤醒了的情况。
这里我们唯一需要看细节的,是unparkSuccessor方法,它用于在当前节点释放资源后,唤醒后续节点:

	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;
	            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);// 唤醒
	    }

这里的逻辑并不复杂,就是用unpark唤醒最前边的有效节点,需要注意的是,它是从tail开始向前寻找的,直到找到那个离当前节点最近的、有效的节点。被唤醒以后,就会继续走acquireQueued的逻辑了,一般来讲,这个有效节点就是当前节点的next,如果真的出现中间有无效节点,也没关系,有效节点被唤醒后,会再次尝试向前找安全点,就会把自己放置到当前节点后,下一轮自旋时,就会被顺利激活。

三.共享模式

1.acquireShared
这是共享模式下,用于获取资源的方法,获取成功则直接返回,失败则进入队列:

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

tryAcquireShared同样是需要子类自己实现的,但这里AQS其实定义好了返回值的语意,负数代表失败,0代表获取成功但没有剩余资源,正数代表成功且有剩余资源。这里我们唯一需要看的是doAcquireShared,它对应独占模式下的acquireQueued方法。

		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);
		        }
		    }
		
		可以看到和acquireQueued区别不大,只有两点差异:1.在竞争资源时,会调用setHeadAndPropagate,唤醒自己后,如果还有剩余资源,则会继续唤醒后续节点。2.处理中断的位置与acquireQueued不同。
		我们重点看下setHeadAndPropagate函数:
		
		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();
		        }
		    }

可以看出,流程上是先唤醒自己,再唤醒后续节点,也就是说,如果当前剩余资源不足以唤醒自己,即便能够满足后续线程使用,也不会跳过当前线程,而是严格按照FIFO。doReleaseShared方法,则是唤醒后继节点的方法,我们把它放在releaseShared方法中讲。

2.releaseShared
这是共享模式下释放资源的方法,是acquireShared的反操作:

		public final boolean releaseShared(int arg)
		    {
		        if (tryReleaseShared(arg))
		        {// 尝试释放资源
		            doReleaseShared();// 唤醒后继结点
		            return true;
		        }
		        return false;
		    }

同样的逻辑,先调用子类的tryReleaseShared尝试释放资源,释放成功后,调用doReleaseShared唤醒后继节点。
跟独占模式下的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()的返回值。
下边看下doReleaseShared细节:

		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;
		                    unparkSuccessor(h);// 唤醒后继
		                }
		                else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
		                    continue;
		            }
		            if (h == head)// head发生变化
		                break;
		        }
		    }

与独占模式差不多。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值