从ReentrantLock的实现了解AQS(下)

4 篇文章 0 订阅
3 篇文章 0 订阅


上节在讨论ReentrantLock的过程中,我们遇到了几个在AQS中实现的方法,比如AQS的核心逻辑实现 acquire、判断是否有前辈的方法 hasQueuedPredecessors、尝试占锁的方法 compareAndSetState、拿到锁之后设置锁的占有线程方法 setExclusiveOwnerThread等。这次我们通过这些方法的实现细节,来了解一下AQS整个的内部结构。

锁状态存储

之前在介绍Synchronized关键字的时候,我们知道synchronized的锁状态其实最终是存在对象的头部,有当前锁的状态,拿到锁的线程等等。而AQS想要单独实现锁的逻辑,那么这些锁的基本信息肯定不能缺失,在AQS中,这些信息直接保存在内部的私有属性中,对外只提供了两个方法去设置锁的属性。就是我们上边提到的compareAndSetStatesetExclusiveOwnerThread

State

	/**
     * The synchronization state.
     */
    private volatile int state;

首先,这个是AQS内部最重要的一个字段,它是一个int类型,表示当前锁的同步状态,状态可以分为三类:

  • 0 :空闲状态,没有被任何线程持有
  • 1 :已经被线程拿到锁,正在处理中
  • 大于1:锁的重入状态,值是多少,重入的次数就是多少

这个字段AQS只对外部提供了一种方式可以更改,就是compareAndSetState(),从名字就可以看出来,它是一个原子操作,这里就是AQS拿锁的实现,用CAS的操作保证拿锁的原子性,用volatile关键字保证可见性,让锁状态可以及时同步给所有正在抢占的线程。拿锁成功后,CAS返回true,失败返回false。具体内部CAS的实现是调用了原生方法,Java层看不到

protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

exclusiveOwnerThread

public abstract class AbstractOwnableSynchronizer
    implements java.io.Serializable {

    protected AbstractOwnableSynchronizer() { }

    private transient Thread exclusiveOwnerThread;

    protected final void setExclusiveOwnerThread(Thread thread) {
        exclusiveOwnerThread = thread;
    }
    protected final Thread getExclusiveOwnerThread() {
        return exclusiveOwnerThread;
    }
}

compareAndSetState方法我们可以拿到一个锁,但是如何知道锁是被谁拿到了呢?那就是exclusiveOwnerThread,这个属性是在AQS的父类AbstractOwnableSynchronizer中保存,这个类的唯一属性就是它,只用来保存当前拿到锁的线程。当线程通过上边的CAS操作如果拿到锁,就可以用setExclusiveOwnerThread()这个方法将自己的信息设置进去,表示该锁被自己占有。

线程队列

拿锁是简单,但是怎么管理那些没有拿到锁的线程,如何安抚他们的情绪,不让他们占用无意义的资源才是AQS真正复杂的部分。我们知道,拿锁就是一个CAS,但是如果同时几十个线程同时来拿锁,只有一个能拿到锁,其他没拿到锁的线程怎么办呢?既不能让他们直接失败,又不能让他们一直无意义的自旋一直做CAS浪费资源,所以这个线程队列,就是AQS用来让他们暂时休息的地方。我们看下AQS中队列的基本信息。

  private transient volatile Node head;
    
  private transient volatile Node tail;

队列节点

我们一直说队列队列,但是最终AQS中存的根本没有队列的属性,只有一头一尾。没错,AQS中线程的存储结构是用AQS内部类Node实现的一个双向队列,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;
        static final int CONDITION = -2;
        static final int PROPAGATE = -3;
        volatile int waitStatus;
        volatile Node prev;
        volatile Node next;
        volatile Thread thread;
        Node nextWaiter;
    
        final boolean isShared() {
            return nextWaiter == SHARED;
        }
        
        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内部的设计并不只是简单的实现了锁的双向队列,它还包含了AQS提供的其他的锁的扩展功能,包括独享锁,共享锁,还有Condition条件锁等,我们上节介绍的ReentrantLock就是独享锁,也是我这两篇文章核心介绍的内容,共享锁是同样对AQS扩展实现的ReentrantReadWriteLock读写锁,它将锁分成两个状态,读的状态下可以支持线程并发读取,这就是所谓的共享。Condition是ReetrantLock的一个特色,它跟wait、notify方法类似,用来调配线程。所以它也是基于AQS实现的。共享锁跟Condition不是我们的重点,我们先抛开这两部分,注意力放在独享锁的部分。其实我们要看的部分非常简单。

 volatile Node prev;
 volatile Node next;
 volatile Thread thread;
 volatile int waitStatus;

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

