代码改变世界

AbstractQueuedSynchronizer原理及代码分析

2014-06-05 11:03  noahark-zhang  阅读(3600)  评论(0编辑  收藏  举报

一、AQS简介

AbstractQueuedSynchronizer(AQS)是java.util.concurrent并发包下最基本的同步器,其它同步器实现,如ReentrantLock类,ReentrantReadWriteLock类,Semaphore类(计数信号量),CountDownLatch类,FutureTask类和SynchronousQueues类都是基于它来实现的(各个实现类在内部持有了一个实现AQS的内部类,然后通过代理对外提供同步器的功能)。AQS会维护一个同步状态(state),在互斥和共享语义下有不同的含义,在AQS内部,采用CAS技术实现同步状态原子性的管理,而对同步状态的更改逻辑(什么情况下进行更改)则由子类来实现,正是由于这点,产生了不同语义的同步器;另外,AQS根据同步状态的值将对调用线程进行阻塞和解除阻塞的操作;最后,AQS提供了一个FIFO队列,将阻塞的线程压入队列,进行排队管理,然后再按照顺序出队列。总之,AQS框架为同步状态的原子性管理、线程的阻塞和解除阻塞以及排队提供了一种通用机制。

二、AQS实现

AQS包含两种方法,一种是acquire,另一种是release。acquire操作阻塞调用的线程,直到或除非同步状态允许其继续执行。而release操作则是通过某种方式改变同步状态,使得一或多个被acquire阻塞的线程继续执行。

同步器背后的基本思想非常简单。acquire操作如下:

while(synchronization state does not allow acquire) {
        enqueue current thread if not already queued;
        possibly block current thread;
    }
    dequeue current thread if it was queued;

release操作如下:

update synchronization state;
    if (state may permit a blocked thread to acquire)
        unblock one or more queued threads;

为了实现上述操作,需要下面三个基本组件:

1) 同步状态的原子性管理;

2) 线程的阻塞与解除阻塞;

3) 队列的管理;

1、同步状态

AQS类使用一个int(32位)来保存同步状态,并暴露出getState、setState以及compareAndSet操作来读取和更新这个状态。这些方法都依赖于j.u.c.atomic包的支持,这个包提供了兼容JSR133中volatile在读和写上的语义,并且通过使用本地的compare-and-swap或load-linked/store-conditional指令来实现compareAndSetState,使得仅当同步状态拥有一个期望值的时候,才会被原子地设置成新值。

基于AQS的具体实现类必须根据暴露出的更改状态的方法来定义tryAcquire和tryRelease方法,来控制acquire和release操作。当同步状态满足时,tryAcquire方法必须返回true,而当新的同步状态允许后续acquire时,tryRelease方法也必须返回true。这些方法都接受一个int类型的参数用于传递想要的状态。例如:可重入锁中,这个参数表示同个线程申请锁的次数。很多同步器并不需要这样一个参数,忽略它即可。

2、阻塞

在JSR166之前,阻塞线程和解除线程阻塞都是用Java内置管程(synchronised)来实现,没有其它的API可以使用。唯一可以选择的是Thread.suspend和Thread.resume,但是它们都有无法解决的竞态问题,所以也没法用:当一个非阻塞的线程在一个正准备阻塞的线程调用suspend前调用了resume,这个resume操作将不会有什么效果。

j.u.c包有一个LockSuport类,这个类中包含了解决这个问题的方法。方法LockSupport.park阻塞当前线程除非直到有个LockSupport.unpark方法被调用(unpark方法被提前调用也是可以的)。unpark的调用是没有被计数的,因此在一个park调用前多次调用unpark方法只会解除一个park操作。另外,它们作用于每个线程而不是每个同步器。一个线程在一个新的同步器上调用park操作可能会立即返回,因为在此之前可能有”剩余的”unpark操作。但是,在缺少一个unpark操作时,下一次调用park就会阻塞。虽然可以显式地消除这个状态,但并不值得这样做。在需要的时候多次调用park会更高效。

