Java并发编程-锁(八)

Condition的使用和实现

我们知道,任意一个Java Object,都拥有一组监视器方法,主要包括wait()、 wait(long timeout)、notify()以及notifyAll()方法,这些方法和synchronized同步关键字配合,可以 实现等待/通知模式。

Condition接口也提供了类似Object的监视器方法,和Lock配合也可以实现等待/通知模式,但是这两者在使用以及功能上还是有比较大差别的。

Condition定义了等待/通知这两种类型的方法,当前线程调用这些方法时,需要提前获取到 Condition对象关联的锁。而Condition对象是由Lock对象,也就是调用Lock对象的newCondition()方法创建出来的,我们前面讲Lock接口的时候也提到过,换句话说,Condition其实是依赖Lock对象的。

使用

Condition的使用方式比较简单,只是需要注意下在调用方法前先要获取到锁。

下面我们就通过一个有界队列的示例来深入了解下Condition的使用方式。

首先,介绍下有界队列,顾名思义,有界队列就是一种特殊的队列,当队列为空时,队列的获取操作就会阻塞获取线程,直到队列中有新增元素,当队列满了,队列的插入操作就会阻塞插入线程,直到队列出现“空位”。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedQueue<T> {
    private Object[]  items;
    // 添加的下标,删除的下标和数据当前的数量
    private int       addIndex, removeIndex, count;
    private Lock      lock     = new ReentrantLock();
    private Condition notEmpty = lock.newCondition();
    private Condition notFull  = lock.newCondition();
    public BoundedQueue(int size) {
        items = new Object[size];
    }
    // 添加一个元素,如果数组满了,就让线程进入到等待队列,直到数组有空位
    public void add(T t) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) // 防止虚假唤醒
                notFull.await();
            items[addIndex] = t;
            if (++addIndex == items.length)
                addIndex = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
    // 从头删除一个元素,如果数组是空的,则删除线程进入等待状态,直到有新添加元素
    @SuppressWarnings("unchecked")
    public T remove() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) // 防止虚假唤醒
                notEmpty.await();
            Object x = items[removeIndex];
            if (++removeIndex == items.length)
                removeIndex = 0;
            --count;
            notFull.signal();
            return (T) x;
        } finally {
            lock.unlock();
        }
    }
}

add(T t)

BoundedQueue通过add(T t)方法添加一个元素,通过remove()方法移出一个 元素。

首先需要获得锁,目的是确保数组修改的可见性和排他性。当数组数量等于数组长度时, 表示数组已满,就会调用notFull.await(),当前线程随之释放锁并进入等待状态。

如果数组数量不 等于数组长度,表示数组没满,就添加元素到数组中,同时通知等待在notEmpty上的线程,告知数 组中已经有新元素可以获取。

注意,在添加和删除方法中使用while循环而不是if判断,目的就是为了防止过早或意外的通知,只有条件符合才能够退出循环。

回想下之前提到的等待/通知的经典范式,两者是非常类似的。

实现

Condition接口的实现 ConditionObject其实是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要 获取相关联的锁。每个Condition对象都包含着一个队列(下面我们就把它叫做等待队列),这个队列是Condition对象实现等待/通知功能的关键所在。

所以呢,要分析Condition的实现,关键内容就是这3个:

等待队列、等待和通知,接下来我们提到的Condition如果没有特别说明都指的是ConditionObject类。

等待队列

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,这个线程就是 在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么这个线程就会 释放锁、构造成节点加入等待队列并进入等待状态。

事实上,这个节点的定义其实复用了同步器中节点 的定义,也就是 AbstractQueuedSynchronizer中的Node类。

一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点 (lastWaiter)这两个属性。当前线程调用Condition.await()方法之后,就会用当前线程来构造节点,并把节点从尾部 加入到等待队列里面,先来看下等待队列的基本结构。

在这里插入图片描述

可以看到,Condition拥有首尾节点的引用,而新增节点只需要把原有的尾节点的nextWaiter 属性指向它,并且更新尾节点就可以了。

注意,上面的节点引用更新的过程并没有使用CAS来保证线程安全,原因就在于调用 await()方法的线程肯定是获取了锁的线程。

对比下 Object的监视器模型,一个对象只有一个同步队列和等待队列,而并发包里的 Lock(更确切地说是同步器)有一个同步队列和多个等待队列,他们是这样对应的。

在这里插入图片描述

Condition的实现其实是同步器的内部类,所以每个Condition实例都能够访问到同步器 提供的方法。

大致了解了等待队列的结构之后,我们来看看等待的实现方式。

await()

调用Condition的await()方法(或者以await开头的方法),会让当前线程进入等待队列并且释放锁,同时线程状态会变为等待状态。而当线程从await()方法返回时,当前线程一定已经获取了和Condition相关联的锁。

如果从队列,也就是同步队列和等待队列的角度看await()方法,当调用await()方法时,相当于同步队列的首节点,也就是获取了锁的节点 移动到了Condition的等待队列里面。

注意,同步队列的首节点并不会直接加入等待队列,而是通过 addConditionWaiter()方法把当前线程构造成一个新的节点 然后再加入到等待队列中

在这里插入图片描述

来看下代码,Condition的await()方法

