AQS_2_独占锁

99 阅读12分钟

AQS独占锁

独占获取锁:acquire

在AQS中独占锁通过acquire方法获取锁

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

执行顺序为:tryAcquire -> addWaiter -> acquireQueued。

tryAcquire

AQS的模板方法,也是自定义独占锁需要实现的方法,能不能获取锁成功取决于该方法的返回值,如果返回为true,则整个if条件表达式为false,正常执行,说明获取锁成功;如果返回false,则进入后续阻塞入队操作。这个在前面自定义独占锁已经有相关实现了,后面会分析ReentrantLock的相应实现。

addWaiter(Node.EXCLUSIVE)

如果前面的tryAcquire返回为false了,代表获取锁失败了,则需要调用该方法针对线程生成一个阻塞节点Node,并且线程类型为Node.EXCLUSIVE独占的。并将节点添加到阻塞队列尾部。

代码如下:

 // acquire调用:addWaiter(Node.EXCLUSIVE)
 private Node addWaiter(Node mode) {
     // 创建一个Node,该Node.thread指向当前线程
     /*
         Node(Node nextWaiter) {
             this.nextWaiter = nextWaiter;// 新建的Node后续节点为Node.EXCLUSIVE,该节点为null,代表了独占模式
             THREAD.set(this, Thread.currentThread());
         }
     */
     Node node = new Node(mode);
     
     // 自旋,尝试将新阻塞的节点加入队尾,或者初始化阻塞队列
     for (;;) {
         Node oldTail = tail;
         // 新阻塞的节点加入队尾
         if (oldTail != null) {
             node.setPrevRelaxed(oldTail);
             if (compareAndSetTail(oldTail, node)) {
                 oldTail.next = node;
                 return node;
             }
         } else {
             // 初始化队列↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
             initializeSyncQueue();
         }
     }
 }
 ​
 ​

initializeSyncQueue():

初始化阻塞队列,注意这个方法是在JDK1.9以后单独抽出来的,1.8不是这样的写法,是一个enq方法里面进行初始化,实现都一样,但是1.9代码更加精炼。

 /*
     初始化阻塞队列,java.util.concurrent.locks.AbstractQueuedSynchronizer#initializeSyncQueue
     如果头节点为null,则创建一个新节点作为头部节点和尾部节点
     等同于:
         Node  h  = new Node();  
         if(head == null){
             head = h;
             tail = h;
         }
 */
 private final void initializeSyncQueue() {
     Node h;
     if (HEAD.compareAndSet(this, null, (h = new Node())))
         tail = h;
 }

当线程完成了初始化同步队列,重新自旋,此时tail就不为空了则顺利将节点加入队尾,并返回node。

acquireQueued

前面addWaiter已经针对线程阻塞的前提工作做完了,包括创建阻塞节点,加入阻塞队列等操作,接下来就是该方法正式将线程阻塞已经阻塞后唤醒的逻辑了。

 final boolean acquireQueued(final Node node, int arg) {
     try {
         // 用于控制阻塞过程中是否被中断过,该变量用于最后返回,与acquire方法的selfInterrupt();相关联。
         boolean interrupted = false;
         // 通过自旋的方式,阻塞线程
         for (;;) {
             final Node p = node.predecessor();
             // 如果当前阻塞队列里面只有当前一个节点,则再次尝试获取锁。
             /*
                 为什么要有p == head这个条件,才尝试再重新获取锁?
                 这个p代表了当前节点的上一节点,如果上一节点为head说明阻塞队列里面只有当前一个线程,则可以尝试获取,如果阻塞队列已经等待着一堆线程了,说明已经阻塞了并且还未释放,因此没有必要再重新获取。
             */
             if (p == head && tryAcquire(arg)) {
                 setHead(node);
                 p.next = null; // help GC
                 return interrupted;
             }
             // shouldParkAfterFailedAcquire:判断节点是否应该阻塞
             // parkAndCheckInterrupt:执行阻塞,并返回在阻塞过程中是否有被中断
             if (shouldParkAfterFailedAcquire(p, node) &&
                 parkAndCheckInterrupt())
                 interrupted = true;
         }
     } catch (Throwable t) {
         cancelAcquire(node);
         throw t;
     }
 }

shouldParkAfterFailedAcquire(p,node):

该方法用于判断节点是否应该阻塞,以及对已经阻塞的节点更新其状态:其中有三个分支

1、如果前驱节点已经是阻塞,则当前节点毫无疑问也该阻塞。

2、如果前驱节点已经取消获取锁,则断开该节点。