没错,就是这部分,双向队列内部肯定首先是由线程组成的,然后双向肯定是指每个Node需要知道自己的前一个prev和后一个next。还有就是每个节点的唤醒状态,来控制线程应该睡眠还是被唤醒,我们可以先暂时只关注这部分。

队列操作方法

对应着这个双向队列,AQS提供了非常多的队列的操作方法,但是最终,真正入队的操作只有两个,就是通过CAS操作要么插入队头,要么插入队尾。为什么插入队列还需要CAS呢?那当然了,一旦发生了竞争,同时那么多线程要一起插入队头或者队尾,肯定要保证最终这个双向队列是原子的呀。

    /**
     * CAS head field. Used only by enq.
     */
    private final boolean compareAndSetHead(Node update) {
        return unsafe.compareAndSwapObject(this, headOffset, null, update);
    }

    /**
     * CAS tail field. Used only by enq.
     */
    private final boolean compareAndSetTail(Node expect, Node update) {
        return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
    }
hasQueuedPredecessors()

除了上边最重要的两个入队方法,还有一个不得不提一下,那就是我们文章开始就提到的hasQueuedPredecessors()方法,没错,终于在这里见到它了,前边讲过公平锁的实现就是用这个方法来得知自己是否可以优先尝试拿锁的,我们看下一下,虽然这个方法的内容少,但是内部的逻辑比较绕。

  public final boolean hasQueuedPredecessors() {
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

分析之前,要先带一个结论去看就是,这个方法返回false代表没有前辈,返回true就代表有前辈。分析如下:

  1. 头节点不等于尾节点的情况下才继续执行,否则就说明现在队列头等于尾,要么队列里没有节点,要么只有一个节点(***队列中如果只有一个节点肯定是空节点,这个下面会说道***),返回false。(所以公平锁那块的调用增加了!符号来将逻辑摆正,代表可以尝试拿锁)
  2. 头节点不等于尾节点的情况下,头节点的下一个节点是空的,直接断路返回true(这个是因为我们上边说过,如果队内只有一个节点,那肯定头是等于尾的,但是现在头不等于尾,所以肯定不止一个节点,况且头节点的下一个节点虽然是空的,但肯定不是我自己呀,因为我还没有入队呢,只是看看有没有前辈,那肯定有其他的线程在入队过程中,我只能往后排),否者继续进行下个条件。
  3. 头节点不等于尾节点的情况下,如果下一个节点是我自己,就返回false(这个是因为重入锁的情况下,虽然我没入队,但是我已经拿到过锁了,那我自己就是前辈了。),如果下个节点不是我自己就代表确实是有前辈,那就返回true。

acquire(核心)

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

终于到了这个方法,这个我们之前就说过,这个是AQS实现锁最核心的逻辑,这段代码可以概括成两部分,第一部分就是调用tryAcquire()进行拿锁,如果拿锁直接用!符号进行阻断下一步。否则就进行第二部分入队。

拿锁

第一部分的tryAcquire拿锁方法应该不用再多说了吧,我们上节公平锁和非公平锁的拿锁实现,就是重写的这个方法,因为不重写这个方法的话,AQS直接默认就把拿锁的方法抛出异常了,具体的实现我们上节也都解释过了。

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

安排入队睡眠

第二部分就是核心的入队操作了,线程入队不仅仅只做了入队操作。我把流程分成了入队、尝试唤醒、睡眠等待三部分。我们挨个看下

入队

首先既然到了入队这里,不论后续是不是也有可能在睡眠之前就该我拿锁了,都不管!先老老实实的入队。入队这块有一个重要的逻辑就是关于AQS 双向队列的设计,如果在入队之前的所有拿锁操作都特别顺利,代表线程是交替执行的,这时候AQS中的队列一直是空的,不需要初始化出来。但是一旦要入队了,那就说明起码有最少两个线程同时抢占锁,AQS用了一个空的头Node来代表多个线程竞争中已经拿到锁的线程节点,从第二个Node开始才代表等待着的线程。这个就是为什么上边我有提到,如果队列中只有一个节点,那么一定是一个空节点。我们看下入队方法

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

以等待者入队的逻辑还是比较清晰,简单概括就是如果队列中有节点,那就直接拼接在队列的尾部。但是还有一种情况是队列是空的,头节点跟尾节点都是null,怎么搞?

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

这块入队代码就验证了我上边提到的,哪怕是队列第一次初始化出来,也代表有多个线程在竞争,用new Node()添加一个空的node到队头,然后才将后边没拿到锁的线程节点加入到尾部。

那让头节点一直是空的作用是什么呢?我们继续往下看

二次确认是否进入睡眠

进队列之后,有两个操作,第一个是如果进队之后,发现前边已经没人了,其实应该轮到自己拿锁了,那就直接去尝试拿锁。否者的话第二个操作就是去睡眠。但是真的是这么简单吗?

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;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
发现可以拿锁

首先说一下自己能拿锁,并且通过tryAcquire方法拿到了锁的处理,我上边提过很多次一旦线程拿到锁,那这个线程就要变成头节点,头节点的首要特征就是null,那让头节点一直是空的作用是什么呢?不论是拿到锁还是唤醒锁,我都只找到了对于线程的操作,但是最终这些线程的Node节点是怎么出队的,一直没有代码体现,上边其中的两行代码给我们做了这个操作。

 setHead(node);
 p.next = null; // help GC
private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
}

