AQS:从原理到源码解读

目录

前言

我们在使用ReentrantLock、CountDownLatch等并发相关的工具的时候,会发现它们都继承了一个类:AbstractQueuedSynchronizer,简称AQS,有人说他是并发的基石,也有一种说法是并发框架,但是不管哪种说法,都显示出了AQS的重要性,所以我们也有必要去了解一下AQS,本文只是我对于AQS的了解,如有不当之处,欢迎指出我及时修改。

在AQS中,核心方法是围绕独占式地获取、释放锁,共享式地获取、释放锁,条件队列condition,不过相较于独占式、共享式这两种情况,这个条件队列的存在感要稍微比它们俩低一点,但是也很重要哈,接下来就从原理、源码这两个方面来看AQS。

原理

现在有一个共享资源可供线程使用,现在有线程A来获取资源,因为此时共享资源还是无主之物,那么线程A就直接占用了。随后线程B、C也需要这个资源,也来请求锁,但是这个锁目前有线程A在使用,线程B与线程C无法获取到锁,那这时对于这些无法获取到锁的线程我们该怎么办?

1、丢弃没有获取到锁的线程。
2、将未获取到锁的线程保存起来,等到锁被释放了,这些线程再去获取。

第一种方法虽然简便,但是会让我们丢失掉请求资源的线程,并发度降低,因此AQS采用的则是第二种,将未获取到锁的线程保存起来。

在AQS的内部,它维护了一个CLH变体的FIFO双向队列,以及一个volatile的int类型的成员变量来表示同步状态。AQS的思想就是:如果资源的状态state为0,那么前来请求的线程就可以占有资源,将状态值+1,并把当前线程设置为持有资源的线程。如果此时又有线程来获取资源,但是此时资源已经被占用了,AQS会初始化一个队列以及一个头节点,将后续请求的线程包装成Node节点加入到这个队列中。当资源被释放之后,公平锁会让排在队列中的线程依次去获取资源,如果是非公平锁的话则是队列中的第一个线程与此时前来争夺资源的线程进行竞争。

这里我们用一张图片来表示下吧:

在这里插入图片描述

这么看下来,貌似也很简单啊,哪有什么难的。不过实际情况肯定要比这个复杂的多,在这里搞清主题脉络,对于了解AQS还是有很大帮助的。但是需要注意的是:这个队列的头节点是个空节点,它不会存放具体的线程值的,只存放了对应的节点状态。

构造

在AQS中维护了一个同步队列,线程会被包装为一个节点,节点定义为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队列中阻塞,当其他线程调用Condition的signal方法后,Condition状态的节				//点将从等待队列转移到同步队列中
        static final int CONDITION = -2;
    		//共享模式下,当前节点不仅会唤醒后继节点,而且也可能会唤醒后继的后继节点
        static final int PROPAGATE = -3;

    		//节点的状态,也就是上面的几个值
        volatile int waitStatus;
				//前驱节点
        volatile Node prev;
				//后继节点
        volatile Node next;

        //包装的线程
        volatile Thread thread;

       	//条件队列相关,条件队列中的下一个节点
        Node nextWaiter;

     
    }

除了上面的几个状态,节点在创建的时候,默认值大小为0,而且这个CANCEl状态设置为1是个很关键的信息,因为啥吧,在节点的状态中,只有取消状态的值大于0。在AQS中,有的地方会根据这个值是否大于0来进行程序运行,也就是判断状态是否为取消状态。

独占式
获取锁
public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
                acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

获取锁的方法则是这个acquire方法,这个方法中涉及到很多的其他方法,接下来我们就一步一步来剖析这些方法,我会尽量地将代码都注上注解以方便理解。

tryAcquire:

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

AQS采用的是模板设计方法,他会开放一些方法让继承了AQS的子类去实现这些开放的方法,tryAcquire方法就是这样的一个方法。对于不同的实现子类,他的具体实现也是存在一些不同的,不过虽然不同,但是方法的核心思想还是大致一样的:就是看能否获取到资源,在acquired中,当tryAcquired方法返回false时(也就是获取资源失败),才会去执行后面的方法。按照我们文章开头说的AQS的思想,想这些获取资源失败的该咋整?没错,就是将这些线程包装成节点入队。

addWaiter(Node.EXCLUSIVE):

private Node addWaiter(Node mode) {
    	//独占锁的方式,将当前线程以独占锁的方式生成为节点。
        Node node = new Node(Thread.currentThread(), mode);
        Node pred = tail;
        //尾节点,如果尾节点不为空,那么就把新增加的节点放在原先的尾节点的后面
        if (pred != null) {
            node.prev = pred;
            //那么此时尾节点就要是新加入的节点,CAS替换,主要是为了防止其他线程也要向队尾插入节点。
            if (compareAndSetTail(pred, node)) {
                //此处为释放锁时,为什么要从后向前找:至此,只是前面的节点指向了原先尾节点。而原先尾节点还没有指				//向当前节点,所以如果从前向后找的话,那么相当于当前节点还没有入队
                pred.next = node;               
                return node;                    
            }
        }
        //如果尾节点为空,说明队列还没有初始化
        //一个for循环,其实就是为了保证节点能够成功插入到队列中去。
        enq(node);
        return node;
    }

