从ReentrantLock看AQS的独占式原理解析


前言

Java中的大部分同步类都是基于AQS实现的。AQS是一种基于模板方法模式的线程同步框架,提供了独占式EXCLUSIVE和共享SHARED两种模式的同步模板方法。


1.原理概述

AQS的核心思想是如果被请求资源是空闲状态,那么就将当前请求资源的线程设为有效的工作线程,将共享资源设为锁定状态。否则如果共享资源被占用,就需要一套阻塞唤醒线程的机制来保证锁的分配。这个机制主要依靠CLH队列实现,那些获取锁失败的线程会封装成节点加入该队列。

AQS使用一个volatile修饰的int类型的变量state表示同步状态,通过FIFO的双向队列(即CLH队列)完成资源获取排队操作,通过CAS完成对state值的修改。具体如下图所示:在这里插入图片描述

2.数据结构

①Node节点

static final class Node {
        /** Marker to indicate a node is waiting in shared mode */
        static final Node SHARED = new Node();
        /** Marker to indicate a node is waiting in exclusive mode */
        static final Node EXCLUSIVE = null;

        /** waitStatus value to indicate thread has cancelled */
        static final int CANCELLED =  1;
        /** waitStatus value to indicate successor's thread needs unparking */
        static final int SIGNAL    = -1;
        /** waitStatus value to indicate thread is waiting on condition */
        static final int CONDITION = -2;
        /**
         * waitStatus value to indicate the next acquireShared should
         * unconditionally propagate
         */
        static final int PROPAGATE = -3;
		//当前节点在队列中的状态
        volatile int waitStatus;
		//前驱指针
        volatile Node prev;
		//后继指针
        volatile Node next;
		//当前节点的线程
        volatile Thread thread;
		//指向下一个处于CONDITION状态的节点
        Node nextWaiter;

        /**
         * 共享模式返回true
         */
        final boolean isShared() {
            return nextWaiter == SHARED;
        }