public final void await() throws InterruptedException {
    if (Thread.interrupted())
    //若线程已被中断(例如 Thread.interrupted() 返回 true),直接抛出 InterruptedException
        throw new InterruptedException();
    Node node = addConditionWaiter(); //将当前线程封装为节点,添加到条件变量对应的等待队列尾部
    //完全释放当前线程持有的锁(注意是彻底释放,包括重入锁的全部计数)。
    //例如,线程持有锁时多次重入,此方法会将锁状态重置为初始值
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) { //检查是否被唤醒并移入同步队列
        LockSupport.park(this);// 挂起线程
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
        //检查中断标记 checkInterruptWhileWaiting(node),若中断发生则退出循环
            break;
    }
    //退出等待循环后,通过 acquireQueued(node, savedState) 重新在同步队列中排队获取锁(与普通锁竞争逻辑一致)
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
    //处理线程重新获取锁过程中可能发生的中断,并调整中断模式
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null) 
    //清理条件队列(Condition Queue)中已被取消的等待节点,保持队列的干净和高效
        unlinkCancelledWaiters(); 
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode); //上报中断状态
}

private void reportInterruptAfterWait(int interruptMode)
    throws InterruptedException {
    if (interruptMode == THROW_IE)
        throw new InterruptedException(); // 等待过程的中断直接抛出异常
    else if (interruptMode == REINTERRUPT)
        selfInterrupt(); // 重新获取锁过程的中断,设置中断标记位,由上层处理
}

调用这个方法的线程必须是成功获取了锁的线程,也就是同步队列中的首节点,这个方法会把当前线程构造成节点并加入等待队列里面,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程就会进入等待状态。

而当等待队列中的节点被唤醒,被唤醒节点的线程就会开始尝试获取同步状态。

中断处理:

假设线程A调用 await() 后被唤醒,进入同步队列排队等待锁:

  • 若此时线程再次被中断(如 interrupt() 调用),则 acquireQueued() 返回 true

  • 若原 interruptMode0(无中断或已处理),则更新为 REINTERRUPT,后续在 reportInterruptAfterWait() 中调用 selfInterrupt() 恢复中断状态

类比说明(食堂排队场景)

在这里插入图片描述

  1. 点餐时发现没菜:
    你(线程)在食堂点餐发现没菜(条件不满足),把座位(锁)让给其他人(释放锁),排到“等菜队伍”(Condition 队列)中等候.

  2. 等待厨师补菜:
    厨师(其他线程)补菜后大喊“菜来了!”(调用 signal()),你会被从“等菜队伍”转移到“取餐队伍”(同步队列)。这时你需要重新抢座位(锁)才能开始就餐(继续执行)

signal()

调用 Condition 的 signal() 方法,会唤醒在等待队列中等待时间最⻓的节点, 也就是首节点,在唤醒节点之前,会先把节点移到同步队列里面。

在这里插入图片描述

调用这个方法的前置条件是当前线程必须获取了锁,可以看到 signal() 方法进行了isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程。

看看 signal 方法, 也就是通知方法,代码很简单

public final void signal() {
    if (!isHeldExclusively()) // 当前线程必须是获取了锁的线程
        throw new IllegalMonitorStateException();
    Node first = firstWaiter;
    if (first != null)
        doSignal(first); // 唤醒首节点
}

接着获取等待队列的首节点,调用 doSignal 执行唤醒

private void doSignal(Node first) {
// 将条件队列中的节点转移到同步队列(Sync Queue)以唤醒线程
    do {
        if ( (firstWaiter = first.nextWaiter) == null) // 更新头节点
            lastWaiter = null; // 重置尾节点
        first.nextWaiter = null; // 断开链接
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

我们看看是怎么唤醒的? 可以看到,这里是一个循环,循环内部执行的操作就是:

  1. 更新头节点:将 firstWaiter(条件队列的头部指针)指向当前节点的下一个节点 first.nextWaiter

  2. 重置尾节点:若 first 是队列的最后一个节点(nextWaiter == null),则清空 lastWaiter,保持队列一致性.

  3. 断开链接:first.nextWaiter = null 将当前节点从条件队列移除,避免循环引用

循环退出的条件就是: 某个节点成功转移(transferForSignal(first) 返回 true)或者队列遍历结束(无更多节点可处理)。

我们看看它是怎么移动的呢?

final boolean transferForSignal(Node node) {
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        //如果节点状态无法改变,说明已经被cancel
        return false;
    Node p = enq(node); // 线程安全地移动等待队列的头节点到同步队列
    int ws = p.waitStatus;
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        LockSupport.unpark(node.thread);
    return true;
}

通过调用同步器的 enq(Node node) 方法,等待队列中的头节点会被线程 安全地移动到同步队列。这个方法我们之前看过,就不具体看了 。

当节点移动到同步队列后,再使用 LockSupport 唤醒该节点的线程。被唤醒后的线程,会从 await() 方法中的 while 循环中退出 (也就是 isOnSyncQueue(Node node) 方法 返回 true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状 态的竞争中。

最后 ,成功获取同步状态 (或者说锁) 之后,被唤醒的线程会从调用的 await() 方法中返回,这个时候线程已经成功地获取了锁。

signalAll()

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; //记录当前节点的下一个节点 next
        first.nextWaiter = null;//断开当前节点的链接,防止残留引用干扰遍历
        transferForSignal(first);//将节点传入同步队列,唤醒对应线程
        first = next;//移动至下一个节点
    } while (first != null);
}

看看 Condition的signalAll()方法,它其实就相当于对等待队列中的每个节点 都执行一次 signal() 方法,效果就是把等待队列中所有节点全部移动到同步队 列当中,并且唤醒每个节点的线程。

总结

这一节主要介绍了Java并发包中和锁相关的API和组件,也通过示例展示了这些API和组件的使用 方式以及需要注意的地方,并且在此基础上我们一起详细地剖析了队列同步器、重入锁、读写锁以及 Condition等 API和组件的实现细节。只有真正理解了这些API和组件的实现细节才能够更加准确地运用它们。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

递归书房

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

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

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

打赏作者

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

抵扣说明:

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

余额充值