AQS_4_条件锁

103 阅读7分钟

AQS条件锁

条件锁应用场景

条件锁顾名思义用于在特定条件下进行锁的等待和唤醒。条件锁不会自己控制条件的判断,还是需要开发者自己实现条件。类似于synchronized的wait和notify/notifyAll方法。使用条件锁,依赖于ReentrantLock,需要先获取到ReentrantLock的锁才能使用条件锁。

比如juc的CyclicBarrier循环栅栏的实现,就是通过条件锁实现的,我们可以通过条件锁自己实现一个通知者消费者模式,在生产了10个产品的时候就消费,没有产品的时候就通知生产。

条件锁使用Demo

上面生产者消费者的代码如下:

 public class ConditionLockProducerConsumer {
     private static ReentrantLock lock = new ReentrantLock();
 ​
     // 消费者条件
     private static Condition consumerCL = lock.newCondition();
     // 生产者条件
     private static Condition producerCL = lock.newCondition();
 ​
     private static class Producer extends Thread {
         private int productSeqNum = 1;// 产品序号
         private volatile List<String> product = null;
 ​
         public Producer(List<String> product) {
             this.product = product;
         }
 ​
         @SneakyThrows
         @Override
         public void run() {
             String name = Thread.currentThread().getName();
             while (true) {
                 lock.lock();
                 if (product.isEmpty()) {
                     System.out.println(name + " 满足生产条件,开始生产.......");
                     for (int i = 0; i < 10; i++) {
                         product.add("产品 " + (productSeqNum++));
                     }
                     System.out.println(name + " 生产完成,通知消费者消费");
                     consumerCL.signal();
                 } else {
                     // 产品不做消费
                     producerCL.await();
                 }
                 lock.unlock();
             }
         }
     }
 ​
     private static class Consumer extends Thread {
 ​
         private volatile List<String> product = null;
 ​
         public Consumer(List<String> product) {
             this.product = product;
         }
 ​
         @SneakyThrows
         @Override
         public void run() {
             String name = Thread.currentThread().getName();
             while (true) {
                 lock.lock();
                 if (product.size() == 10) {
                     System.out.println(name + " 有产品开始消费...");
                     Iterator<String> iterator = product.iterator();
                     while(iterator.hasNext()){
                         String pro = iterator.next();
                         System.out.println(name + " 消费产品..." + pro);
                         TimeUnit.SECONDS.sleep(1);
                         iterator.remove();
                     }
                     System.out.println(name + " 消费完成,通知生产者生产");
                     producerCL.signal();
                 } else {
                     consumerCL.await();
                 }
                 lock.unlock();
             }
         }
     }
 ​
     public static void main(String[] args) {
         List<String> product = new ArrayList<>();
         Producer producer = new Producer(product);
         Consumer consumer = new Consumer(product);
         producer.start();
         consumer.start();
     }
 }

这段代码就是使用了两个条件锁,实现了线程之间相互通信,完成了线程协作和synchronized的wait以及notify实现功能相似,但是看得出这种方式对条件的控制更加精准。毕竟synchronized的notify和notifyAll无法精确的通知唤醒指定的线程。

条件锁原理介绍

我们可以看到上面的Demo,知道了条件锁,相较于synchronized的wait和notify最大的区别在于能够精确的唤醒指定条件上的等待线程。它的实现原理就是对每一个条件锁都维护了一个独立的阻塞队列,可以粗暴的理解每一个条件锁都是一个独立的独占锁,我们对指定的条件锁唤醒,就是唤醒该条件锁维护的队列。

示意图:

image-20220612095838871.png

如上图所示,线程针对特定的条件锁调用了await就会进入相应的阻塞队列里面,线程A对条件锁1调用await就进入了条件锁1的阻塞队列,线程B,E等同理。这样我们在执行唤醒的时候,直接调用条件锁1的signal就能够唤醒条件锁1的阻塞队列中的线程,相应的唤醒线程A、C。

为什么条件锁要依赖于ReentrantLock?

如果不先获取到ReentrantLock的锁,直接使用条件锁会有什么问题?

首先得明确条件锁的await和signal是一种线程同步机制,也就是说在执行该操作的时候不能发生线程竞争的情况,要不然会造成重复等待或者唤醒等线程冲突问题,并且我们在调用await或者signal是很明确的操作一定要等待或者唤醒。因此在调用await和signal要先保证不会产生线程竞争,因此要在最外层使用一定的同步手段来控制。

ps:synchronized的wait和notify/All同理。

条件锁源码分析

条件锁之所以能够实现上面的功能,主要是AQS里面的一个ConditionObject对象,该对象维护了一个独立的双向链表作为条件队列和AQS的阻塞队列结构一样,但是没有哨兵节点。Reentrantlock的newCondition就是创建了一个该对象。

 final ConditionObject newCondition() {
     return new ConditionObject();
 }

image-20220612101204466.png

并且在conditionObject里面还定义了等待唤醒相关的代码逻辑。

阻塞:await()

