【并发编程】(十五)Condition的使用及其阻塞唤醒原理

1.Condition的使用

1.1.作用

在使用独占锁的情况下,线程在临界区中运行,如果不做其它的处理,只有线程将临界区中的代码逻辑运行完成之后才会去解锁。

但是我们有时候需要线程循环执行一些特定的代码,直到满足了一定的条件才会暂停下来,当这个条件不满足后又重新开始执行。
比如线程池中的线程都是运行在一个死循环中,只要任务队列中获取了任务,就拿出来运行,任务队列中没有任务,线程就会阻塞。这里的阻塞和唤醒就是通过Conditionsignal()await()方法来实现的。

1.2.使用方式

Condition是依赖于Lock存在的,在临界区中的线程可以通过Condition来释放锁。
下面使用3个线程依次打印的例子来感受一下:

public class ConditionDemo {

    private static ReentrantLock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void test() {
        lock.lock();
        try {
            for (; ; ) {
                System.out.println(Thread.currentThread().getName());
                Thread.sleep(1000);
                // 唤醒等待队列中的下一个节点中的线程
                condition.signal();
                // 当前线程加入等待队列尾并阻塞
                condition.await();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(ConditionDemo::test, "线程1");
        Thread t2 = new Thread(ConditionDemo::test, "线程2");
        Thread t3 = new Thread(ConditionDemo::test, "线程3");
        t1.start();
        t2.start();
        t3.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

线程1,2,3每隔一秒打印一次自己的线程名,在test()中是一个死循环,如果不使用Condition的话,哪个线程先进入循环就会一直打印,阻塞其它两个线程。
此外,一个Lock可以创建多个Condition对象,针对不同的条件来做等待和唤醒操作。

2.Condition的原理

在看原理之前,建议先看一下上一篇笔记《(十四)Java可重入互斥锁实现——ReentrantLock详解》,会更容易理解Condition的原理。


通过上面的例子,我们已经知道了Condition可以使当前线程释放锁,并将自己挂起让出CPU资源,下面就来看看阻塞和唤醒是如何实现的。

2.1.阻塞如何实现

首先来看一下Condition的类结构,它是AQS的一个内部类,定义了两个Node字段:

 public class ConditionObject implements Condition, java.io.Serializable {
    // 条件队列的头节点
    private transient Node firstWaiter;
    // 条件队列的尾结点
    private transient Node lastWaiter;
    // ......
}

Node有4种状态分别为CANCELLED,SIGNAL,CONDITION,PROPAGATE,在条件等待队列中的节点只会用到CANCELLEDCONDITION两种状态。

2.1.1.线程加入条件等待队列

调用await()方法,线程会创建一个CONDITION状态的节点,然后先将自己加入到条件队列中去。

public final void await() throws InterruptedException {
    if (Thread.interrupted()) throw new InterruptedException();
    // 创建新的节点,并加入到条件队列中
    Node node = addConditionWaiter();
   	// 省略部分代码
}

加入队列的过程有下面几种可能性。

  • 如果条件队列未初始化,则初始化队列,将自己置为头节点
  • 如果条件队列已初始化,则将自己挂接到队列尾之后。
  • 如果尾结点是CANCEL状态,则从头到尾扫描CANCEL状态的节点,将他们从队列中清除,然后再将自己挂接到队列尾之后。

上面的动作完成之后,将条件队列中的lastWaiter指向新创建的节点。

private Node addConditionWaiter() {
    Node t = lastWaiter;
    // 尾结点是取消状态,将它从队列中清除。
    if (t != null && t.waitStatus != Node.CONDITION) {
        unlinkCancelledWaiters();
        t = lastWaiter;
    }
    // 创建新节点,并加入到队列中去。
    Node node = new Node(Thread.currentThread(), Node.CONDITION);
    if (t == null)
        firstWaiter = node;
    else
        t.nextWaiter = node;
    lastWaiter = node;
    return node;
}

可能大家已经注意到了,这里的每一步操作都没有使用CAS,因为await()方法是在临界区中调用的,这里同一时间只能有一个线程访问到,不存在线程安全问题。

2.1.1.1.Condition队列的初始化及尾结点添加过程

当队列的尾结点指向null时,队列会进行初始化:
在这里插入图片描述
当新的线程加入到的条件队列时,会挂接掉队列尾:
在这里插入图片描述

2.1.1.2.CANCEL节点的清理过程

尾结点是取消状态时,就会进入unlinkCancelledWaiters(),这个方法中定义了3个指针来从头到尾操作队列,将所有CANCEL状态的节点都从队列中清理出去,三个指针分别为:

  • t:从头到尾依次移动的指针
  • trail:指向t经过的上一个Condition状态的节点。
  • next:保存t即将经过的下一个节点。
private void unlinkCancelledWaiters() {
    Node t = firstWaiter;
    Node trail = null;
    while (t != null) {
        Node next = t.nextWaiter;
        if (t.waitStatus != Node.CONDITION) {
            t.nextWaiter = null;
            // trail为null表示经过的都是CANCEL状态的节点,此时直接将队列头指向下一个节点
            if (trail == null) {
				firstWaiter = next;
			}
			// 此时t指向的是CANCEL状态的节点,这一步将其断开
            else {
                trail.nextWaiter = next;
			}
			// next为null表示整个队列已经迭代完了,将队列尾指向最后一个CONDITION状态的节点
            if (next == null)
                lastWaiter = trail;
        }
        else {
        	// 当前节点是CONDITION状态,使用trail保存起来
            trail = t;
		}
        // 指针向右移动
        t = next;
    }
}

下面通过流程图来演示CANCEL节点是如何从队列中清除的。
在这里插入图片描述
第一步,t指向头节点,next指向头节点的后置节点。
在这里插入图片描述
此时t指向的节点是CANCEL状态,并且trail还没有指向,t断开指向后置节点的指针,在操作完成后,firstWaitert向右移动。
在这里插入图片描述

此时t指向CONDITION状态的节点,只需要将trail指向t指向的节点,然后t继续向右移动。
在这里插入图片描述
现在t又指向了CANCEL节点了,并且trail已经有了指向。接下来t断开指向后置节点的指针,然后将trail指向后置节点,完成后同样向右移动。
在这里插入图片描述
next指向null了,此时会执行最后一个循环,这次循环中会处理尾结点的指针。

在这里插入图片描述
清理完成后,得到如下的队列:
在这里插入图片描述

2.1.1.3.小结

线程调用await()方法后,会创建一个节点加入到队列中,如果此时队列没有初始化则先初始化队列。
如果队列尾部的节点为CANCEL状态,则发起一个清理操作,从头到尾扫描队列将所有CANCEL节点都从队列中清除出去,然后再将新创建的节点挂接到队列尾。

2.1.2.释放锁

既然当前线程挂起了,那就需要把持有的锁释放掉,让其他的线程可以获取锁。

public final void await() throws InterruptedException {
	if (Thread.interrupted()) throw new InterruptedException();
	  Node node = addConditionWaiter();
	  // 释放锁
	  int savedState = fullyRelease(node);
	  // 省略部分代码
}

这里可能会有重入锁的情况,在释放锁时不再一次将锁状态state减1,而是直接释放所有重入锁。

final int fullyRelease(Node node) {
    boolean failed = true;
    try {
    	// 获取当前的锁状态,使用这个锁状态一次性释放所有重入锁
        int savedState = getState();
        if (release(savedState)) {
            failed = false;
            return savedState;
        } 
        // 省略部分代码
   }
}

2.1.3.挂起线程

上面两步做完之后,就需要将当前线程挂起

public final void await() throws InterruptedException {
    if (Thread.interrupted()) throw new InterruptedException();
    Node node = addConditionWaiter();
    int savedState = fullyRelease(node);
    
    // interrupt状态,用于中断自旋
    int interruptMode = 0;
    // 当前节点没有在CLH队列中,才会挂起
    while (!isOnSyncQueue(node)) {
    	// 当前线程将自己挂起
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
   	// 省略部分代码
}

这里的代码有两个需要注意的点:

  • interruptMode:当它不等于0时,表示有其它的线程调用的当前线程的interrupt()方法尝试中断等待,这里当前线程会被唤醒然后执行中断等待的操作。
  • isOnSyncQueue(node):除了interrupt中断之外,当前线程被唤醒后也需要中断自旋才能执行接下来的逻辑。所以这里的判断是在查询当前线程所在的Node是否已经被加入了CLH队列(如果在CLH中表示已经从条件队列中移除),在CLH中的线程直接进入等待抢锁的流程中。

到目前为止,当前线程已经释放了锁,并加入到了条件队列中将自己挂起,阻塞的流程就结束了。接下来只需要等待有其它的线程将当前线程唤醒,我们看看唤醒是如何实现的。

2.2.唤醒如何实现

线程调用signal()方法时,就会去条件队列中唤醒firstWaiter中的线程,当然前提是条件队列中还有处于阻塞状态的线程,代码如下:

public final void signal() {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

2.2.1.弹出头节点

只有独占锁可以使用Condition,所以这里检查到是非独占状态就会抛出异常。然后看doSinal

private void doSignal(Node first) {
    do {
    	// 弹出头节点,将firstWaiter指针向右移动
        if ((firstWaiter = first.nextWaiter) == null) {
            lastWaiter = null;
		}
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

这里为什么会有一个循环?
考虑到first节点的状态可能是CANCEL,这时就需要通过迭代了取下一个节点,直到迭代完毕或取到了CONDITION状态的节点为止。

transferForSignal(first)中会尝试修改first节点的状态,并加入到CLH队列中,并返回true中断这里的循环。

2.2.2.头节点加入CLH

final boolean transferForSignal(Node node) {
    // CANCEL状态的节点会修改失败
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;
        
   	// 头节点加入CLH队列,并返回前置节点p
    Node p = enq(node);
    int ws = p.waitStatus;
    // 如果前置节点p已取消,则唤醒当前线程去处理所有前置的已取消的节点
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        LockSupport.unpark(node.thread);
    return true;
}

如果看过上一篇笔记,这里的逻辑就不难理解了。enq(node)就是将当前节点加入到CLH队列中排队等待抢锁。

这里并没有判断是否为头节点的后置节点,为什么会去唤醒它呢?
其实在上篇笔记中一直在强调一个点,一个节点中的线程只有在它的前置节点SIGNAL状态时才能被前置节点唤醒。
上面的代码中,调用enq返回的p就是当前节点的前置节点,这个前置节点如果是CANCEL状态,就应该将当前线程唤醒去处理掉它前面的取消状态的节点,处理完后再把自己挂起。

2.2.3.线程唤醒

当前线程被唤醒后,会继续执行await()方法剩下的逻辑,我们先看看完整的方法:

public final void await() throws InterruptedException {
    if (Thread.interrupted())  throw new InterruptedException();
    Node node = addConditionWaiter();
    // 锁状态
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    // 尝试抢锁,如果抢锁成功则将锁状态修改为之前释放锁前的状态
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
   	// 当前线程抢到锁后尝试执行一次清理条件队列的操作
    if (node.nextWaiter != null) 
        unlinkCancelledWaiters();
    // 如果线程被中断,则做中断操作
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

线程被唤醒后会调用acquireQueued()尝试抢锁,这里的逻辑就和ReentrantLock一致了。
不同点在于,这里的加锁需要把之前释放的重入锁全部加回去,让线程在临界区中自己去一层层的释放重入锁。


至此,Condition中的signal()方法就执行完了,这个流程还是非常简单的,用一句话来总结:将条件队列中的非CANCEL状态的头节点弹出加入到CLH队列中等待抢锁。

图示如下:
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

挥之以墨

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

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

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

打赏作者

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

抵扣说明:

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

余额充值