将线程以独占锁的方式生成为节点,如果此时存在为节点,那么就将当前线程指向尾节点。如果尾节点为空,那么说明队列还没有初始化,接下来执行队列初始化操作,也就是enq方法。

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

在enq方法中,采用了for循环,目的就是为了保证不管队列中有没有节点,最终都会让线程入队。虽然在之前的方法中判断过尾节点是否为空了,但是在这里还是有必须再次判断一下的,因为在这个过程期间,极端情况下可能会有别的线程去初始化队列。

至此,就已经将未获取资源的线程以节点的形式排队到同步队列中了。线程虽然是入队了,但是入队之后还又一个问题:线程是要一直请求资源,还是进入睡眠等待状态呢?这个时候发现在addWaiter方法外面还有一个方法:acquireQueued。关于线程入队后怎么处理,就全交给这个方法了。

acquiredQueued方法:

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;//是否中断
            for (;;) {
                //在上一步addWaiter方法的时候,就已经将当前节点插入到队列尾中了
                //p为当前节点的pre节点。
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {//如果是头节点,并且当前节点获取同步锁成功了。
                  //注意是当前节点,而不是前驱节点,因为前驱节点是一个空节点。
                    setHead(node);//那么就将当前节点设置为头节点,node的thread设置为null,修改的仅仅是指向的						//线程,对于节点的waitStatus,是不会修改的。
                    //将原先的头节点从队列中删除,因为此时当前节点已经获取到了锁,成为新的头节点,原先的头节点也					//就没有必要存放到队列中了,同时也可以将他在GC的时候回收掉
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                //说明前驱节点不为头节点并且没有获取到同步锁
                //判断当前线程是否需要阻塞
                if (shouldParkAfterFailedAcquire(p, node) &&
                        parkAndCheckInterrupt())
                    //以上执行完之后,会执行selfInterrupt
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

线程入队之后,如果所有的线程都要不断地自旋请求获取资源,那么会很消耗cpu的,所以对于大部分等待的线程,都是可以将其阻塞掉的。比如队列中的第四个、第五个节点,因为队列是先进先出的,所以他的前面还排有线程,他就没有必要一直去请求,阻塞掉就可以。阻塞线程的标准就是看这个线程的前驱节点是不是头节点,并且线程是否获取到了锁,如果这两者都不满足,那么就会尝试将这个线程阻塞掉,也就是后面的这两个方法:shouldParkAfterFailedAcquire、parkAndCheckInterrupt。

shouldParkAfterFailedAcquire:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        //pred是当前的节点的前缀节点
        int ws = pred.waitStatus;
        //如果pre节点是singal,表示他需要去唤醒他的后继节点。
        //如果pre的status是singal,表示当pre释放了同步状态或者取消了,会通知当前节点,所以当前节点
        //可以安心的阻塞了(相当于睡觉会有人叫醒)
        if (ws == Node.SIGNAL)
            return true;
        //如果状态大于0,表示为取消状态,需要将取消状态的节点从队列中移除,直到找到一个状态不是取消的节点为止
        if (ws > 0) {
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            //否则的话就会将前缀节点置为SIGNAL状态。
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

这个方法的目的就是将不符合申请资源的线程给阻塞掉,在前面的构造部分提到了,如果前驱节点的状态为SIGNAL,那么前驱节点在释放资源之后,会通知后续节点去获取资源。因此要将当前节点的前驱节点设置为SIGNAL状态。

至于parkAndCheckInterrupt方法就很简单了,将当前线程阻塞掉。:

private final boolean parkAndCheckInterrupt() {
        //阻塞当前线程
        LockSupport.park(this);
        //返回当前线程的中断状态
        return Thread.interrupted();
    }

至此,独占式获取锁已基本完成,其实回头看下,可以简单概括一下获取锁的过程:判断当前线程是否可以获取锁,如果获取锁失败,那么就将这个线程转换为一个节点指向原先队列的尾部,然后判断这个节点是否需要被阻塞,最终未获取到同步状态的线程就会进行自我中断。

释放锁

我们看下AQS中释放锁的方法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;
    }

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

因为在前面说过嘛,AQS它是采用的模板设计模式,它会开放一些方法让实现了该类的子类去自定义实现,在release方法中的tryRelease也是这样,而继承AQS的子类也是有很多,比如像ReentrantLock、ReentrantReadWriteLock,他们的具体实现不是完全相同的,这里以ReentrantLock为例,看下他是怎么实现tryRelease方法的。

// 判断当前线程能否被释放
protected final boolean tryRelease(int releases) {
	// 减少可重入次数
	int c = getState() - releases;
	// 当前线程不是持有锁的线程,抛出异常
	if (Thread.currentThread() != getExclusiveOwnerThread())
		throw new IllegalMonitorStateException();
	boolean free = false;
	// 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state
	if (c == 0) {
		free = true;
		setExclusiveOwnerThread(null);
	}
	setState(c);
	return free;
}

代码都用注释标记起来了,这个方法也好理解,setExclusiveOwnerThread(null)方法表示将当前线程设置为null。

现在回头来看release中的release方法,如果tryRelease方法返回true,那么release方法就会继续向下执行。获取头节点,判断头节点是否不为空,并且头节点的状态不为0,如果条件成立,那么就去唤醒后继节点。不过判断条件为啥是h!=null && h.waitStatus!=0呢?

h == null:Head还没初始化。或者虽然初始化了,但是节点还未来得及入队,Head没有被初始化为一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head=null的情况。如果waitStatus=0的话,说明后继节点对应的线程在运行,不需要唤醒(因为下面的unparkSuccessor方法就是唤醒后继节点)

unparkSuccessor:

private void unparkSuccessor(Node node) {
        //获取当前节点状态
        int ws = node.waitStatus;//这个node是头节点
        //如果状态小于0,那么cas设置为0。因为当前节点已经释放锁了,所以把他的状态设置为0。
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        Node s = node.next;
        //如果后继节点为空,或者后继节点的等待状态大于0(为取消状态),
        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;
                //为啥要从后向前找,可以看上面获取锁的addWaiter方法
        }
        if (s != null)
            //唤醒后继节点。
            LockSupport.unpark(s.thread);
        //唤醒后会执行parkAndCheckInterrupt
    }

这个方法就描述了将后续节点唤醒,至于为啥从后向前找可用节点,可以去上面的addWaiter方法去查看,我在里面添加上了注释。线程唤醒后会执行parkAndCheckInterrupt方法,这个方法返回的是当前执行线程的中断状态,并清除。

parkAndCheckInterrupt:

private final boolean parkAndCheckInterrupt() {
        //阻塞当前线程
        LockSupport.park(this);
        //返回当前线程的中断状态
        return Thread.interrupted();
    }
共享式
获取锁
public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)//以共享模式获取对象,如果获得失败,才调用do方法
            doAcquireShared(arg);
    }