3、 将前驱节点更新为阻塞状态

 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 {
         // 将前驱节点更新为等待唤醒
         pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
     }
     return false;
 }

提出一个问题:为什么这里要将前驱节点设置为等待唤醒状态,而不是将当前节点设置为等待唤醒状态?

答:因为此时node还没有阻塞,因此也就不存在等待唤醒这个说法。就好比你进入房间准备睡觉,睡着了后需要在房间门上挂一个已经睡着的牌子,这时候你在没有睡着的时候,肯定不能自己挂,只有当自己睡着了,下一个人来确定你的状态,已经处于睡眠了,然后再帮你在门上挂上已经睡着的牌子,等待唤醒,如果你自己挂已经睡着的牌子,很可能你刚挂上,马上就把你给叫起来,但是你这时候又没睡着就造成了错误。这也是为什么要有哨兵节点的原因之一,还要结合解锁源码来看,才能分析出具体原由

总结一下:这个操作的目的是为了在唤醒的时候,被唤醒节点一定是进入了阻塞状态,防止对一个未进入阻塞状态的节点执行唤醒出现未知的异常。

parkAndCheckInterrupt()

阻塞线程,并返回阻塞过程中线程是否中断过。

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

以上就是独占锁获取锁源码的全过程,可以看到当获取锁失败的时候,会创建一个阻塞节点,并将阻塞节点加入到阻塞队列中,然后调用park进行线程阻塞。

AQS-独占锁加锁逻辑.png

获取锁失败入阻塞队列时,阻塞队列状态:

image-20220607221021019.png

看了上面阻塞队列状态发现,当前被阻塞的节点阻塞后的waitStatus永远是等于0的,只有下一个阻塞节点入队的时候,才会将其更新为-1。为什么要这样干的原因上面已经有解释了,是为了保证被阻塞节点中线程一定是已经阻塞,阻塞后由下一个线程来更新前一个被阻塞的线程状态。

总结一下:waitStatus=0的情况下,线程节点节点代表即将阻塞还没入队的状态或者已经入队但是未队尾节点,头结点代表队列没有待唤醒节点;waitStatus=-1的情况下,线程节点代表已经被阻塞,头结点队列中存在待唤醒节点。

addWaiter:通过自旋进行初始化队列和将新的阻塞节点加入到队尾中。

acquireQueued:通过自旋阻塞获取锁失败节点和删除被唤醒获取到锁的节点无效节点(结合释放锁看)。

独占锁释放:release

 public final boolean release(int arg) {
     if (tryRelease(arg)) {
         // 如果头部节点不是需要唤醒状态,则不做处理
         /*
                 当head==null的时候,说明持有锁期间并没有被其它线程竞争,阻塞队列为初始化。
                 当head.waitStatus==0的时候,说明没有等待被唤醒节点,可以直接释放锁,不用执行唤醒操作
             */
         Node h = head;
         if (h != null && h.waitStatus != 0)
             // 唤醒等待节点,按照入队顺序
             unparkSuccessor(h);
         return true;
     }
     return false;
 }

tryRelease

AQS的模板方法,也是自定义独占锁需要实现的方法,能不能释放锁成功取决于该方法的返回值,如果返回为true,则整个if条件表达式为false,正常执行,说明释放锁成功;如果返回false,则释放锁失败,不做任何操作。这个在前面自定义独占锁已经有相关实现了,后面会分析ReentrantLock的相应实现。

unparkSuccessor(h);

唤醒阻塞节点,从头部开始

 private void unparkSuccessor(Node node) {
     int ws = node.waitStatus;
     // 重置head的waitStatus为0
     /*
         这里可以提出一个问题:在这里把head的waitStatus赋值为了0,如果队列里面还有多个未唤醒节点,此时是不是就满足不了上面release里面的 if (h != null && h.waitStatus != 0)条件了?
     */
     if (ws < 0)
         node.compareAndSetWaitStatus(ws, 0);
     // 真实线程节点
     Node s = node.next;
     // 如果阻塞节点为是>0也就是被取消的状态,则从队列尾部开始向前遍历,删除非阻塞节点,并同时定位到第一个需要被唤醒节点
     if (s == null || s.waitStatus > 0) {
         s = null;
         for (Node p = tail; p != node && p != null; p = p.prev)
             if (p.waitStatus <= 0)
                 s = p;
     }
     // 执行唤醒
     if (s != null)
         LockSupport.unpark(s.thread);
 }

image-20220608195216626.png

分析了上面释放独占锁源码,提出相应问题:

1、在unparkSuccessor这里把head的waitStatus赋值为了0,如果队列里面还有多个未唤醒节点,此时是不是就满足不了上面release里面的 if (h != null && h.waitStatus != 0)条件了?

2、通读了release源码后,按道理被唤醒节点应该没有用了,应该删除已经被唤醒的节点,但是整个release里面并没有删除阻塞节点的逻辑,那么是什么时候删除的呢?

回答上面两个问题,先看下阻塞和唤醒的源码:

image-20220608200621193.png

上面的两个问题都可以看加锁acquireQueued源码来回答,第一个问题在unparkSuccessor当head节点被重置为了0,执行步骤3唤醒节点,注意,此时的节点为阻塞队列中head后的第一个节点,因此在acquireQueued步骤1处被唤醒后,会自旋重新尝试获取锁,获取成功,则调用setHead将被唤醒的节点设置为head节点,然后移除原来的head节点,完成阻塞队列节点被唤醒后的删除操作

同时,如果获取锁失败,则会执行步骤1,调用shouldParkAfterFailedAcquire重新回到入同步队列的逻辑,此时一定会执行shouldParkAfterFailedAcquirepred.compareAndSetWaitStatus(ws, Node.SIGNAL);将头部重新设置为-1,这样就不影响释放锁的时候,判断唤醒条件if (h != null && h.waitStatus != 0)了,可以在锁释放后安心唤醒阻塞节点,这种情况一般存在非公平竞争中,刚被唤醒,但是其它线程立马抢占到了锁,然后被唤醒线程就只能继续等待的情况,这也是为什么要用死循环自旋的目的 。

 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
     int ws = pred.waitStatus;:
     if (ws == Node.SIGNAL)
         return true;
     if (ws > 0) {
     } else {
         // 将前驱节点更新为等待唤醒
         pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
     }
     return false;
 }

AQS中的阻塞队列head节点的作用?

在阻塞队列里面只要初始化了同步队列,都会出现一个head节点,该节点在一些文章也称为哨兵节点。这个节点的作用在我分析主要有两点。

一、作为哨兵节点,标识当前阻塞队列是否存在被阻塞的线程;这个在上面的shouldParkAfterFailedAcquire方法分析的时候有说明。

二、作为并发编程减少边界值判断的一种技巧,这种技巧在LinkedBlockingQueue中也有应用。

 // 入队 
 if (oldTail != null) {
      node.setPrevRelaxed(oldTail);
      if (compareAndSetTail(oldTail, node)) {
          oldTail.next = node;
          return node;
      }
  } else {
      // 初始化队列
      // initializeSyncQueue(); ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
      Node h;
     if (HEAD.compareAndSet(this, null, (h = new Node())))
         tail = h;
  }

针对入队来讲,其实有没有哨兵节点,都得做这个阻塞队列是否为null的这个判断,如这里的oldTail == null就初始化同步队列。但是入队时候如果没有哨兵节点,也就是无法在并发的时候无法保证tail入队不为null,可能节点在入队的时候if判断tail不为null,但是当执行tail.next = node 的时候tail已经出队了,就会造成空指针异常;而有了哨兵节点,可以放心的使用tail.next。

针对出队来说,每次获取真实节点只需要拿到哨兵节点的head.next即可,也就是只要队列产生了一次竞争后,阻塞队列被初始化过,后续永远可以这样使用,即时是阻塞队列中的有效线程节点全部出队了,依旧存在head节点,不用做null判断。

 int ws = node.waitStatus;
 ​
 if (ws < 0)
     node.compareAndSetWaitStatus(ws, 0);
 // 真实线程节点
 Node s = node.next;
 if (s == null || s.waitStatus > 0) {
     s = null;
     for (Node p = tail; p != node && p != null; p = p.prev)
         if (p.waitStatus <= 0)
             s = p;
 }
 // 执行唤醒
 if (s != null)
     LockSupport.unpark(s.thread);

如果没有head节点,则每次拿线程节点,都得进行一个node!=null的判断,不等于null才进行逻辑处理,有哨兵节点head之后,就可以大胆的获取head.next。

ReentrantLock独占锁的实现

前面分析了AQS的独占锁的获取锁和释放锁的整体流程,接下来分析AQS的一个实现,也就是大名鼎鼎的ReentrantLock,作为独占锁的实现。

ReentrantLock作为独占锁,有两种实现,分别是非公平锁和公平锁。

非公平锁:竞争锁的时候,不会去前面是否已经有线程阻塞了,直接参与竞争。

公平锁:竞争锁的时候会判断阻塞队列中是否存在阻塞线程,如果存在则将当前线程入阻塞队列,不存在则获取锁成功。

