并发编程 — AQS 原理 详解

目录

一、概述

二、接口说明

1、接口

2、模板方法

三、实现原理

1、同步队列

 2、独占式申请资源操作

3、独占式释放资源操作

4、共享式获取资源操作

5、共享式释放资源操作

四、示例

1、独占式示例

2、共享式示例


一、概述

AQS 全称为 AbstractQueuedSynchronizer (队列同步器),这个类是其他许多同步类的基类,它是使用一个 volatile 修饰 int 类型成员变量表示某种状态(如:ReentrantLock用它来表示所有者线程已经重复获取锁的次数,Semaphore用它来表示剩余的许可数量,FutureTask用它来表示任务的状态),通过内置一个虚拟的 FIFO 队列来完成获取资源的线程的排队等待工作。在J.U.C 包中很多同步器都是基于 AQS 构建的,在基于 AQS 构建的同步器中,只可能在同一时刻发生阻塞,从而降低上下文切换的开销,并提高吞吐量。

二、接口说明

1、接口

AQS 的主要使用方式是继承,子类通过继承 AQS 类并实现它的某些方法来管理同步器状态,AQS 提提供类以下 3 个方法来访问或修改同步状态。

  • getState():获取当前同步状态
  • setState(int newState):设置当前同步状态
  • compareAndSetState(int expect, int update):使用 CAS 设置当前状态,该方法能够保证设置状态的原子性。

通过 AQS 可以实现 独占式 和 共享式 两种通过同步器类。 独占式是指一次只能有一个线程能够访问资源;共享式是指可以允许多个线程访问资源。

实现独占式需要重写的方法

  • boolean tryAcquire( int arg ):独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后在进行 CAS设置同步状态。
  • boolean tryRelease( int arg):独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态。

实现共享式需要重写的方法

  • int tryAcquireShared(int arg):共享式获取同步状态,返回大于等于0的值,表示成功,小于0的值表示失败
  • boolean tryReleaseShared(int arg):共享式释放同步状态

其他方法

  • boolean isHeldExclusively():当前同步器是否在独占模式下被占用,一般该方法表示是否被当前线程所独占。

2、模板方法

在实现自定义同步器时,推荐 子类被定义为同步组件的静态内部类,然后在自定义组件中调用 AQS 中的相关模板方法。AQS 实现的模板方法如下所示:

独占式模板方法

  • void acquire(int arg):独占式获取同步状态,如果当前线程获取同步状态成功,则有该方法返回,否则,将会进入同步队列等待。
  • void acquireInterruptibly(int arg):可中断的 独占式获取同步状态,如果未获取到同步状态进入同步队列中,,如果当前线程被中断,则该方法会抛出中断异常。
  • boolean tryAcquireNanos(int arg, long nanosTimeout):在 acquireInterruptibly(int arg) 的基础上增加了超时限制,如果当前线程在超时时间内未获取到同步状态,则返回 false, 如果成功返回 true.
  • boolean release(int arg):独占式的释放同步状态,该方法会在释放同步状态后,将同步队列中第一个节点的线程唤醒。

共享式模板方法

  • void acquireShard(int arg):共享式获取同步状态,如果当前线程获取同步状态成功。
  • void acquireShardInterruptibly(int arg):可中断的 共享式获取同步状态,如果未获取到同步状态进入同步队列中,,如果当前线程被中断,则该方法会抛出中断异常。
  • boolean tryAcquireShardNanos(int arg, long nanosTimeout):在 acquireShardInterruptibly(int arg) 的基础上增加了超时限制,如果当前线程在超时时间内未获取到同步状态,则返回 false, 如果成功返回 true.
  • boolean releaseShard(int arg):共享式的释放同步状态,该方法会在释放同步状态后,将同步队列中第一个节点的线程唤醒。

三、实现原理

1、同步队列

AQS 是依赖内部一个虚拟的 FIFO 双向队列同步队列来完成同步状态的管理,为什么说是虚拟的队列,因为 AQS 内部并非真的有个队列,而是在其内部定义了一个Node类,来维护了一个双向队列。Node类中主要属性 如下所示:

static final class Node {
        // 节点状态
        volatile int waitStatus;
        // 前驱节点,当节点加入同步队列时被设置
        volatile Node prev;
        // 后继节点
        volatile Node next;
        // 获取同步状态的线程引用
        volatile Thread thread;
        // 等待队列中的后继节点,如果当前节点是共享的,那么这个字段将是一个SHARED常量,
        // 也就是说节点类型(独占和共享)和等待队列中的后继节点共用一个字段
        Node nextWaiter;
    }

属性 waitStatus 节点状态字典说明:

  • CANCELLED(1):由于在同步队列中等待的线程超时或者被中断,会触发变更为此状态,进入该状态后的结点将不会再变化。
  • SIGNAL(-1):后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消, 将会通知后继节点,使后继节点的线程得以运行。
  • CONDITION(-2):表示结点等待在Condition上,当其他线程调用了 Condition 的 signal() 方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
  • 0:新结点入队时的默认状态。

通过 Node 节点是构成同步队列的基础,在 AQS 中拥有首节点 Node head 和尾结点 Node tail,没有成功获取同步状态的线程将被构造成 Node节点加入到队列的尾部,同步队列的基本结构如下所示:

 2、独占式申请资源操作

由上图可知,在 AQS 中包含了 头结点 (head) 和尾结点( tail )两个节点类型的引用,当一个线程申请同步状态失败后,就会被构造成一个 节点( Node )插入到队列的尾部,而这个操作必须要保证是线程安全的,所以在 AQS 内部提供了一个基于 CAS 的设置尾结点的方法:boolean compareAndSetTail(Node expect, Node update)。入队操作如下所示:

查看申请锁源码如下:

2.1、acquire 方法

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(),将中断补上。

2.2、tryAcquire(int arg) 方法

上面介绍了此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,还是那句话,当然不仅仅只限于tryLock()。如下是tryAcquire()的源码:

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

 此方法默认抛出一个 异常,需要有 子类具体实现其方法。不再多说

2.3、addWaiter()

此方法是将当前线程 构造成一个Node 然后插入到 同步队列的队尾。源码如下所示:

private Node addWaiter(Node mode) {
    // 把 当前线程构建成一个 Node 节点
	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 节点插入队尾 
	enq(node);
	return node;
}


private Node enq(final Node node) {
   // 通过 CAS + 自旋的方式,将 Node 插入到同步队列的队尾
	for (;;) {
		Node t = tail;
        
		if (t == null) { // 如果队列为空,则先创建一个 头结点,然后在插入Node节点
			if (compareAndSetHead(new Node()))
				tail = head;
		} else {
			node.prev = t;
			if (compareAndSetTail(t, node)) { // 通过CAS 操作插入到队尾
				t.next = node;
				return t;
			}
		}
	}
}

 在enq(Node node) 方法中,AQS 是通过“死循环” 来保证节点的正确添加,在 “死循环” 中只有通过 CAS 将节点设置成尾结点,否则将一直尝试,直到设置成功。

2.4、acquireQueued()

源码如下:

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;
			}
			//如果自己可以休息了,就通过park()进入waiting状态,直到被unpark()。
			// 如果不可中断的情况下被中断了,那么会从park()中醒过来,发现拿不到资源,从而继续进入park()等待。
			if (shouldParkAfterFailedAcquire(p, node) &&
				parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
// 如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
		if (failed)
			cancelAcquire(node);
	}
}

通过 tryAcquire() 该线程获取资源失败,并且通过 addWaiter() 已经被放入等待队列尾部后,就会调用 acquireQueued() 方法,使当前线程在 “死循环” 中尝试获取同步状态,而只有前驱节点是头结点的节点才能够尝试获取同步状态,这是为什么呢?原因有如下两个:

  1. 头结点是成功获取到同步状态的节点,而头结点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否为头结点。
  2. 维护同步队列的 FIFO 原则。

acquireQueued() 函数有一个返回值,表示什么意思呢?虽然该函数不会中断响应,但它会记录被阻塞期间有没有其他线程向它发送过中断信号。如果有,则该函数会返回true;否则,返回 false 。

 该方法中,节点自旋获取同步状态的行为如下所示:

 2.5、shouldParkAfterFailedAcquire

此方法主要用于检查状态,看看自己是否真的可以去休息了(进入waiting状态,如果线程状态转换不熟),万一队列前边的线程都放弃了只是瞎站着,那也说不定,对吧!

shouldParkAfterFailedAcquire

整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。