tryAcquireShared方法同样是AQS开放给子类进行扩展的方法,对于不同的同步器,他们的具体实现也是不尽相同的,这里就以CountDownLatch的tryAcquireShared为例:

 protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

虽然是共享锁,但是在CountDownLatch中,只有getState为0时,才会允许线程去获取资源,也就是只能有一个线程获取锁。如果获得锁失败,那么就回去调用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);//得到剩余的资源,注意的是,尽管是共享模式获取节点,但
                    //是有的实现了aqs的类中的节点其实也是只允许一个节点来获取资源。
                    if (r >= 0) {//说明还有剩余资源
                        //设置头节点,如果还有剩余资源,那么唤醒后继节点获取资源
                        setHeadAndPropagate(node, r);//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);
        }
    }

首先进来的就是addWaiter方法,这个方法其实我们在前面看独占式的时候,就已经看过了,唯一不同的是这里的节点类型为共享式。接下来就是根据头节点、共享资源来决定是否需要挂起、是否可以唤醒等。

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

可以看到,tryReleaseShared方法仍然是交给子类去自定义实现的,我们仍然以CountDownLatch为例,看看CountDownLatch是怎么实现他的:

protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c-1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }

这个for循环就很熟悉了,就是一直在自旋,他会判断资源的state是否为0,因为资源的state为0的话,也就是没有线程持有锁,也就不能释放锁。如果不为0,那么就会使用CAS尝试将它的值减1,如果state减1之后为0了,那么就执行doReleaseShared方法,也就是释放锁。

private void doReleaseShared() {
        for (;;) {
            Node h = head;
            //如果头节点不为空 && 头节点不等于尾节点,说明存在有效的node节点。
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                //如果头节点状态为signal,说明存在需要唤醒的后继节点,
                if (ws == Node.SIGNAL) {
                    //将头节点状态更新为0(初始值状态),因为此时头节点已经没用了
                    //continue为了保证替换成功。
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    //唤醒后继节点
                    unparkSuccessor(h);
                }
                //如果状态为0,那么就设置成PROPAGATE状态,确保在释放同步状态时能通知后继节点。
                else if (ws == 0 &&
                        !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }

在这里,我已经将代码的意思给标记出来了,而unparkSuccessor唤醒后继节点方法,其实跟独占式释放锁的unparkSuccessor方法是共用的,如果unparkSuccessor方法看不太懂的话,可以向上翻翻之前的的方法注释。

写在最后

至此,关于AQS的独占式、共享式的获取、释放锁的主体步骤已经解析的差不多了,要看明白AQS的代码还是需要花费一点时间的,不过我觉得看懂代码只是第一步,理解他的思想才是最重要的。

哦,对了,还有一个条件队列,关于AQS的条件队列,我会放到下一篇中去介绍。码了这么多字,觉得还可以的话,不妨点个赞呗,如果有问题,也欢迎提出,我及时修改哈。


条件队列已更新:
AQS:条件队列的源码解读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值