JDK 提供了一系列场景的并发安全队列。总的来说,按照实现方式的不同 可分为 阻塞队列 和 非阻塞队列,前者使用锁🔒实现,后者使用 CAS 非阻塞算法实现。
文章目录
一、ConcurrentLinkedQueue
ConcurrentLinkedQueue 是线程安全的无界非阻塞队列,其底层数据结构使用单向链表实现,对于入队 和 出队,使用 CAS 实现线程安全。
1、类图
ConcurrentLinkedQueue 内部的队列使用单向链表的方式实现,其中有两个 volatile 类型的 Node 节点 分别用来存放队列的首 head、尾 tail 节点:
private transient volatile Node<E> head;
private transient volatile Node<E> tail;
ConcurrentLinkedQueue无参构造源码:
public ConcurrentLinkedQueue() {
// (1)
head = tail = new Node<E>(null);
}
(1):
Node(E item) {
UNSAFE.putObject(this, itemOffset, item);
}
可以看到,默认头、尾节点都是指向 item 为 null值 的哨兵节点。
Node 是 ConcurrentLinkedQueue 的 静态内部类,源码:
private static class Node<E> {
volatile E item;
volatile Node<E> next;
可以看到,在 Node 节点内部维护一个使用 volatile 修饰的变量 item,用来存放节点的值,next 用来存放链表的下一个节点。新元素会被插入到队列末尾,出队时从队列头部获取一个元素,内部使用 UNSAFE 工具类提供的 CAS 算法保证出入队时操作链表的原子性。
2、ConcurrentLinkedQueue 原理介绍
(1) offer 操作
源码:
public boolean offer(E e) {
// (一)e 为 null 则抛出空指针异常
checkNotNull(e);
// (二)
final Node<E> newNode = new Node<E>(e);
// (三)
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
// (四)如果 q == null,说明 p 是尾节点
if (q == null) {
// (五)使用 CAS 设置 p 节点的 next 节点
if (p.casNext(null, newNode)) {
// (六)
if (p != t) // hop two nodes at a time
casTail(t, newNode); // Failure is OK.
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q)
// (七)多线程操作时,由于 poll 操作移除元素后 可能会把 head 变成自引用,也就是 head 的 next 变成了 head,所以这里需要重新找新的 head
p = (t != (t = tail)) ? t : head;
else
// (八)寻找尾节点
p = (p != t && t != (t = tail)) ? t : q;
}
}
对于线程第一次添加元素,分析如下:(一),对传入的参数进行空检查,如果为 null,则抛出 NPE 异常。否则执行(二),并使用 e 作为构造方法的参数,创建一个新的节点,然后 (三)从尾部开始循环,打算从尾部添加元素,执行到 (四)时,如图所示:
这时节点 p、t、head、tail 同时指向了 item 为 null 的哨兵节点,由于哨兵节点的 next 节点为 null,所以 q 也指向 null。(四) 成立,接下来执行 (五),提供 CAS 操作 判断 p 节点的 next 节点是否为 null,如果为 null ,则使用节点 newNode 替换 p 的 next 节点,然后执行(六)由于 p 是 == t 的,所以不会设置尾节点 tail,接下来就返回 true 退出 offer 方法。
这是一个线程调用 offer 方法的情况,如果多个线程同时调用,就会存在多个线程同时执行到(五)的情况。假设线程 A 调用 offer(e1),线程 B 调用 offer(e2),同时执行到 (五) if (p.casNext(null, newNode))
。由于 CAS 的比较设置操作是原子性的,假设线程 A 先执行了比较设置操作,发现当前 p 的 next 节点确认是 null,则会原子性更新 next 节点为 e1 ,这时线程 B 也会判断 p 的 next 节点是否为 null,结果发现不是,就会跳到 (三),然后执行代码 (四):
再下来,线程 B 的代码应该执行(八),把 q 赋给 p:
然后线程 B 再次跳到(三)执行 Node<E> q = p.next;
:
这时 q == null
成立,所以线程 B 会执行代码 (五),通过 CAS 操作,判断当前 p 的 next 节点是否为 null,不是则再次循环尝试,是的话则使用 e2 替换。假设 CAS 成功了,(也就是说 CPU 没给线程 A,就是这种情况,是可以 CAS 成功的。) 那么执行 (六),由于 p != t
,所以设置 tail 节点为 e2,返回退出 offer 方法:
分析至此,(七)没有走过,其实这一步需要在执行 poll 操作后才会执行。这里来看一下执行 poll 操作后可能会存在的一种情况:(等会儿讲 poll 方法时就会见到)
这时执行 offer 方法,执行到(三)时:
接下来,q == null
不成立,而且 p == q
,所以执行(七),t = tail
,所以会把 head 赋给 p,然后继续循环,到了 (三),会把 p.next 也就是 NULL 赋给 q:
接下来到了(四),由于 q == null
成立,所以会执行 (五),进行 CAS 操作,如果当前没有其他线程执行 offer 操作,则 CAS 就会成功,p 的 next 节点被设置为新增节点,然后执行代码(六),由于 p != t
,所以设置新节点为队列的尾部:
然后哪个自引用的节点就会被垃圾回收掉。
可见,offer 操作中的关键步骤是 (五),通过原子 CAS 操作来控制某时只有一个线程可以追加元素到末尾,直到 CAS 成功了才会返回,也就是通过无限循环不断进行 CAS 尝试方式来替代阻塞算法挂起调用线程。相比阻塞算法,这是使用 CPU 资源换取阻塞所带来的开销。
(2) add 操作
add 操作是在链表末尾添加一个元素,其实在内部调用的还是 offer 操作。
public boolean add(E e) {
return offer(e);
}
(既生 offer 何生 add ❔❔❔)
(3) poll 操作
在队列头部获取并移除一个元素,如果队列为空 则返回 null。
源码:
public E poll() {
// (一)goto 标记
restartFromHead:
// (二)
for (;;) {
for (Node<E> h = head, p = h, q;;) {
// (三)保存当前节点值
E item = p.item;
// (四)当前节点有值 则通过 CAS 变为 null
if (item != null && p.casItem(item, null)) {
// (五)CAS 成功则标记当前节点 并从链表中移除
if (p != h) // hop two nodes at a time
//(六)
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
// (七)当前队列为空 则返回 null
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
// (八)如果当前节点被自引用了,则重新寻找新的队列头节点
else if (p == q)
continue restartFromHead;
else
// (九)
p = q;
}
}
}
(六)updateHead:
final void updateHead(Node<E> h, Node<E> p) {
if (h != p && casHead(h, p))
h.lazySetNext(h);
}
队列一开始为空时:
进入循环。 因为 item == null
,所以代码走到 (七),假设这个过程中没有线程调用 offer 方法,则此时 q 等于 null :
下来该执行 updateHead(h, p)
方法,由于 h == p
,所以没有设置头节点,poll 方法直接返回 null 。
假设执行到 (七)时,已经有其他线程调用了 offer 方法 并成功添加一个元素到队列,这时候 q 指向的是新增元素的节点:
这时 ,(七)判断结果为 false,继续执行代码 (八),此时 p 不等于 q, 执行代码 (九),p 指向 q:
然后继续循环,到(四),通过 CAS 操作尝试设置 p 的 item 值为 null,如果此时没有其他线程进行 poll 操作,则 CAS 成功,执行 (五),由于 p 不等于 h,所以设置头节点为 p,并设置 h 的next 节点为它自己,然后返回从队列中移除的节点值 item。
现在代码中的分支 (八)还没有走过,什么时候会执行呢?假设线程 A 执行 poll 操作时,当前队列状态是:
那么执行 到(四)p.casItem(item, null) 通过 CAS 操作 尝试设置 p 的 item 值为 null:
假设 CAS 设置成功,这时 p != h
,所以会执行 (六)updateHead 方法,假设线程 A 执行 updateHead 前,另一个线程 B 开始 poll 操作,这时 线程 B 的 p 指向 head 节点,但是还没有执行到(七):
然后线程 A 执行 updateHead ,执行完毕后线程 A 退出:
然后线程 B 继续执行代码(七),q = p.next
,由于该节点是自引用节点,所以 p==q ,就会执行 (八),跳到外层循环,然后获取当前队列头节点 head :
🎭总结:
poll 是简单地使用 CAS 操作把当前节点的 item 值设置为 null,然后通过重新设置头节点将该元素从队列里移除,被移除的节点就成了孤立节点,这个节点会在垃圾回收时被回收掉。另外,如果在执行分支中发现头节点被修改了,要跳到外层循环重新获取新的头节点。
(4) peek 操作
获取队列头部一个元素(只获取 不移除),如果队列为空 则返回 null。
源码:
public E) {
// (一)
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
// (二)
E item = p.item;
// (三)
if (item != null || (q = p.next) == null) {
updateHead(h, p);
return item;
}
// (四)
else if (p == q)
continue restartFromHead;
else
// (五)
p = q;
}
}
}
peek 操作与 poll 操作类似,不同之处在于 (三) 处少了 casItem(item, null) ,因为 peek 只是获取队头元素值,并不清空值。
执行到 (三):
接下来执行 updateHead(h, p);
,由于 h 节点等于 p 节点,所以不进行任何的操作,然后 peek 操作会返回 null。
当队列中至少有一个元素时,假设只有一个;
这时会执行到 (四),但是 p != q,所以会执行(五),p 指向 q:
继续循环,到 (三),item != null,所以执行 updateHead(h, p); ,设置 头节点:
也就是剔除了哨兵节点。
🎭总结:
peek 操作的代码与 poll 相似,只是前者只获取队列头元素 但是并不从队列里将它剔除,而后者 获取后需要从队列中将它剔除。另外,第一次调用 peek 方法时,会删除 哨兵节点 ,并让队列的 head 节点指向队列里第一个元素 或者 null 。
(5)size 操作
计算当前队列元素个数,在并发环境下不是很有用,因为 CAS 没有加锁,所以从调用 size 方法到返回结果期间有可能增删元素,导致统计的个数不精确。
源码:
public int size() {
int count = 0;
// (一) (二)
for (Node<E> p = first(); p != null; p = succ(p))
if (p.item != null)
// Collection.size() spec says to max out
if (++count == Integer.MAX_VALUE)
break;
return count;
}
(一):获取第一个元素(哨兵元素不算),没有则为 null
Node<E> first() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
boolean hasItem = (p.item != null);
if (hasItem || (q = p.next) == null) {
updateHead(h, p);
return hasItem ? p : null;
}
else if (p == q)
continue restartFromHead;
else
p = q;
}
}
}
(二):获取当前节点的 next 元素,如果是自引入节点 则 返回真正的头节点
final Node<E> succ(Node<E> p) {
Node<E> next = p.next;
return (p == next) ? head : next;
}
(6)remove 操作
如果队列里存在该元素,则删除该元素,如果存在多个则删除第一个,并返回 true,否则返回 false。
public boolean remove(Object o) {
if (o != null) {
Node<E> next, pred = null;
for (Node<E> p = first(); p != null; pred = p, p = next) {
boolean removed = false;
E item = p.item;
if (item != null) {
if (!o.equals(item)) {
// 获取 next 元素
next = succ(p);
continue;
}
removed = p.casItem(item, null);
}
next = succ(p);
// 如果有前驱节点,并且 next 节点不为空 则链接前驱节点到 next 节点
if (pred != null && next != null) // unlink
pred.casNext(p, next);
if (removed)
return true;
}
}
return false;
}
(7) contains 操作
判断队列中是否含有指定对象,由于是遍历整个队列,所以像 size 操作一样,结果也不精确,有可能调用该方法的时候 元素还在队列里面,但是遍历过程中 其他线程把元素删除了,那就会返回 false 了。
源码:
public boolean contains(Object o) {
if (o == null) return false;
for (Node<E> p = first(); p != null; p = succ(p)) {
E item = p.item;
if (item != null && o.equals(item))
return true;
}
return false;
}
🎭总结 :
✨ ConcurrentLinkedQueue 的底层使用单向链表数据结构,每个元素被包装成一个 Node 节点。队列靠头、尾节点维护,创建队列时 头、尾系欸但指向一个 item 为 null 的哨兵节点。第一次执行 peek 或者 first 操作时,会把 head 指向第一个真正的队列元素。由于使用非阻塞 CAS 算法,没有加锁,所以在计算 size 时有可能进行了 offer、poll 或者 remove 操作,导致计算的个数不准确,所以在并发情况下, size 方法不是很有用。
入队、出队都是使用 volatile 修饰的 tail、head 节点,要保证在多线程下出入队线程安全,只需要保证这两个 Node 操作的可见性 和 原子性即可。由于 volatile 保证可见性,所以只需要保证对两个变量操作的原子性即可。
offer 操作是在 tail 后面添加元素,也就是调用 tail.casNext 方法,而这个方法使用的是 CAS 操作,只有一个线程会成功,然后失败的线程会循环,重新获取 tail,再只需 casNext,poll 也是通过类似的 CAS 算法保证出队时 移除节点操作的原子性。✨
    