        /**
         * Returns previous node, or throws NullPointerException if null.
         * Use when predecessor cannot be null.  The null check could
         * be elided, but is present to help the VM.
         *
         * 返回前驱节点
         */
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {    // Used to establish initial head or SHARED marker
        }

        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

在上面原理概述说过,线程会被封装成Node节点,先来看下Node这个数据结构。

属性值和方法含义
waitStatus当前节点在队列中的状态
prev前驱指针
next后继指针
thread当前节点的线程
nextWaiter指向下一个处于CONDITION状态的节点
predecessor返回前驱节点,没有的话抛出npe

waitStatus有几个枚举值,具体含义在源码中注释写的很明白了,这里简单记录一下

waitStatus含义
CANCELLED,1表示取消线程获取锁的请求
SIGNAL,-1表示线程已经准备好,就等资源释放
CONDITION,-2表示节点等待在等待队列中,节点线程等待唤醒
PROPAGATE,-3共享模式下使用,表示需要向下传递
INITIAL,0Node初始化时的默认值

②同步状态State

private volatile int state;

在AQS源码中,操作state变量的方法都是final修饰的,说明自类无法重写它们。而且对state值的修改都是原子操作,通过CAS实现。

3.源码分析

我们先看一下ReentrantLock中非公平锁的加锁代码实现,即AQS中独占式锁的实现相关

static final class NonfairSync extends Sync {
    ...
    final void lock() {
    	//如果加锁时CAS修改同步状态成功,即获取锁成功,就将当前线程设置为独占线程
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
        	//获取锁失败,进入acquire方法进行后续操作
            acquire(1);
    }
  ...
}

acquire方法点进去是在AQS类中,代码如下

public final void acquire(int arg) {
	//通过tryAcquire方法尝试获取锁,如果获取成功就不再向后执行,否则,就要将当前线程加入等待队列
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

acquire方法流程如图所示:
在这里插入图片描述

tryAcquire方法在AQS中只是定义了一个模板方法,具体实现是在子类中实现的

// java.util.concurrent.locks.AbstractQueuedSynchronizer
//AQS中protected修饰的方法都在子类中有对应的实现
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

例如在ReentrantLock的独占锁实现如下

protected final boolean tryAcquire(int acquires) {
			//重写tryAcquire方法,调用自己的nonfairTryAcquire方法
            return nonfairTryAcquire(acquires);
        }
final boolean nonfairTryAcquire(int acquires) {
			//获取当前线程
            final Thread current = Thread.currentThread();
            //获取同步状态(共享资源)
            int c = getState();
            //如果共享资源没有被占用
            if (c == 0) {
            	//CAS占锁
                if (compareAndSetState(0, acquires)) {
                	//占锁成功,将线程设置为独占线程,返回true
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //当前线程是占锁线程
            else if (current == getExclusiveOwnerThread()) {
            	//重复上锁
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                //设置新的同步状态值,返回true
                setState(nextc);
                return true;
            }
            return false;
        }

上面说过,如果尝试获取资源失败,会进入acquireQueued(addWaiter(Node.EXCLUSIVE), arg)尝试线程节点获取锁方法,下面先详细说一下addWaiter入队方法,只有先将线程封装成节点入队之后才有可能去竞争释放的资源。

private Node addWaiter(Node mode) {
		//将当前线程和锁模式(独占/共享)封装成一个Node节点
        Node node = new Node(Thread.currentThread(), mode);
        //pred指针指向CLH队列尾节点
        Node pred = tail;
        //如果pred不为null,说明队列中有节点
        if (pred != null) {
        	//将新节点的前驱指针指向pred
            node.prev = pred;
            //通过CAS操作完成尾节点的设置(即将当前节点设为尾节点)
            //这个方法主要是对tailOffset和Expect进行比较,如果tailOffset的Node和Expect的Node地址是相同的,那么设置Tail的值为Update的值
            //private final boolean compareAndSetTail(Node expect, Node update)
            //return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        //如果pred是null,说明队列中没有节点或者当前pred指针和tail指向的位置不同(说明被别的线程修改了)那么就要看一下enq方法的实现
        enq(node);
        return node;
    }
    
    //enq方法
    private Node enq(final Node node) {
    	//一个无限循环(即自旋)
        for (;;) {
        	//获取tail节点的指针
            Node t = tail;
            //如果是null,就需要进行初始化,下面的原注释如此
            if (t == null) { // Must initialize
            	//初始化一个头节点,注意这个头节点不是当前线程节点,而是调用Node的一个无参构造方法的节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
            	//如果是尾节点被其他线程修改,那么获取新的尾节点之后再次使用CAS将当前线程节点入队设为尾节点
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

综上,可以看出,addWaiter()方法就是将当前线程节点入队并设为尾节点的操作,需要注意的是,这个队列的头节点是一个无参构造的头节点。这个入队流程如下图所示:在这里插入图片描述
addWaiter()方法执行完成后返回了包含当前线程的节点,而这个节点会作为参数,进入acquireQueued方法,该方法会对队列中的线程进行获取锁的操作。总的来说,一个线程获取锁失败,就被放入等待队列,acquireQueued会把队列中的线程不断去获取锁,直到成功或者中断。下面从“何时出队列”和“如何出队列”两方面分析一下其源码:

final boolean acquireQueued(final Node node, int arg) {
		//标志是否成功获取资源
        boolean failed = true;
        try {
        	//标记等待过程中是否中断
            boolean interrupted = false;
            //自旋,要么获取锁,要么中断
            for (;;) {
            	//获取当前节点的前驱节点,这个方法在Node类中说过
                final Node p = node.predecessor();
                //如果p是头节点,说明当前节点在队列的前端,就可以去尝试获取资源,因为前面说过队列的头节点是个无参构造的节点,实际可以获取资源的是头节点的下一个节点
                if (p == head && tryAcquire(arg)) {
                	//获取资源成功,将当前节点设为头节点,清除thread属性和前驱节点属性
                    setHead(node);
                    //将p节点置为null,方便GC回收
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                //`shouldParkAfterFailedAcquire`百度翻译是失败后是否应停止获取,所以下面这段代码意思明了了,即p是头节点且当前节点线程没有获取到锁或者p压根就不是头节点,这个时候就要判断当前节点是否要被阻塞,防止无限循环浪费资源
                //注意这里`shouldParkAfterFailedAcquire`返回true的情况只能是前驱节点处于signal状态,这样才可能阻塞当前线程;否则,当前线程继续自旋尝试获取锁
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
        	//最终获取资源失败
            if (failed)
            	//取消获取资源
                cancelAcquire(node);
        }
    }
    
    //shouldParkAfterFailedAcquire,通过前驱节点判断当前线程是否该被阻塞
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    	//前驱节点的状态值
        int ws = pred.waitStatus;
        //如果前驱节点处于SIGNAL(唤醒)状态,只等资源释放
        if (ws == Node.SIGNAL)
            //那么当前节点可以阻塞,返回true继续`parkAndCheckInterrupt()`执行
            return true;
        //ws>0代表取消状态
        if (ws > 0) {
            //前驱节点已经因为超时或响应了中断取消了当前线程节点,所以需要跨越掉这些CANCELLED节点,直到找到一个<=0的节点
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            //通过CAS设置前驱节点的等待状态为signal
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        //返回false,当前线程继续自旋尝试获取锁
        return false;
    }
    
    //`parkAndCheckInterrupt`主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。
    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

出队流程如下图所示:在这里插入图片描述
在前面有判断前驱节点状态为等待状态的情况,那么取消节点是怎么生成的?又是在什么时间释放节点通知到被挂起的线程呢?
acquireQueued方法的finally块中,有个cancelAcquire方法,将Node的状态标记为CANCELLED。代码如下:

private void cancelAcquire(Node node) {
        // 将无效的节点过滤
        if (node == null)
            return;
        //将节点的线程设为null,当前节点成为虚节点
        node.thread = null;
        //获取当前节点的前驱节点
        Node pred = node.prev;
        //通过前驱节点,跳过取消状态的节点,将找到的节点作为前驱节点
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;
        //获取过滤后的前驱节点的后继节点
        Node predNext = pred.next;
        //将当前节点设置为取消状态
        node.waitStatus = Node.CANCELLED;
        //如果当前节点是尾节点,将上面获取的当前节点的前驱节点设为尾节点
        if (node == tail && compareAndSetTail(node, pred)) {
        	//更新成功,将尾节点的后继节点设为null
            compareAndSetNext(pred, predNext, null);
        } else {
            //更新尾节点失败
            int ws;
            //如果前驱节点不是头节点
            //1、前驱节点是signal
            //2、前驱节点不是signal和cancel,并且设置为signal成功
            //1或2满足一个条件,并且前驱节点的线程不是null
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                //将前驱节点的后继节点设为当前节点的后继节点
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
           		//当前节点是头节点的后继节点或上述条件不成立,唤醒当前节点
                unparkSuccessor(node);
            }
            //将当前节点出队
            node.next = node; // help GC
        }
    }

在上述代码中,一直是在对Next指针做修改,而没有对Perv指针操作,这是为什么?何时会对Prev指针进行操作?
执行cancelAcquire()方法时,可能prev在acquireQueued方法的try代码块中已经出队了,如果此时修改prev指针,可能会导致指向移除队列的节点,不安全。
shouldParkAfterFailedAcquire方法中,执行了node.prev = pred = pred.prev;,这行代码是在获取锁失败的情况下执行的,这时共享资源已被占用,当前节点之前的节点都不会再变化,这时候变更prev更安全。


上面我们对AQS的加锁过程做了介绍,下面对于AQS的解锁流程做一下简单的分析。 先看下ReentrantLock的`unLock`方法,代码如下:
public void unlock() {
        sync.release(1);
    }

可以看出,解锁并不会区分公平锁和非公平锁,最终都是通过继承AQS的sync子类调用AQS的release方法,代码如下:

public final boolean release(int arg) {
		//尝试释放锁,同尝试获取锁类似,这是个模板方法,具体实现看子类实现
		//如果成功,说明资源已被释放
        if (tryRelease(arg)) {
        	//头节点
            Node h = head;
            //头节点不为null且头节点不是初始化状态
            //h==null代表还没有节点入队
            //h.waitStatus == 0代表头节点刚刚初始化
            if (h != null && h.waitStatus != 0)
            	//解除线程挂起状态,该线程可以去获取资源
                unparkSuccessor(h);
            return true;
        }
        //尝试释放锁失败,释放锁失败
        return false;
    }
   
   //ReentrantLock中尝试释放锁的具体实现
   protected final boolean tryRelease(int releases) {
   			//释放后占用次数
            int c = getState() - releases;
            //当前线程不是资源占用线程
            if (Thread.currentThread() != getExclusiveOwnerThread())
            	//抛出异常
                throw new IllegalMonitorStateException();
            //资源释放标志
            boolean free = false;
            if (c == 0) {
            	//如果资源锁定次数为0,即已被释放,标志置为释放
                free = true;
                //将资源占用线程设为null
                setExclusiveOwnerThread(null);
            }
            //修改state值
            setState(c);
            //返回是否资源释放
            return free;
        } 
       
       //看一下唤醒线程的方法 
       private void unparkSuccessor(Node node) {
        //头节点的等待状态
        int ws = node.waitStatus;
        //小于0,将头节点设为初始化状态
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        //获取头节点的后继节点
        Node s = node.next;
        //如果下个节点是null或下个节点处于取消状态,找队列最开始的非canceled状态节点
        if (s == null || s.waitStatus > 0) {
            s = null;
            //从尾节点向前寻找队列中第一个waitStatus<=0的节点
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        //如果头节点的下个节点不为null,就把头节点的后继节点唤醒
        if (s != null)
            LockSupport.unpark(s.thread);
    } 

上面就是释放锁的整个流程了,相对于加锁,释放锁就显得简单明了了。总结一下就是,线程释放锁,由于是可重入锁,所以只有将资源的所有重入次数清零,资源才算是真正释放,这时,AQS就会唤醒等待队列的“头节点”(注意这里加了引号,因为不是真正的头节点,而是等待队列中所有有效的等待节点的第一个)去获取资源。

4.AQS的应用

除了ReentrantLock的可重入性应用,AQS作为并发编程框架,还向其他同步工具提供了应用。

同步工具同步工具与AQS的关联
Semaphore使用AQS同步状态来保存信号量的当前计数。tryRelease会增加计数,acquireShared会减少计数。
CountDownLatch使用AQS同步状态来表示计数。计数为0时,所有的Acquire操作(CountDownLatch的await方法)才可以通过。
ReentrantReadWriteLock使用AQS同步状态中的16位保存写锁持有的次数,剩下的16位用于保存读锁的持有次数。

当然,我们也可以自定义自己的同步工具,去实现一些功能。如下,是一个简单实现:

//自定义的锁
public class AqsDemo {
    private static class Sync extends AbstractQueuedSynchronizer{
        //重写尝试获取锁
        @Override
        protected boolean tryAcquire(int arg){
            return compareAndSetState(0,1);
        }

        //重写尝试释放锁
        @Override
        protected boolean tryRelease(int arg){
            setState(0);
            return true;
        }

        //是否持有资源
        @Override
        protected boolean isHeldExclusively(){
            return getState() == 1;
        }
    }

    private Sync sync = new Sync();

    public void lock(){
        //调用aqs的占资源方法
        sync.acquire(1);
    }

    public void unlock(){
        //调用aqs的释放资源方法
        sync.release(1);
    }
}
测试类,无论运行多少次,count都是2000,当然,这里我用sleep阻塞主线程,让A和B线程执行,其实这不是一个好的方法,仅作为demo测试使用。
public class TestAqsDemo {
    static int count = 0;
    final static AqsDemo aqsDemo = new AqsDemo();

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            aqsDemo.lock();
            System.out.println(Thread.currentThread().getName()+"运行");
            for(int i=0;i<1000;i++){
                count++;
            }
            aqsDemo.unlock();
        },"A").start();
        new Thread(()->{
            aqsDemo.lock();
            System.out.println(Thread.currentThread().getName()+"运行");
            for(int i=0;i<1000;i++){
                count++;
            }
            aqsDemo.unlock();
        },"B").start();
        Thread.sleep(5000);
        System.out.println(count);
    }
}

总结

这篇只针对AQS的独占模型的部分代码进行了分析,而共享式也是很重要的一块内容,需要再去阅读一下代码,丰富一下自己的知识。我觉得文中的几个流程图对于理解代码有很好的帮助,可以考虑自己跟着流程图去阅读源码。最后,再次感谢开源作者的分享。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

雅俗共赏zyyyyyy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值