3、队列

整个框架的关键就是如何管理被阻塞的线程的队列,该队列是严格的FIFO队列,不支持基于优先级的同步,它是基于CLH锁(自旋锁)实现的。它是一个双向链表队列,通过两个字段head和tail来存取,这两个字段是原子更新的,两者在初始化时都指向了一个空节点。

clhNode

 

 

 

 

 

 

一个新的节点node,通过一个原子操作入队:

do{
        pred = tail;
     while(!tail.compareAndSet(pred, node));

新的结点总是加在对尾,并且调用park()阻塞自己,然后等待前驱结点通过unpark(node.thread)唤醒。出队列需要判断当前结点的前驱结点是否是head,只有前驱结点是头结点才能出队列,该结点出队列之后,同时将head字段指向该节点:

head = node;

在节点中显式地维护前驱结点除了在出/入队列中所起的作用之外,还可以有效处理”超时”和各种形式的”取消”:如果一个节点的前驱节点取消了,这个节点就可以忽略该前驱结点,直接使用前驱结点的前驱结点。

另外,在结点中设置结点的状态是用于控制阻塞的,这样也可以避免没有必要的park和unpark调用。在调用park前,线程设置一个”唤醒(signal me)”位,一个释放的线程会清空其自身状态,这样线程就不必频繁地尝试阻塞。

抛开具体的细节,基本的acquire操作的最终实现的一般形式如下(互斥,非中断,无超时):

if(!tryAcquire(arg)) {
        node = create and enqueue new node;
        pred = node's effective predecessor;
        while (pred is not head node || !tryAcquire(arg)) {
            if (pred's signal bit is set) park();
            else compareAndSet pred's signal bit to true;

            pred = node's effective predecessor;
        }

        head = node;
    }

release操作:

if(tryRelease(arg) && head node's signal bit is set) {
         compareAndSet head's bit to false;
         unpark head's successor, if one exist
    }

4、条件队列

AQS框架提供了一个ConditionObject类,给维护独占同步的类以及实现Lock接口的类使用。一个锁对象可以关联任意数目的条件对象,可以提供典型的管程风格的await、signal和signalAll操作,包括带有超时的,以及一些检测、监控的方法。ConditionObject类有效地将条件(conditions)与其它同步操作结合到了一起。该类只支持Java风格的管程访问规则,这些规则中,仅当当前线程持有锁且要操作的条件(condition)属于该锁时,条件操作才是合法的。这样,一个ConditionObject关联到一个ReentrantLock上就表现的跟内置的管程(通过Object.wait等)一样了。两者的不同仅仅在于方法的名称、额外的功能以及用户可以为每个锁声明多个条件。

ConditionObject使用了与同步器一样的内部队列节点。但是,是在一个单独的条件队列中维护这些节点的。signal操作是通过将节点从条件队列转移到锁队列中来实现的,而没有必要在需要唤醒的线程重新获取到锁之前将其唤醒。

基本的await操作如下:

create and add new node to conditon queue;
    release lock;
    block until node is on lock queue;
    re-acquire

signal操作如下:

transfer the first node from condition queue to lock queue;

因为只有在持有锁的时候才能执行这些操作,因此他们可以使用顺序链表队列操作来维护条件队列(在节点中用一个nextWaiter字段)。转移操作仅仅把第一个节点从条件队列中的链接解除,然后通过CLH插入操作将其插入到锁队列上。

三、AQS实现原理

1、同步状态

AQS如何区分同步器的排他性和共享性?这主要通过维护一个同步状态来实现,其定义如下:

private volatile int state;

通过添加volatile修饰符保证线程中读取到的state值都是最新更改的值,另外通过compareAndSetState函数来原子性操作(用CAS指令实现无阻塞的原子性操作)。在不同的同步器中,state的含义是不同的。

ReentrantLock类使用AQS同步状态来保存锁(重复)持有的次数。当锁被一个线程获取时,ReentrantLock也会记录下当前获得锁的线程标识,以便检查是否是重复获取,以及当错误的线程试图进行解锁操作时抛出异常。

ReentrantReadWriteLock类使用AQS同步状态中的16位来保存写锁持有的次数,剩下的16位用来保存读锁的持有次数。WriteLock的构建方式同ReentrantLock。ReadLock则通过使用acquireShared方法来支持同时允许多个读线程。

Semaphore类(计数信号量)使用AQS同步状态来保存信号量的当前计数。它里面定义的acquireShared方法会减少计数,或当计数为非正值时阻塞线程;tryRelease方法会增加计数,当计数为正值时还要解除线程的阻塞。

CountDownLatch类使用AQS同步状态来表示计数。当该计数为0时,所有的acquire操作才能通过。

FutureTask类使用AQS同步状态来表示某个异步计算任务的运行状态(初始化、运行中、被取消和完成)。设置或取消一个FutureTask时会调用AQS的release操作,等待计算结果的线程的阻塞解除是通过AQS的acquire操作实现的。

SynchronousQueues类(一种CSPCommunicating Sequential Processes形式的传递)使用了内部的等待节点,这些节点可以用于协调生产者和消费者。同时,它使用AQS同步状态来控制当某个消费者消费当前一项时,允许一个生产者继续生产,反之亦然。

2、FIFO队列

在AQS内部维护一个FIFO队列对阻塞的线程进行管理,队列中的元素Node保存着线程引用和线程状态的容器,每个线程都可以看作是队列中的一个节点。Node的主要包含以下成员变量:

Node {
        int waitStatus;
        Node prev;
        Node next;
        Node nextWaiter;
        Thread thread;
    }

其含义如下所示:

属性名称 描述
int waitStatus

表示节点的状态。其中包含的状态有:

  1. CANCELLED,值为1,表示当前的线程被取消;
  2. SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark;
  3. CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中;
  4. PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行;
  5. 值为0,节点的初始值。
Node prev 前驱节点
Node next 后继节点
Node nextWaiter 存储condition队列中的后继节点
Thread thread 入队列时的当前线程

在AQS内部,会维护两个队列,一个是同步队列,一个是条件队列,当条件满足之后,条件队列中的结点会被转移到同步队列中,由同步队列统一维护。

3、API说明

AQS采用模板方法,定义了一套通用的处理框架,规范了排他锁和共享锁获取锁的流程,其中包括队列的维护,线程的阻塞及唤醒等等,而实现排他锁和共享锁的逻辑则交给子类来实现(主要是赋予同步状态不同的含义来实现)。其中acquire和release函数定义了排他锁的锁获取和释放的处理流程,对应的同步状态的维护则由tryAcquire和tryRelease函数负责,这个函数由子类来实现,如果直接调用AQS中的实现,则会抛出UnsupportedOperationException异常。依次类推,acquireShared,releaseShared,tryAcquireShared和tryReleaseShared定义共享锁的处理流程。除了这两个基本的流程,AQS还为它们提供了超时和中断的版本,为了简单起见,在这里主要分析非超时和中断的版本。

四、源码分析

1、acquire方法主要实现排他锁的获取,其代码如下:

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

这个函数规范了获取锁的流程:1、尝试获取锁;2、获取失败入队列;3、阻塞当前线程。另外在调用过程中,会清空线程的中断状态,在退出之前,还需要有恢复中断状态(selfInterruput)。

1)尝试获取锁

以ReentrantLock的不公平锁的获取为例(在tryAcquire中调用nonfairTryAcquire函数)

final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

不公平锁主要是指最新申请锁的线程有可能比在队列中等待获取锁的线程优先得到锁,这对后者来说是不公平的,因为这违背了先来先得的原则。这样做的目的主要是为了获取更大的吞吐量,在ReentrantLock中同时也实现了公平锁,可以根据不同的场景中自由选择,在性能和公平之间自由切换。

在ReentrantLock中,有两个重要的变量与之关联,一个是锁的状态:state和当前获取锁的线程。state为零表示锁未被占用,可以被申请;大于零表示被占用,具体的数值表示重入的数量,即锁被重新获取的数量(ReentrantLock可被占有的线程重新获取)。在该方法中,两种情况返回true,一种是锁未被占用,另外一种情况是申请锁的线程和锁占用的线程是同一个时。

2)入队列并阻塞线程

