AQS_3_共享锁

92 阅读8分钟

AQS共享锁

共享锁顾名思义,就是支持多线程获取的锁,常用的有:Semaphore、Countdownlatch、ReentrantRedWriteLock等,都是共享锁的实现。

共享锁实现

Semaphore:

应用在资源受限或场景受限的场景下,如停车场只有十个车位,每一辆车都是一个线程,共享锁可以控制只有存在空车位的时候(资源充足),才能开进去(获取锁),当车位被停满了,车辆只能在外等待。

Demo

 // 停车场十个车位
 Semaphore semaphore = new Semaphore(10);
 for (int i = 1; i <= 11; i++) {
     // 每个线程代表一辆车,申请一个车位
     // 当第11辆车申请车位的时候,因为被申请完了,因此只能等待有车开出去
     // 等待10s后,让第5辆车,等待十秒钟后就开出,第11辆车申请车位成功
     new Thread(() -> {
         try {
             String tn = Thread.currentThread().getName();
             System.out.println(tn + "申请车位");
             semaphore.acquire();// 申请车位
             System.out.println(tn + "申请车位成功");
             if (tn.equals("car5")) {
                 TimeUnit.SECONDS.sleep(10);
                 System.out.println(tn + "让出车位");
                 semaphore.release();// 释放车位
             }
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
     }, "car" + i).start();
 }

CountDownLatch

Demo

应用资源消耗不可逆转的情况下,假如你有10万块,一个人用太费劲了,于是你给10个人用,但是这10个人它用钱时间不定,有的一下就用完了,有的用得很慢,用完你就去上班继续挣钱。

 CountDownLatch countDownLatch = new CountDownLatch(10);
 for (int i = 1; i <= 10; i++) {
     new Thread(() -> {
         String name = Thread.currentThread().getName();
         System.out.println(name + "取钱一万,开始花钱");
         try {
             TimeUnit.SECONDS.sleep(new Random().nextInt(3));
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
         countDownLatch.countDown();
         System.out.println(name + "钱花完了");
 ​
     }, "person" + i).start();
 }
 // 等待这10个人用钱
 countDownLatch.await();
 System.out.println(Thread.currentThread().getName() + " 该上班了");

共享锁获取锁 acquireSharedInterruptibly:

 public final void acquireSharedInterruptibly(int arg)
     throws InterruptedException {
     // 检测中断
     if (Thread.interrupted())
         throw new InterruptedException();
     if (tryAcquireShared(arg) < 0)
         doAcquireSharedInterruptibly(arg);
 }

tryAcquireShared(arg)

是否能够获取锁模板方法和独占锁的tryAcquire方法一样。不过需要注意的是,这个方法返回的是的剩余可获取资源而独占锁tryAcquire返回的却是boolean值。如果返回的可用资源<0则调用doAcquireSharedInterruptibly进入阻塞队列。

Semaphore的tryAcquireShared

Semaphore的tryAcquireShared主要是功能是对当前的资源state减去需要获取的资源数,返回其结果。

image-20220611120051648.png

Semaphore也有公平模式和非公平模式

 // 非公平模式
 final int nonfairTryAcquireShared(int acquires) {
     for (;;) {
         int available = getState();
         // 获取一次,则减少指定资源,默认为1
         int remaining = available - acquires;
         if (remaining < 0 ||
             compareAndSetState(available, remaining))
             return remaining;// 最后返回修改后的资源数量
     }
 }
 ​
 // 公平模式
 protected int tryAcquireShared(int acquires) {
     for (;;) {
         // 与非公平模式的区别就是多了一个判断阻塞队列中是否有值,这个判断方法在独占锁已经分析了
         if (hasQueuedPredecessors())
             return -1;
         int available = getState();
         int remaining = available - acquires;
         if (remaining < 0 ||
             compareAndSetState(available, remaining))
             return remaining;
     }
 }

CountDownLatch的tryAcquireShared

CountDownLatch的tryAcquireShared主要功能是判断当前资源数量是否为0,如果不为0则阻塞,因为CountDownLatch要求资源被消耗完才能继续执行。

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

doAcquireSharedInterruptibly(arg)

 private void doAcquireSharedInterruptibly(int arg)
     throws InterruptedException {
     // 添加共享节点并生成线程阻塞节点或者初始化同步队列
     final Node node = addWaiter(Node.SHARED);
     try {
         for (;;) {
             final Node p = node.predecessor();
             // 和独占锁一样,队首取出一个阻塞节点尝试获取锁,一、该节点是刚入队节点;二、该节点为刚被唤醒节点
             if (p == head) {
                 // 再次尝试获取锁
                 int r = tryAcquireShared(arg);
                 if (r >= 0) {
                     // 如果获取锁成功,并且资源充足的情况下,将当前节点变为头节点,并尝试唤醒阻塞队列里面的节点(资源充足的情况下)
                     setHeadAndPropagate(node, r);
                     p.next = null; // help GC
                     return;
                 }
             }
             // 和独占锁一样,更新前驱节点为待唤醒状态
             if (shouldParkAfterFailedAcquire(p, node) &&
                 parkAndCheckInterrupt())
                 throw new InterruptedException();
         }
     } catch (Throwable t) {
         cancelAcquire(node);
         throw t;
     }
 }
 ​

setHeadAndPropagate(共享锁传播):

共享传播性方法的实现

 // 在节点获取锁成功的情况下:唤醒后继节点,并设置当前节点为头结点
 private void setHeadAndPropagate(Node node, int propagate) {
     Node h = head; // Record old head for check below
     setHead(node);
     // 满足以下条件,则尝试唤醒共享锁;通俗点就是:哥们我获取到锁了,你们阻塞的要不要也是下来获取,万一资源充足呢,这就是共享传播性,目的是尽快的通知线程获取锁。
     if (propagate > 0 || h == null || h.waitStatus < 0 ||
         (h = head) == null || h.waitStatus < 0) {
         Node s = node.next;
         // 唤醒后继节点
         // s==null,这种情况可能有疑问,都TM没有后继节点了还唤醒个啥?可能存在判断的时候,整好有入队节点,即时没有,那么唤醒也不会做任何操作,无伤大雅
         // s.isShared(),这个不用说了,就是后继节点肯定是共享锁阻塞节点了,需要唤醒。
         if (s == null || s.isShared())
             // 唤醒共享锁
             doReleaseShared();
     }
 }
  • propagate > 0: 说明资源充足,可以唤醒阻塞节点,让其自旋尝试获取锁
  • h==null :按道理这里执行了addWaiter初始化了同步队列,这里不可能成立。但是有个非常特殊情况,执行完setHead(node);之后旧的head是不是就没有指针指向它了,因此这里是防止旧head在执行完setHead之后被gc回收的情况下才满足,满足这一条至少说明了队列被初始化过并且节点刚获取锁成功,因此可以尝试唤醒。
  • h.waitStatus<0:节点为-1或者-3,则说明了肯定是有后继节点的,因为入队节点默认是0,状态只能后继节点修改成-1或者-3;还有一种情况就是唤醒的时候会有一个-1到-3的转换,这个后面会说到。反正知道这个条件满足,肯定有后继阻塞节点。
  • (h = head) == null:这个条件在我看来是很难成立的,因为前面setHead已经对头节点指定了明确指向,而node肯定是不可能为null的。
  • h.waitStatus < 0:这个判断和上面的h.waitStatus < 0逻辑是一样,不过一个是旧head判断一个是新head判断,结合这里可以分析出(h = head) == null这就是为了赋值防止空指针的一个写法,目的是为了判断这个条件。

我对两个head判断的理解:

第一个h == null || h.waitStatus < 0判断主要是判断旧head后面是否存在待唤醒节点,这也是为什么他优先级高于新head判断,如果旧队列中存在待唤醒节点,那么我们可以尝试唤醒节点竞争锁。

第二个(h = head) == null || h.waitStatus < 0判断主要是新head后面是否存在待唤醒节点,则肯定是存在待唤醒了也得唤醒。

第一个判断是可能存在,第二个判断是肯定存在待唤醒节点,这样做的目的就是为了加快共享锁传播,即时会引起 一些不必要的唤醒这也是可以接受的,这个在源码注释中有说到。

共享锁释放锁 releaseShared

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

tryReleaseShared(arg)

是否能够释放锁模板方法和独占锁的tryRelease方法一样。如果满足释放条件,则调用doReleaseShared唤醒阻塞队列里面的节点。

Semaphore的tryReleaseShared

Semaphore释放锁的主要功能一个是将持有的资源返还给AQS即可。

 protected final boolean tryReleaseShared(int releases) {
     for (;;) {
         int current = getState();
         int next = current + releases;// 返还后的资源数量
         if (next < current) // overflow
             throw new Error("Maximum permit count exceeded");
         if (compareAndSetState(current, next))
             return true;
     }
 }

CountDownLatch的tryReleaseShared

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))
             // 如果资源消耗完了,则唤醒调用await被阻塞的线程
             return nextc == 0;
     }
 }