总结:非公平锁不会管前面是否已经有在等待的线程,公平锁会在前面已经等待线程唤醒完后才会产于锁的竞争。

前面有整个独占锁的源码分析,因此下面只分析ReentrantLock对AQS模板方法的实现,即:tryAcquire和tryRelease方法。

ReentrantLock内部有个同步器Sync分别由两套实现,也就是公平和非公平锁:

image-20220608222448699.png

加锁源码

image-20220608222732176.png

非公平锁

 static final class NonfairSync extends Sync {
     private static final long serialVersionUID = 7316153563782823691L;
     protected final boolean tryAcquire(int acquires) {
         // 调用非公平获取锁
         return nonfairTryAcquire(acquires);
     }
 }
 final boolean nonfairTryAcquire(int acquires) {
     final Thread current = Thread.currentThread();
     int c = getState();
     // 如果竞争资源state为0,说明当前没有线程获取锁,则通过CAS进行锁状态修改,修改成功返回true,意味着加锁成功
     if (c == 0) {
         if (compareAndSetState(0, acquires)) {
             setExclusiveOwnerThread(current);
             return true;
         }
     }
     // 重入锁实现的关键逻辑:如果尝试获取锁线程为当前线程对竞争资源state进行+1,代表重入次数
     else if (current == getExclusiveOwnerThread()) {
         int nextc = c + acquires;
         if (nextc < 0) // overflow
             throw new Error("Maximum lock count exceeded");
         setState(nextc);
         return true;
     }
     // 如果获取锁失败,并且持有锁也不是当前线程,则加锁失败,进入阻塞逻辑,即AQS的acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
     return false;
 }

公平锁

 static final class FairSync extends Sync {
     private static final long serialVersionUID = -3000897897090466540L;
     @ReservedStackAccess
     protected final boolean tryAcquire(int acquires) {
         // 公平锁的加锁逻辑和重入逻辑与上面一直
         final Thread current = Thread.currentThread();
         int c = getState();
         if (c == 0) {
             // 与非公平锁的区别就是多了一个hasQueuedPredecessors()判断,该方法判断阻塞队列中是否存在待处理节点,如果存在则加锁失败,否则尝试cas修改状态进行加锁
             if (!hasQueuedPredecessors() &&
                 compareAndSetState(0, acquires)) {
                 setExclusiveOwnerThread(current);
                 return true;
             }
         }
         else if (current == getExclusiveOwnerThread()) {
             int nextc = c + acquires;
             if (nextc < 0)
                 throw new Error("Maximum lock count exceeded");
             setState(nextc);
             return true;
         }
         return false;
     }
 }
 public final boolean hasQueuedPredecessors() {
     // The correctness of this depends on head being initialized
     // before tail and on head.next being accurate if the current
     // thread is first in queue.
     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());
 }

hasQueuedPredecessors方法的情况:

h != t && ((s = h.next) == null || s.thread != Thread.currentThread());

如果h==t则说明阻塞队列中只有哨兵节点或者都为null(没有初始化),不存在阻塞节点。

如果(s = h.next) == null:能走到这里说明h!=t,在h!=t有两种情况;

  1. 存在除了哨兵节点以外的阻塞节点,这个很好理解直接返回true;head->node1->node2(tail)
  2. 刚构建完哨兵节点h,还没有将h赋值给tail,导致head=node,tail=null这种情况,但是处于这种情况下初始化队列的线程又不是当前获取锁的线程,则说明已经有线程获取到了锁,并且正在初始化,因此加上了s.thread != Thread.currentThread()这个条件,最后也是可以返回为true的。

总结

ReentrantLock和synchronized的区别?

功能层面:

ReentrantLock功能比synchronized更加强大,它支持公平和非公平锁,并且提供很多API,能够让调用者感知到锁的状态,如getQueueLength()获取阻塞多少了线程,isLocked()锁是否已经被占用等,这些功能都是synchronized不具备的。

使用层面:

ReentrantLock的使用比synchronized更加复杂,需要手动调用lock和unlock,如果忘记了调用unlock容易导致死锁,而synchronized是JVM层面的关键字,会自动在同步代码块插入monitorenter和monitorexit,不用开发者自己考虑释放锁。

性能层面:

目前来说,性能层面,在1.6以后synchronized和ReentrantLock差不多。但是如果只是单单的独占加锁,还是推荐synchronized的,因为JVM会对synchronized引入一些锁优化,比如锁消除,锁粗化等,这个是ReentrantLock不具备的。