lock锁实现条件队列
一、条件队列的意义
条件(也称为条件队列或条件变量)为一个线程暂停执行(“等待”),直到满足了条件,另一线程通知因为条件不满足而阻塞了的线程。
由于对该共享状态信息的访问发生在不同的线程中,因此必须对其进行保护,因此某种形式的锁与该条件相关联。 等待条件提供的关键属性是它自动释放关联的锁并挂起当前线程,就像Object.wait一样。
二、创建条件队列
使用lock锁来创建条件队列
static ReentrantLock lock = new ReentrantLock();
// 假期等待集
static Condition vocationCondition = lock.newCondition();
// 价钱等待集
static Condition moneyCondition = lock.newCondition();
看一下添加底层创建条件队列的源码:
final ConditionObject newCondition() {
return new ConditionObject();
}
在下面会来说条件队列对象ConditionObject。
Condition将Object监控器方法( wait , notify和notifyAll )分解为不同的对象,从而通过与任意Lock实现结合使用,从而使每个对象具有多个等待集。
Lock替换了synchronized方法和语句的使用,而Condition替换了Object监视器方法的使用。
Condition实例从本质上说就是通过同步队列住转移到条件队列,然后再从条件队列转移到同步队列。
二、条件队列结构
ConditionObject对象中只有两个属性:firstWaiter、lastWaiter。队头和队尾。
因为条件队列中队头和队尾是Node类型,而同步队列中的节点类型也是Node类型。
所以二者之间就建立起来了对应的联系。
队列的信息包含以下几个部分:
- private transient Node firstWaiter;// 队列的头部结点;
- private transient Node lastWaiter;// 队列的尾部节点;
- 而Node节点中的nextWaiter表示条件队列中的下一个结点;
条件队列是一个单向链表,在该链表中我们使用nextWaiter属性来串联链表。但是,就像在同步队列中不会使用nextWaiter属性来串联链表一样,在条件队列是中,也并不会用到prev, next属性,它们的值都为null。
队列中节点的信息包含以下几个部分:
- 当前节点的线程 thread
- 当前节点的状态 waitStatus
- 当前节点的下一个节点指针 nextWaiter
所以在分析条件队列中的Node结点的时候,重点关注结点中的属性即可。
三、条件队列和同步队列的区别
在条件队列和同步队列中的节点都是Node类型,所以属性是完全相同的。
在同步队列中,只使用Node结点中的prev、next来串联链表,且是双向链表。
在条件队列中,只是用到了Node结点中的nextWaiter来串联链表,且是单向链表;
但是对于节点Node来说,先从同步队列中转移到条件队列中,然后在从条件队列转移到同步队列。
四、同步队列转移到条件队列和条件队列转移到同步队列
下面来解释下上面说的:先从同步队列中转移到条件队列中,然后在从条件队列转移到同步队列。
(1)同步队列转移到条件队列
直接看代码:
lock.lock();
moneyCondition.await();
lock.unlock();
既然当前线程能够获取得到锁,那么就意味着,当前线程是在同步队列中的。
画个简单的图表示:lock.lock()
而在条件队列调用:moneyCondition.await(); ,这个时候,看下底层做了什么?
看下源代码中的实现:
注意:在条件队列中添加线程的时候,是持有锁的。不存在线程安全问题。
既然在条件队列中添加了当前线程,那么就意味着应该将在同步线程中的线程移除掉。
所以先看下条件队列的初始化过程
4.1、条件队列初始化
每一个线程调用了Condtion对象的await方法的线程都会被包装成新的Node结点扔进一个条件队列中
条件队列对于新来的结点采用的是尾插法。
下面来看下源代码中的演示:
对于第一个新来的结点来说,肯定是
当以后的线程过来的时候,如下所示:
形成一个单向链表结构,新来的线程插在尾结点中来。
注意:在条件队列中,我们只需要关注一个值即可那就是Node.CONDITION。它表示线程处于正常的等待状态,条件队列中的waitStatus都应该是CONDITION
而只要waitStatus不是CONDITION,我们就认为线程不再等待了,此时就要从条件队列中出队。
4.2、同步队列移除掉当前节点
首先对于当前节点来说,是因为被唤醒了才能够这样子操作。(忽略是因为线程中断引起的)
那么就意味着当前节点是头结点了。
因为条件不满足,当前线程进入到了条件队列中。所以当前同步队列中的线程应该移除掉。
因为在条件队列中和同步队列中的Node已经不是一个对象了,所以这里直接获取得到修改state来释放锁。
看看具体的代码:
因为是当前线程持有锁,所以可以持有锁。但是需要注意的是:现在的head就是当前节点
当前操作中仅仅只是将node节点中的独占锁exclusiveOwnerThread置为null,还有一些属性需要重置。
那么只需要看一下unparkSuccessor方法
此时,node节点中的节点状态pre为空,但是next还有值。需要下一个节点获取得到锁的时候,将next置为null。方便GC
4.3、条件队列转移到同步队列中
此时此刻,条件队列中的node只有两个属性:currentthread和waitStatus=Node.CONDITION
当前线程被唤醒(忽略线程中断),所以满足条件了,再次来尝试得到锁。
这里太过于熟悉了。无非是获取得到了锁,直接执行;又或者是再次进入到阻塞队列中来,等待被唤醒。
如果是没有获取得到锁,那么会将waitStatus状态从Node.CONDITION修改到SIGNAL;
如果是直接获取得到了锁,会在锁释放的时候,将Node.CONDITION改为0;
这里直接分析能够持有锁的情况下,就开始执行临界区代码。
执行完成需要释放锁,那么怎么释放?
是否还记得一行代码?
lock.lock();
moneyCondition.await();
lock.unlock();
lock.unlock();这个是在这里使用到的。
所以上面的三行代码看起来是一起执行的,但是事实上并不是一起执行的。
执行顺序是:lock.lock(); moneyCondition.await();-------->在同步队列中获取得到锁之后,转移到条件队列中阻塞,释放锁;
然后是:moneyCondition.await();lock.unlock();-------->获取得到锁之后,执行临界区代码,然后释放锁;
但是这里也同样的理解了,为什么条件队列为什么要结合着lock锁来执行了。
2.2、同步队列和条件队列
一般情况下,等待锁的同步队列和条件队列条件队列是相互独立的,彼此之间并没有任何关系。
1、条件队列中的线程首先要在同步队列中
但是如果想要使用到条件队列,那么必须得首先持有锁。那么添加到条件队列中的线程,首先第一步需要的进入到同步队列中来,然后从同步队列中转移到条件队列中去。
lock.lock();
moneyCondition.await();
lock.unlock();
而只有调用了await方法,才会进入到条件队列中去。
2、条件队列调用signal方法尝试转移到同步队列中来
但是,当我们调用某个条件队列的signal方法时,会将某个或所有等待在这个条件队列中的线程唤醒,被唤醒的线程和普通线程一样需要去争锁,如果没有抢到,则同样要被加到等待锁的同步队列中去,此时节点就从条件队列中被转移到同步队列中
从网上找来一个图来进行描述:
但是,这里尤其要注意的是,node是被 一个一个转移过去的,哪怕我们调用的是signalAll()方法也是一个一个转移过去的,而不是将整个条件队列接在同步队列的末尾。
同时要注意的是,我们在同步队列中只使用prev、next来串联链表,而不使用nextWaiter;我们在条件队列中只使用nextWaiter来串联链表,而不使用prev、next.事实上,它们就是两个使用了同样的Node数据结构的完全独立的两种链表。 因此,将节点从条件队列中转移到同步队列中时,我们需要断开原来的链接(nextWaiter),建立新的链接(prev, next),这某种程度上也是需要将节点一个一个地转移过去的原因之一。
3、条件队列和同步队列的区别
同步队列是等待锁的队列,当一个线程被包装成Node加到该队列中时,必然是没有获取到锁;当处于该队列中的节点获取到了锁,它将从该队列中移除(事实上移除操作是将获取到锁的节点设为新的对头元素,并将thread属性置为null)。
条件队列是等待在特定条件下的队列,因为调用await方法时,必然是已经获得了lock锁,所以在进入条件队列前线程必然是已经获取了锁;在被包装成Node扔进条件队列中后,线程将释放锁,然后挂起;当处于该队列中的线程被signal方法唤醒后,由于队列中的节点在之前挂起的时候已经释放了锁,所以必须先去再次的竞争锁,因此,该节点会被添加到同步队列中。因此,条件队列在出队时,线程并不持有锁。
而且需要注意的是,当线程调用await方法之后,是先让线程进入到条件队列中,形成单链表之后,才释放锁的。
从而避免线程安全问题。
这句话总结的非常好。
三、源码分析
上面只是对源码中的信息做了分析和总结,而下面才会来看真正的源码。
1、await方法
拿到锁的线程因为不满足某种条件而进入到条件队列中来。想要使用这种效果,需要调用condition.await()方法实现。
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 添加节点到条件队列中
Node node = addConditionWaiter();
// 释放当前线程所占用的锁,保存当前的锁状态
int savedState = fullyRelease(node);
int interruptMode = 0;
// 如果当前队列不在同步队列中,说明刚刚被await, 还没有人调用signal方法,
// 则直接将当前线程挂起
while (!isOnSyncQueue(node)) {
// 线程挂起的地方
LockSupport.park(this);
// 线程将在这里被挂起,停止运行
// 能执行到这里说明要么是signal方法被调用了,要么是线程被中断了
// 所以检查下线程被唤醒的原因。重点代码
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 线程将在同步队列中利用进行acquireQueued方法进行“阻塞式”争锁,
// 抢到锁就返回,抢不到锁就继续被挂起。因此,当await()方法返回时,
// 必然是保证了当前线程已经持有了lock锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
// 因为中断唤醒的
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
线程醒来的原因
线程如果正在阻塞中,醒来的原因无非有两个:
- 1、被条件队列中的线程调用singal唤醒;
- 2、阻塞线程因为中断被唤醒
因为支持可中断的方式,所以这里必须得记录一下到底是因为哪种原因导致线程被唤醒的。
/**
* Checks for interrupt, returning THROW_IE if interrupted
* before signalled, REINTERRUPT if after signalled, or
* 0 if not interrupted.
*/
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}
从源码注释中看:检查是否中断
- 1、如果在signalled之前,线程已经中断,那么返回THROW_IE(-1)
- 2、如果在signalled之后,线程已经中断,那么返回THROW_IE(1)
- 3、如果没有被中断过,那么线程返回0,表示正常返回。正常返回的时候表示的是,是被同步队列中的线程唤醒的;
三种状态都会跳出while循环。但是循环方式不同:
- 1、如果出现了异常而中断,这里是直接break;
- 2、如果是正常的被唤醒的,那么返回0。然后再次判断在同步队列中,当前肯定在同步队列中;因为这个而退出来的
然后来尝试获取得到锁来执行。
unlinkCancelledWaiters
这个方法在await方法中体现出来了两次。
1、入队的时候需要判断一下尾结点是否取消了;为了提高效率,先检查一下
2、唤醒醒来的时候,判断下一个结点;
那么来看一下这里的算法是如何实现的:
private void unlinkCancelledWaiters() {
Node t = firstWaiter;
Node trail = null;
while (t != null) {
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) {
t.nextWaiter = null;
if (trail == null)
firstWaiter = next;
else
trail.nextWaiter = next;
if (next == null)
lastWaiter = trail;
}
else
trail = t;
t = next;
}
}
做个图来表示
经过第一轮循环之后
经过第二轮循环之后
经过第三轮循环之后
然后经过最后一轮循环之后
所以unlinkCancelledWaiters的目的就是为了排除掉waitStatus不为Node.CONDITION(-2)的结点。
transferAfterCancelledWait
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}
如果是因为线程中断而被取消的,那么将会执行第一个逻辑判断。
而线程中断又分为了在signalled之前中断的和在signalled之后中断的。
所谓的中断无非是外部触发了某个条件,需要给当前线程一个中断标记,让线程停止工作而已。
在唤醒之前中断
transferAfterCancelledWait方法
final boolean transferAfterCancelledWait(Node node) {
// 一般说来,这种会终止掉
if (compareAndSetWaitStatus(node, Node.CONDITION, 0)) {
enq(node);
return true;
}
/*
* If we lost out to a signal(), then we can't proceed
* until it finishes its enq(). Cancelling during an
* incomplete transfer is both rare and transient, so just
* spin.
*/
while (!isOnSyncQueue(node))
Thread.yield();
return false;
}
中断唤醒的线程,此时可能是没有锁的。CAS交换waitStatus之后,将waitStatus置为0,然后队列。
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
在总结这里的时候,发现了一个问题。在从条件队列中转移到同步队列的队列中,node的nextWaiter还是存在着连接的。整个条件队列中还是有指向的。
所以才会有了下面判断:
if (interruptMode != 0)
// 因为中断而导致的传播
reportInterruptAfterWait(interruptMode);
/**
* Throws InterruptedException, reinterrupts current thread, or
* does nothing, depending on mode.
*/
private void reportInterruptAfterWait(int interruptMode)
throws InterruptedException {
if (interruptMode == THROW_IE)
throw new InterruptedException();
else if (interruptMode == REINTERRUPT)
selfInterrupt();
}
正常情况下是直接抛出异常!而标记成自我中断的,需要在外部循环进行判断。
在唤醒之后中断
用到的时候再来表示。
条件队列将节点添加进同步队列中,并要么立即唤醒线程,要么等待前驱节点释放锁后将自己唤醒,无论怎样,被唤醒的线程要从哪里恢复执行呢?调用了await方法的地方
四、条件队列总结
条件队列是基于lock锁的基础之上建立起来的,进入到条件队列之前,一定是在同步队列中的。
而在条件队列中的线程,只会在唤醒之后,才会进入到同步队列中。这里分为两种情况:1、排队;2、直接可以获取得到锁;
获取得到锁的线程如果满足了条件,那么直接执行临界区代码;如果不满足条件,那么进入到条件队列中进行等待。
典型的生产者/消费者模式。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
2021-10-21 mybatis-plus的使用