doReleaseShared

唤醒阻塞节点的核心方法

 private void doReleaseShared() {
     // 在节点满足唤醒条件下,持续自旋唤醒后继节点;唤醒条件:h(oldHead) == head(newHead)
     // h代表未被唤醒前的head
     for (;;) {
         Node h = head;
         // case1:队列已经初始化了,并且存在后继节点
         if (h != null && h != tail) {
             int ws = h.waitStatus;
             // case2:如果是待唤醒节点,则直接唤醒
             if (ws == Node.SIGNAL) {
                 if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
                     continue;            // loop to recheck cases
                 unparkSuccessor(h);
             }
             // case4:如果head状态0,并且在阻塞队列里面还存在后续节点,则尝试将head状态更新为-3
             else if (ws == 0 &&
                      !h.compareAndSetWaitStatus(0, Node.PROPAGATE))
                 continue;                // loop on failed CAS
         }
         // case3:如果旧的head和执行了上面操作的head相等,说明无后续待唤醒节点
         if (h == head)                   // loop if head changed
             break;
     }
 }

case1:如果head!=null说明队列一定是已经初始化过了并且满足了h != tail条件,则说明队列头不等于队尾,则队列中节点数量肯定是大于1的因此需要尝试执行唤醒操作。

case2:如果当前节点类型为待唤醒节点,则尝试唤醒后继节点,因为在获取锁失败入队列的时候都是一个逻辑,执行shouldParkAfterFailedAcquire方法,会将阻塞节点的前继节点修改为-1。