当锁被占用的时候,AQS会将申请锁的线程压入队列:

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

在addWaiter方法中主要是生成一个排他类型的结点,将根据CAS指令插入到FIFO队列的队尾。

final boolean acquireQueued(final Node node, int arg) {
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } catch (RuntimeException ex) {
            cancelAcquire(node);
            throw ex;
        }
    }

生成结点并压入队尾之后,会将新生成的结点传入acquireQueued方法。在该方法中,会对当前结点的前驱结点进行轮询,判断其前驱结点是否是head结点,并再次尝试获取锁,如果成功,则将当前结点设置成为head结点,并结束轮询退出方法,至此acquire流程结束。如果其前驱结点不是head结点,则对结点进行状态设置且调用park方法阻塞当前线程。对结点状态主要包含两个方面(shouldParkAfterFailedAcquire方法):1)将前驱结点中处于取消状态的结点过滤掉,同时将前驱结点设置为首个非取消状态的结点;2)将前驱结点的状态设置为Node.SIGNAL,表示其后继结点需要被唤醒。设置状态成功之后调用parkAndCheckInterrupt方法,在该方法中调用LockSupport.park(this),阻塞当前线程,并清空线程的中断状态。

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

在release方法中,主要包含两个操作:1)尝试释放锁,判断锁的状态(state)是否可以被申请;2)唤醒head结点后继结点对应的线程。