2.6、parkAndCheckInterrupt

如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态。

private final boolean parkAndCheckInterrupt() {
  LockSupport.park(this); //调用park()使线程进入waiting状态
  return Thread.interrupted(); // 返回当前线程的中断状态,并清空
}

线程调用park()函数,自己把自己阻塞起来,直到被其他线程唤醒,该函数返回。park()函数返回有两种情况。

情况1:其他线程调用了unpark(Thread t)。

情况2:其他线程调用了t.interrupt()。

这里要注意的是,lock() 不能响应中断,但 LockSupport.park() 会响应中断。 

也正因为 LockSupport.park() 可能被中断唤醒,acquireQueued()  函数才写了一个 for 死循环。唤醒之后,如果发现自己排在队列头部,就去拿锁;如果拿不到锁,则再次自己阻塞自己。不断重复此过程,直到拿到锁。被唤醒之后,通过 Thread.interrupted() 来判断是否被中断唤醒。如果是情况1,会返回 false;如果是情况2,则返回 true

2.7、小结

首先线程去尝试获取同步状态,如果获取成功则直接返回,处理自己的事情;如果失败则被构建成一个 Node 节点然后通过 自旋 + CAS 的方式把构建的节点插入到同步队列的队尾,插入成功后会判断其前驱节点是否为头结点,如果不是则线程进入等待状态,如果是会再次尝试申请同步状态,如果获取成功则把自己设置为头部节点,如果失败则进入等待状态。

说明:当前节点的前驱节点是头结点时,会出如下两种情况:

  • 公平锁:如果是公平锁前驱节点是头结点,此时去获取同步状态,正常会成功。
  • 非公平锁:在非公平锁请可下,当前获取同步状态时,有可能被其他节点插队了,所有存在获取同步状态失败的情况。

3、独占式释放资源操作

当前线程获取了同步状态并且执行了响应逻辑后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用 AQS 的 release(int arg) 方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点,进而使后继节点重新尝试获取同步状态。方法源码如下所示:

3.1、release() 方法

public final boolean release(int arg) {
	//通过 tryRelease 尝试释放资源
	if (tryRelease(arg)) {
		Node h = head; //获取头结点
		//判断头结点不null,为非初始状态,则执行唤醒操作
		if (h != null && h.waitStatus != 0)
			unparkSuccessor(h);
		return true;
	}
	return false;
}

3.2、unparkSuccessor 方法

private void unparkSuccessor(Node node) {
    //这里 node 当前释放资源的线程所在的节点。
    int ws = node.waitStatus;
    if (ws < 0)//置零当前线程所在的结点状态,允许失败。
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;//找到下一个需要唤醒的结点s

    // 如果下一个节点为 null 或 已取消(超时或者被中断)
    // 则从尾结点 从后往前查找
    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()唤醒等待队列中最前边的那个未被取取消的线程,这里我们也用 N 表示当前节点。此时,在结合 acquireQueued() ,N 被唤醒后,进入if (p == head && tryAcquire(arg)) 的判断,如果不满足条件则会调用 shouldParkAfterFailedAcquire() 方法寻找下一个安全点,如果成功则执行自己的业务逻辑。

3.3、小结

资源的释放很简单,release() 方法是释放资源的入口,当前节点成功释放资源后就会唤醒后续节点,继续尝试获取资源。

4、共享式获取资源操作

4.1、acquireShared(int) 方法

 此方法是共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。下面是acquireShared()的源码:

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

4.2、tryAcquireShared(int arg)

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

    protected int tryAcquireShared(int arg) {
        throw new UnsupportedOperationException();
    }

4.3、doAcquireShared(int arg)

 此方法用于将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回。源码如下所示:

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 方法很类似,有一点区别是,如果线程被中断在补充中断时 独占式 的是在 acquireQueued 方法之外,而共享式的是放在了 doAcquireShared 方法中。

与独占式还有一点区别是,当节点被唤醒获取资源后,如果还有剩余资源,还会继续唤醒后续节点。如下图所示,当前节点 N0 获取到资源后,还剩余 2 个资源,那么当唤醒后续节点 N1 时,由于资源不足会继续阻塞到 N1 节点,不会在继续唤醒后续节点。

4.4、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();
    }
}