该方法会使得调用者线程进入阻塞。

 public final void await() throws InterruptedException {
     if (Thread.interrupted())
         throw new InterruptedException();
     // 将调用者线程添加到当前条件锁阻塞队列尾部
     Node node = addConditionWaiter();
     // 释放外层获取到的ReentrantLock。问为什么要先释放掉外层的锁?
     int savedState = fullyRelease(node);
     int interruptMode = 0;// 阻塞期间中断标识
     // 判断是否在同步队列中,如果不在同步队列中则需要阻塞,如果在同步队列中,则尝试竞争同步锁
     /*
         注意:isOnSyncQueue这个方法是AQS的方法,判断的也是AQS的同步队列,并不是条件锁的同步队列。
         为什么要AQS的同步队列来判断当前线程是否阻塞呢?这个要分析了signal方法源码才能得出答案。
         因为条件锁的唤醒操作并不是像AQS唤醒一样直接去竞争锁,而是会把阻塞节点先转移到AQS同步队列里面,再进行唤醒,在唤醒线程释放锁的时候才会尝试唤醒,这个后面分析到signal详讲。
     */
     while (!isOnSyncQueue(node)) {
         LockSupport.park(this);
         if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
             break;
     }
     // 此时阻塞节点已经被唤醒了或者本来就在AQS的同步队列里面,则调用AQS的acquireQueued方法,尝试获取同步锁
     if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
         interruptMode = REINTERRUPT;
     if (node.nextWaiter != null) // clean up if cancelled
         unlinkCancelledWaiters();    // 清除掉已被取消的节点,也就是1
     // 如果中断状态不为0,则抛出异常或者给出中断信号
     if (interruptMode != 0)
         reportInterruptAfterWait(interruptMode);
 }

image-20220612111630854.png

分析了上面的源码,先回答一下

为什么在条件锁阻塞节点如队列的时候要先释放掉外层的锁?

答:根据await语义,调用await的目的是为了其它线程在一定条件下能够唤醒被阻塞在该条件锁上的线程。而调用signal方法又必须得先获取到ReentrantLock锁,因此如果await不释放锁,则永远无法唤醒。

另外一个问题就是isOnSyncQueue的判断,为什么要这样干?这个分析了signal唤醒流程再来回答。

唤醒:signal

通知唤醒阻塞在该条件锁上的队列进入待唤醒状态。

 public final void signal() {
     // 必须要获取到了ReentrantLock锁才能调用
     if (!isHeldExclusively())
         throw new IllegalMonitorStateException();
     Node first = firstWaiter;
     if (first != null)
         // 执行唤醒
         doSignal(first);
 }
 private void doSignal(Node first) {
     do {
         // 从队列上移除即将唤醒的节点
         if ( (firstWaiter = first.nextWaiter) == null)
             lastWaiter = null;
         first.nextWaiter = null;
         // 自旋唤醒条件锁上的阻塞节点,如果唤醒成功了一个,就不再唤醒;transferForSignal该方法返回true代表节点迁移成功表达式为false
     } while (!transferForSignal(first) &&
              (first = firstWaiter) != null);
 }

transferForSignal

将条件锁上的所有阻塞节点,迁移到AQS同步队列中,并且

 final boolean transferForSignal(Node node) {
     // 如果改变waitStatus失败,说明该节点已经被取消
     if (!node.compareAndSetWaitStatus(Node.CONDITION, 0))
         return false;
     // 将条件锁上的节点,专业到AQS的同步队列上去,会同时修改节点状态为-1(待唤醒状态)
     Node p = enq(node);
     int ws = p.waitStatus;
     // 如果节点已经取消了或者设置失败,则直接唤醒线程进行重新入同步队列
     if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL))
         LockSupport.unpark(node.thread);
     return true;
 }

唤醒代码到此结束,what?这就完了!节点虽然迁移到了AQS同步队列中,但是还是阻塞的呀,并没有被唤醒呀,那线程还在await阻塞着呢。那么是啥时候唤醒呢?

注意根据notify和signal语义,调用了该方法是不会释放锁的,还会继续执行,只是会让被阻塞的条件队列进入待唤醒状态,真实释放锁是在调用了unlock之后,刚入AQS同步队列的条件节点等同于刚获取独占锁失败的节点,这时候才会尝试唤醒锁。

image-20220612161322352.png

代码演示

 Condition condition = lock.newCondition();
 // T1,T2线程入条件队列
 for (int i = 1; i <= 2; i++) {
     new Thread(() -> {
         lock.lock();
         try {
             condition.await();
             System.out.println(Thread.currentThread().getName()+" 被唤醒了");
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
         lock.unlock();
     }, "T" + i).start();
 }
 TimeUnit.SECONDS.sleep(2);// 等待上面顺利入条件队列
 lock.lock();
 condition.signal();
 lock.unlock();

signalAll

上面的signal只能一次性唤醒一个节点,ConditionObject提供了一个signalAll方法,能够一次性唤醒条件队列上所有节点,功能类似notifyAll。

  public final void signalAll() {
      if (!isHeldExclusively())
          throw new IllegalMonitorStateException();
      Node first = firstWaiter;
      if (first != null)
          doSignalAll(first);
  }
 private void doSignalAll(Node first) {
     lastWaiter = firstWaiter = null;
     do {
         Node next = first.nextWaiter;
         first.nextWaiter = null;
         transferForSignal(first);
         first = next;
         // 少了判断是否迁移成功,会循环将所有阻塞节点迁移完
     } while (first != null);
 }

总结:

条件锁的await和signal与Object的wait和notify/notifyAll区别?

ReentrantLock条件锁与wait和notify/notifyAll最大的区别就是条件锁可以精确的唤醒指定条件锁上的线程,并且一把独占锁可以创建多个条件锁,用于区分;而Object的wait和notify/notifyAll不能精确唤醒指定线程,只能无差别唤醒。