1) 尝试释放锁

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

在该方法中,主要是对当前锁的状态值减1,如果为0则表示锁未被占用,另外锁的释放操作只能由锁的占有者调用 。

1) 唤醒后继结点

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

在unparkSuccessor方法中,一般会直接唤醒head后继结点中的线程,不过如果该后续结点被取消或为空,则会从队尾遍历出第一个未取消的线程进行唤醒。唤醒的线程被系统调度之后将从acquireQueued方法继续执行,同时将该后继结点设置为head头结点,至此,被阻塞的线程才从acquireQueued中返回。

3、ConditionObject条件变量,可替换之前的管程的同步操作(Object对象的wait,notify方法),在该对象中持有两个变量firstWaiter和lastWaiter,表示等条件列的头尾。await对应Object中的wait方法,它的工作是将当前线程加入到条件对列中,并阻塞线程,等待条件满足之后唤醒。signal对应Object中的notify,它仅仅是将当前线程从条件队列中移到同步对列中。

1)await方法

public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            Node node = addConditionWaiter();
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null)
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

在await方法中,主要有四个操作:1)构建结点,加入到条件对列(addConditionWaiter方法);2)释放锁;3)阻塞当前线程;4)重新获取锁。

2)signal方法,该方法主要是调用doSignal实现。

private void doSignal(Node first) {
            do {
                if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null;
                first.nextWaiter = null;
            } while (!transferForSignal(first) &&
                     (first = firstWaiter) != null);
        }

该方法主要是将条件队列中第一个未取消的线程移到同步队列中。

五、总结

这篇文章主要分析了AbstractQueuedSynchronizer(AQS)的实现原理和相关代码,AQS是java并发包中一个基础组件,在它的基础上,构建了一系列高级的同步组件。理解它的原理对于工作中使用java并发技术有莫大的帮助。

 

引用:

1、 The java.util.concurrent Synchronizer Framework 中文翻译版,http://ifeve.com/aqs/

2、 AbstractQueuedSynchronizer的介绍和原理分析,http://ifeve.com/introduce-abstractqueuedsynchronizer/#more-8074

3、java线程阻塞中断和LockSupport的常见问题,http://agapple.iteye.com/blog/970055