二、LinkedBlockingQueue
1、类图
可以看到,LinkedBlockingQueue 也是使用单向链表实现的,也有两个 Node,分别用来存头、尾节点,还有个初始值为 0 的原子变量 count,用来记录队列元素个数。另外还有两个 ReentrantLock 的实例,分别用来控制元素入队和出队的原子性,其中 takeLock 用来控制同时只有一个线程可以从队列头获取元素,其他线程必须等待,putLock 控制同时只能有一个线程可以获取锁,在队列尾部添加元素,其他线程必须等待。还有 notFull 和 notEmpty 是条件变量,内部都有一个条件队列用来存放出队和入队被阻塞的线程,其实这就是 生产者-消费者模型。
无参构造源码:
public LinkedBlockingQueue() {
// (一) 队列默认容量是 0x7fffffff
this(Integer.MAX_VALUE);
}
队列的容量用户也可以自己指定的,从一定程度上说,LinkedBlockingQueue 是有界阻塞队列。
(一):
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
// 初始化头、尾节点,让它们指向哨兵节点
last = head = new Node<E>(null);
}
2、LinkedBlockingQueue 原理介绍
(1) offer 操作
向队列尾部插入一个元素,如果队列中有空闲则插入成功后返回 true,如果队列已满,则丢弃当前元素,然后返回 false,如果 e 元素为 null 则抛出 NullPointerException 异常。该方法是非阻塞的。
源码:
public boolean offer(E e) {
// 为空则抛出空指针异常
if (e == null) throw new NullPointerException();
final AtomicInteger count = this.count;
// 如果当前队列满则丢弃将要放入的元素,然后返回 false
if (count.get() == capacity)
return false;
int c = -1;
// 构建新节点
Node<E> node = new Node<E>(e);
// 获取 putLock 独占锁
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
// 重新判断当前队列是否满,如果队列不满则进队
if (count.get() < capacity) {
enqueue(node);
// 递增元素计数
c = count.getAndIncrement();
// 如果新元素入队后队列还有空闲空间,则唤醒 notFull 的条件队列里因为调用了 notFull 的 await 操作而被阻塞的一个线程
if (c + 1 < capacity)
notFull.signal();
}
} finally {
// 释放锁
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
return c >= 0;
}
(2) put 操作
向队列尾部插入一个元素,如果队列中有空闲则插入后直接返回,如果队列已满,阻塞当前线程,直到队列有空闲,插入成功后返回。如果在阻塞时 被其他线程设置了中断标志,则被阻塞线程会抛出 InteruptedException 异常并返回。另外,如果插入的元素为 null 则抛出 NullPointerException 异常。
源码:
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
// 创建新节点,并获取独占锁 putLock
// private final ReentrantLock putLock = new ReentrantLock();
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
// 获取独占锁,可以被中断
putLock.lockInterruptibly();
try {
// 如果队列满,则把当前线程放入 notFull 的条件队列,线程被阻塞挂起
// while 循环,避免虚假唤醒
while (count.get() == capacity) {
notFull.await();
}
// 入队并递增计数
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
(3)poll 操作
从队列头部获取并移除一个元素,如果队列为空,则返回 null,该方法是不阻塞的。poll 逻辑毕竟简单,值得注意的是,获取元素时 只操作了队列的头节点。
源码:
public E poll() {
final AtomicInteger count = this.count;
// 队列为空则返回 null
if (count.get() == 0)
return null;
E x = null;
int c = -1;
// 获取独占锁 takeLock
// private final ReentrantLock takeLock = new ReentrantLock();
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
// (一)队列不为空则出队 并递减计数
if (count.get() > 0) {
// (二)
x = dequeue();
// (三)
c = count.getAndDecrement();
// 如果计数值大于 1 ,说明当前线程移除队列里的一个元素后 队列不为空
// 这时就可以激活 调用了 take 方法 但是因为队列满 而被阻塞到 notEmpty 的条件队列里的一个线程
if (c > 1)
notEmpty.signal();
}
} finally {
takeLock.unlock();
}
// 如果当前线程移除队头元素前当前队列是满的,那么移除队头元素后 当前队列至少有一个空闲位置
if (c == capacity)
// 那么就可以调用 signalNotFull 激活因为调用 put 方法但是队满 而 阻塞到 notFull 的条件队列里的一个线程
signalNotFull();
return x;
}
(三):
/**
* Atomically decrements by one the current value.
*
* @return the previous value
*/
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}
注释里说, getAndDecrement() 方法原子性地把当前值减 1,返回原先的值。 注意,c 存放的是 count 递减前的值,也就是 当前线程移除元素前 队列的元素个数。
注意到,(一)判断当前队列不为空,则进行出队操作,然后(三)递减计数器,❓ 如何保证 (一)时队列不为空,而执行到 (二)时 队列也一定不为空呢,会不会出现 (一)判断队列不为空了,但是执行到(二)时,其实队列已经空了呢 ❓ 毕竟这不是原子性操作。主要看在 (二)之前 ,哪些地方会修改 count 的计数。由于当前线程已经拿到了 takeLock 锁,所以其他调用 poll 或者 take 方法的线程 不可能去修改 count 了,要是有线程在修改 count,那应该是调用了 put 和 offer 方法,因为 put 和 poll 获取的是 putLock 锁而不是 takeLock 锁,但是 put 和 offer 操作内部是增加 count 计数值的,所以肯定不会出现 (一)判断队列为空,而到了(二)时队列空了的情况。其实只需要看在哪些地方,递减了 count 值即可,只有递减了 count 才会出现上面的情况。只有 poll、take 或者 remove 方法中会递减 count 值,但是这三个方法都需要获取到 takeLock 才能进行操作,而当前线程已经获取了 takeLock 锁,所以其他线程没有机会在当前情况下递减 count 计数值,所以即使 (一)、(二)不是原子性的,但是它们是线程安全的。
(4)peek 操作
获取队列头部元素,但是不从队列里移除它,如果队列为空 则返回 null,该方法是不阻塞的。
源码:
public E peek() {
// (一)
if (count.get() == 0)
return null;
// (二)
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
Node<E> first = head.next;
//(三)
if (first == null)
return null;
else
// (四)
return first.item;
} finally {
takeLock.unlock();
}
}
(一) 和 (二)又不是原子性操作,也就是说,在执行 (一) ,判断队列不为空后,(二)获取锁之前,可能有其他线程执行了 poll 或者 take 操作 导致队列变为 空,然后当前线程获取锁后,继续往下执行,执行到 (四),就会抛出空指针异常。 这就是 poll 和 peek 方法的不同之处 。
(5)take 操作
获取当前队列头部元素 并从队列里移除它,如果队列为空,则阻塞当前线程,直到队列不为空,然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出 InterruptedException 异常而返回。
源码:
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
// 获取锁
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
// 如果当前队列为空 则阻塞挂起,并把当前线程放入 notEmpty 的条件队列
while (count.get() == 0) {
notEmpty.await();
}
// 出队并递减计数
x = dequeue();
c = count.getAndDecrement();
// 如果当前队列不为空,就唤醒因为调用 take 但队空而被阻塞到 notEmpty 的条件队列里的一个线程
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 如果当前线程移除队头元素前当前队列是满的,那么移除队头元素后 当前队列至少有一个空闲位置
if (c == capacity)
// 那么就可以调用 signalNotFull 激活因为调用 put 方法但是队满 而 阻塞到 notFull 的条件队列里的一个线程
signalNotFull();
return x;
}
(6)remove 操作
删除队列里面指定的元素,有 则删除 并返回 true,没有则返回 false。在删除指定元素前加了两把锁,所以在遍历队列查找指定元素的过程中,是线程安全的,并且此时其他调用入队、出队操作的线程全部会被阻塞。
源码:
public boolean remove(Object o) {
if (o == null) return false;
// (一)双重加锁
fullyLock();
try {
// 遍历队列 找到则删除 并返回 true
for (Node<E> trail = head, p = trail.next;
p != null;
trail = p, p = p.next) {
if (o.equals(p.item)) {
// (二)
unlink(p, trail);
return true;
}
}
// 找不到则返回 false
return false;
} finally {
// (三)
fullyUnlock();
}
}
(一) 获取双重锁,获取后,其他线程进行入队 或者 出队 操作时 就会被阻塞挂起:
void fullyLock() {
putLock.lock();
takeLock.lock();
}
(二):
void unlink(Node<E> p, Node<E> trail) {
// assert isFullyLocked();
// p.next is not changed, to allow iterators that are
// traversing p to maintain their weak-consistency guarantee.
p.item = null;
trail.next = p.next;
if (last == p)
last = trail;
if (count.getAndDecrement() == capacity)
notFull.signal();
}
(三):与加锁顺序相反 的顺序释放双重锁。
void fullyUnlock() {
takeLock.unlock();
putLock.unlock();
}
(7)size 操作
源码:
public int size() {
return count.get();
}
获取当前队列元素个数。 由于入队、出队 操作的 count 都是加了锁的,所以结果相比 ConcurrentLinkedQueue 的 size 方法比较准确,
🎭总结 :
LinkedBlockingQueue 的内部是通过 单向链表实现的,使用头、尾 节点来进行入队和出队操作。对头、尾的操作分别使用了单独的独占锁 从而保证了原子性,所以入队和出队是可以同时进行的。另外,对头、尾节点 的独占锁都配备了一个条件队列,用来存放被阻塞的线程,并结合入队、出队实现了一个生产消费模型。