首先第一步就是调用setHead方法将当前节点设置为头部,然后将节点的thread,prev都设置成了null?然后紧接着又将上一个头结点的next设置为null? emmm,对上了。

线程节点在编程头节点的时候,内部的thread和prev属性已经成了null,正好要是下次新的节点要替换它的时候,将它的next属性也设置成null,这不就正好头节点完全成了Null了嘛,直接被GC回收,这样就不用非得我们手动让头结点出队了吧?设计的很巧妙。。

发现确实没希望

如果发现自己确实没有希望可以拿到锁,那就进入睡眠。但是睡眠之前干了什么呢?这是AQS设计又一个巧妙的地方。节点线程睡眠之前自旋了两次,先用shouldParkAfterFailedAcquire方法将自己前边的节点改成已睡眠,然后才去调用parkAndCheckInterrupt方法自己进入睡眠。为什么要这么做呢?

确认前辈睡了
  private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            return true;
        if (ws > 0) {
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

为什么要先确认前辈先睡着了呢?我的理解是因为如果线程在睡之前先改自己的waitStatus状态,改成了已睡眠,但是实际上如果真后续出了什么问题,导致一直没睡下那这个就是假状态,假状态就会完全超出我们的控制范围。所以AQS的设计就是先睡下,然后让后边的线程来确认你睡下了。

我也休息了

终于该我休息了

  private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

AQS的线程最终是被用LockSupport.park方法阻塞睡下的,至于为什么用这个?wait和notify都需要配合synchronized使用,还有其他方式吗?应该是有哈,但是LockSupport肯定是最合适的一种。

总结

本片文章分为两小节,和下,通过跟踪ReetrantLock中lock的实现,我们终于对AQS有了一个比较全面的认识,本篇所有的文章只是围绕着lock加锁的逻辑来做主线跟踪,AQS的实现远比我讲的复杂的多,具体锁的释放以及唤醒等等这些,暂时就先不包含在内了,如果大家感兴趣可以根据AQS上锁的思路,去想想释放锁的时候都需要做什么,可以翻翻它的源码。了解AQS的实现有助于提高我们的基础水平,用锁的时候,更加的得心应手。

本文纯个人理解,如有错误,帮忙指正一下,共同进步,感谢!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值