4.5、小结

 至此,acquireShared()  也要告一段落了。让我们再梳理一下它的流程:

  1. tryAcquireShared() 尝试获取资源,成功则直接返回;
  2. 失败则通过 doAcquireShared() 进入等待队列 park(),直到被 unpark()/interrupt() 并成功获取到资源才返回。整个等待过程也是忽略中断的。

  其实跟 acquire() 的流程大同小异,只不过多了个自己拿到资源后,还会去唤醒后继队友的操作。

5、共享式释放资源操作

5.1、releaseShared() 方法

 上一小节已经把 acquireShared() 说完了,这一小节就来讲讲它的反操作 releaseShared() 。此方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。源码如下所示:

    public final boolean releaseShared(int arg) {
        
        if (tryReleaseShared(arg)) { //尝试释放共享资源
            doReleaseShared(); //操作释放操作
            return true;
        }
        return false;
    }

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

5.3、小结

 共享模式的资源方法也很简单,一言以蔽之就是:释放掉资源后,唤醒后继。跟独占模式下不同的是,独占模式下在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式的实质是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。

四、示例

上面分析了 AQS 的原理及部分源码,接下来我们通过两个例子在巩固一下。

1、独占式示例

我们通过实现一个独占锁。

public class MonopolyLock implements Lock {

    private final Sync sync = new Sync();

    // 定义一个继承 AQS的静态内部类,自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer{
        //独占式获取锁,当状态为 0 时获取锁,获取成功后设置独占线程为当前线程,返回true,否则返回 false
        @Override
        protected boolean tryAcquire(int arg) {
            if(compareAndSetState(0, 1)){
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 释放锁,设置同步状态为 0,同时清空独占线程
        @Override
        protected boolean tryRelease(int arg) {
            if(getState() == 0){
                throw new IllegalMonitorStateException();
            }
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        // 判断锁释放处于被占有状态。
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        Condition newCondition(){
            return new ConditionObject();
        }
    }

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

    @Override
    public void lockInterruptibly() throws InterruptedException {
         sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(time));
    }

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

    @Override
    public Condition newCondition() {
        return  sync.newCondition();
    }
}

2、共享式示例

定义一个锁,实现每次有两个线程可以获取资源。具体代码如下所示:


public class TwinsLock implements Lock {

    /**
     * 继承 AbstractQueuedSynchronizer 实现一个同步器
     */
    private static final class Sync extends AbstractQueuedSynchronizer{

        //构造方法, count 默认资源数量
        Sync(int count){
            if (count < 0){
                throw new IllegalArgumentException("count must large than zero.");
            }
            setState(count);
        }

        // 复写 共享 申请资源方法,通过 自旋 + CAS 的方式更新剩余资源数量
        @Override
        protected int tryAcquireShared(int arg) {
            for (;;){
                int current = getState();
                int newCount = current - arg;
                if(newCount < 0 || compareAndSetState(current, newCount)){
                    return newCount;
                }
            }
        }

        // 释放资源
        @Override
        protected boolean tryReleaseShared(int arg) {
            for (;;){
                int current = getState();
                int newCount = current + arg;
                if(compareAndSetState(current, newCount)){
                    return true;
                }
            }
        }
    }

    // 定义一个同步器,资源数量为 2 个
    private final Sync sync = new Sync(2);


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

    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquireShared(1) > 0;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(time));
    }

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

    @Override
    public Condition newCondition() {
        return null;
    }
}

示例都很简单,如有疑问,可以留言共同探讨

五、写在最后

 有人问过这样一个问题 “ AQS 为什么使用双向队列?”。个人认为原因如下:

  • 因为线程在无法获取资源时,都是需要把构造的节点插入到队尾,使用双向队列,在插入是比价方便,直接操作队尾即可,无需遍历整个队列插入。
  • 在插入尾部时,尾结点以及尾结点之前的 所有节点都已经超时或者中断,那么就可以向前遍历删除这些已经取消的节点,这样在头结点释放锁后,就能直接被唤醒。
  • 在线程 A 占有了资源之后,线程B 也来占有,线程B 需要判断前驱节点的状态确定是否需要阻塞。双向队列方便遍历。
  • 双向扫描,当是否资源时,如果释放资源的后续节点为 null 或者被取消时,回查尾部遍历找到第一个为放弃的线程。

猜你喜欢

转载自blog.csdn.net/small_love/article/details/111088893