case3:如果在case2中,唤醒成功,则会执行unparkSuccessor方法执行唤醒并同时在获取锁成功的情况下更新被唤醒节点为新的head。因此如果满足case3的条件,说明head没发生过变化,代表竞争锁失败,则结束唤醒,否则自旋。这和被阻塞节点会修改前继节点为-1是一个道理,一定要在唤醒并获取锁后才能断定可能会有资源还能继续唤醒

image-20220611100840483.png

case4:ws == 0 && !h.compareAndSetWaitStatus(0, Node.PROPAGATE)这是一个非常特殊的情况,在最开始的AQS没有这一段逻辑,在1.6新增的 Node.PROPAGATE状态为了修复一个bug,这个Node.PROPAGATE状态可以看做和Node.SIGNAL状态一样的功能,只不过用作一个中间状态。

举例:在没有ws == 0 && !h.compareAndSetWaitStatus(0, Node.PROPAGATE)这个逻辑的情况下出现问题的场景。

首先明确能够执行到case4已经可以确定阻塞队列肯定不仅仅只有哨兵节点这一个节点了,并且队列哨兵节点还不为-1。要知道哨兵节点head除了在新建的时候,只有在unparkSuccessor才会修改成0,也就是说哨兵节点除了0就是1。哨兵节点变为了0的时候但是队列中还有节点,这肯定是有问题的。

那么什么情况下才会出现哨兵节点为0,并且阻塞队列中还有节点呢?这就是共享锁支持多线程引发的问题。

如果没有case4这代码会造成以下问题:

场景如下:

现有线程T1,T2,T3,T4;共享资源为2。此时T1,T2获取到了共享资源;T3,T4如阻塞队列,head->T3->T4->Node.SHARED。

T1释放锁,满足case2场景,执行释放锁调用了unparkSuccessor更新了哨兵节点head为0唤醒T3,如果刚更新完,此时T3线程还未被唤醒,或者T3被唤醒后还未在doAcquireSharedInterruptibly方法里面获取到锁,没来得及执行更新head为-1的情况下。T2在这期间也释放锁,此时按道理T3和T4都能够被唤醒并获取锁成功的,此时T2,能够顺利进入case1场景,但是不满足case2场景,因此直接执行case3,不做任何唤醒退出了自旋,造成了T4没有及时通知到位唤醒。如果后续都没有释放,就会导致永远有一个共享资源得不到使用。 image-20220611111816684.png

综上分析,引入了Node.PROPAGATE状态,该状态就是为了维持一个中间状态用于唤醒,在unparkSuccessor里面重置为0。

 if (ws < 0)
   node.compareAndSetWaitStatus(ws, 0);

你可能会问了,为什么不直接修改为-1(Node.SIGNAL)呢?

我们前面分析了在并发编程有个很重要的原则,一定要在已经处于那个状态的时候才回去更新节点,比如更新前继节点为待唤醒节点;唤醒后才更新head节点等。我个人认为,因为这是有可能T4在执行更新-1的时候并不能确定是否有其它线程已经对该线程节点进行了唤醒,因此需要设置一个中间状态来走一遍自旋,让被唤醒的节点来明确这个状态(不一定对啊)。

情况如此复杂的原因就是因为共享锁能够同时被多线程竞争,无法确定当前线程执行通过队列操作的时候,其它线程是否也在执行,因此才需要这样设计