LinkedBlockingQueue 源码解析
说到队列,大家的反应可能是我从来都没有用过,应该不是重要的 API 吧。其实,我们平时使用到的线程池、读写锁、消息队列等等技术和框架,底层都是队列。队列是很多高级 API 的基础,学好队列,对自己深入学习 Java 非常重要。
本文主要以 LinkedBlockingQueue 队列为例,详细描述一下底层具体的实现。
整体架构
LinkedBlockingQueue 直译过来做链表阻塞队列,从命名上就知道其底层数据结构是链表,并且队列是可阻塞的。
类图
从类图中,我们大概可以看出两条路径:
- AbstractQueue -> AbstractCollection -> Collection -> Iterable 这条路径依赖,主要是想复用 Collection 和迭代器的一些操作。
- BlockingQueue -> Queue -> Collection。
Queue 是最基础的接口,几乎所有的队列实现类都会实现这个接口,该接口定义了队列的三大操作:
- 新增操作:
- add:队列满的时候抛异常
- offer:队列满的时候返回 false
- 查看并删除操作:
- remove:队列为空的时候抛异常
- poll:队列空的时候返回 null
- 只查看不删除操作:
- element:队列空的时候抛异常
- peek:队列空的时候返回 null
一共 6 中方法,除了以上分类方法,也可以这样分:
- 遇到队列满或空的时候,抛异常:add、remove、element
- 遇到队列满或空的时候,返回 null 或 false:offer、poll、peek
BlockingQueue 在 Queue 的基础上加上了阻塞的概念,比如一直阻塞,还是阻塞一段时间。
操作 | 抛异常 | 返回特殊值 | 一直阻塞 | 阻塞一段时间 |
---|---|---|---|---|
新增操作–队列满 | add | offer 返回 false | put | offer 过超时时间返回 false |
查看并删除操作–队列空 | remove | poll 返回 null | take | poll 过超时时间返回 null |
只查看不删除操作–队列空 | element | peek 返回 null | 暂无 | 暂无 |
PS: remove 方法,BlockingQueue 类注释中定义的是抛异常,但 LinkedBlockingQueue 中 remove 方法实际是返回 false。
从表格中可以看到,在新增和查看并删除两大类操作上,BlockingQueue 增加了阻塞的功能,而且可以选择一直阻塞,或者阻塞一段时间后,返回特殊值。
类注释
我们看看从 LinkedBlockingQueue 的类注释中能得到哪些信息:
- 基于链表实现的阻塞队列,其底层的数据结构是链表
- 链表维护先入先出队列,新元素被放在队尾,获取元素从头部拿
- 链表大小在初始化的时候可以设置,默认是 Integer 的最大值
- 可以使用 Collection 和 Iterator 两个接口的所有操作,因为事先了两者的接口
源码
内部构成
LinkedBlockingQueue 内部构成简单来说,分成三个部分: 链表存储 + 锁 + 迭代器
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
// 单向链表
static class Node<E> {
E item;
Node<E> next;
Node(E x) { item = x; }
}
// 链表容量,默认是 Integer.MAX_VALUE
private final int capacity;
// 链表已有元素大小,使用AtomicInteger,所以是线程安全的
private final AtomicInteger count = new AtomicInteger();
// 链表头节点
transient Node<E> head;
// 链表尾节点
private transient Node<E> last;
// take时的锁
private final ReentrantLock takeLock = new ReentrantLock();
// take的条件队列,condition可以简单理解为基于AQS同步机制建立的条件队列
private final Condition notEmpty = takeLock.newCondition();
// put时的锁,设计两把锁的目的,主要为了take和put可以同时进行
private final ReentrantLock putLock = new ReentrantLock();
// put的条件队列
private final Condition notFull = putLock.newCondition();
// 迭代器
private class Itr implements Iterator<E> {}
}
从源码上来看,结构是非常清晰的:
- 链表的作用是为了保存当前节点,节点中的数据可以是任意类型,是一个泛型。比如说队列被应用到线程池时,节点就是线程,比如队列被应用到消息队列中,节点就是消息。
- 锁有 take 锁和 put 锁,是为了保证队列操作时的线程安全,设计两种锁,是为了 take 和 put 两种操作可以同时进行,互不影响。
初始化
初始化有三种方式:
- 指定链表容量大小
- 不指定链表容量大小,默认是 Integer 的最大值
- 对已有集合数据进行初始化
// 指定容量初始化
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
// 初始化,链表头节点和尾节点都为null
last = head = new Node<E>(null);
}
// 不指定容量,默认是Integer的最大值
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
// 对已有集合数据进行初始化
public LinkedBlockingQueue(Collection<? extends E> c) {
this(Integer.MAX_VALUE);
final ReentrantLock putLock = this.putLock;
putLock.lock(); // Never contended, but necessary for visibility
try {
int n = 0;
for (E e : c) {
// 集合内元素不能为空
if (e == null)
throw new NullPointerException();
// 如果容量等于Integer的最大值,抛异常
if (n == capacity)
throw new IllegalStateException("Queue full");
// 把元素入队
enqueue(new Node<E>(e));
++n;
}
count.set(n);
} finally {
putLock.unlock();
}
}
对于初始化源码,我们可以看到:
- 初始化时,容量大小是不会影响性能的,只影响在后面的使用,如果初始化队列太小,容易导致没有放多少元素就抛队列已满的异常
- 在对给定集合数据进行初始化时,源码给了一个不优雅的示范,我们不反对在每次 for 循环的时候,都去检查当前链表的大小是否超过容量,但我们希望在 for 循环开始之前就做一步这样的工作。举个列子,给定集合大小是 1 w,链表大小是 9k,按照现在代码实现,只能在 for 循环 9k 次时才能发现,原来给定集合的大小已经大于链表大小了,导致 9k 次循环都是在浪费资源,还不如在 for 循环之前就 check 一次,如果 1w > 9k,直接报错即可。
阻塞新增
新增有多个方法,add、put、offer。
我们拿 put 方法为例,put 方法在碰到队列满的时候,会一直阻塞下去,直到队列有位置时,并且自己被唤醒,才会继续执行,源码如下
// 把元素新增到队列的尾部
// 如果有可以新增的空间的话,直接新增成功,否则当前线程陷入等待
public void put(E e) throws InterruptedException {
// 队列元素不能为null
if (e == null) throw new NullPointerException();
// 设置c为-1,约定为负数为新增失败
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
// 加可中断锁
putLock.lockInterruptibly();
try {
// 队列满了
while (count.get() == capacity) {
// 当前线程阻塞,等待其他线程的唤醒
// 其他线程take成功之后会唤醒此处被阻塞的线程
// await无限等待
notFull.await();
}
// 队列没有满,直接新增到队列的尾部
enqueue(node);
// 新增计数赋值,getAndIncrement()返回的是旧值
// 这里的c比入队后的count小1
c = count.getAndIncrement();
// 如果链表现在的大小 小于链表的容量,说明队列未满
if (c + 1 < capacity)
// 尝试唤醒一个put的等待线程
notFull.signal();
} finally {
// 释放锁
putLock.unlock();
}
// c==0 代表队列里面有一个元素
if (c == 0)
// 尝试唤醒一个take的等待线程
signalNotEmpty();
}
// 入队
private void enqueue(Node<E> node) {
// assert putLock.isHeldByCurrentThread();
// assert last.next == null;
last = last.next = node;
}
从源码中我们可以总结以下几点:
- 往队列新增数据,第一步是上锁,所以新增数据是线程安全的
- 队列新增数据,简单的追加到链表的尾部即可
- 新增时,如果队列满了,当前线程是会被阻塞的,阻塞的底层是使用锁的能力,底层实现也和队列相关
- 新增数据成功后,在适当时机,会唤起 put 的等待线程(队列不满时),或者 take 的等待线程(队列不为空时),这样保证队列一旦满足 put 或者 take 条件时,立马就能唤醒阻塞线程,继续运行,保证了唤起的时机不被浪费
以上就是 put 方法的原理,至于 offer 阻塞超过一段时间后,仍未成功,就会直接返回默认值 false
阻塞删除
删除的方法也很多,我们主要看两个关键问题:
- 删除的原理是怎么样的
- 查看并删除和只查看不删除两种区别是如何实现的
以 take 为例,我们看下查看并删除的底层源码:
// 阻塞出队
public E take() throws InterruptedException {
E x;
// 负数为失败
int c = -1;
// count代表当前链表的真实长度
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
// 加上可中断锁
takeLock.lockInterruptibly();
try {
// 空队列时,阻塞,等待其他线程唤醒
while (count.get() == 0) {
notEmpty.await();
}
// 非空队列,直接从队列头部拿一个元素出来
x = dequeue();
// 数量减1,并拿到旧值
c = count.getAndDecrement();
// 队列有值,唤醒其他被阻塞的take线程
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 如果队列还剩一个空闲位置,尝试唤醒一个put的等待线程
if (c == capacity)
signalNotFull();
return x;
}
private E dequeue() {
// assert takeLock.isHeldByCurrentThread();
// assert head.item == null;
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
// 原头节点指向null,删除
first.item = null;
return x;
}
整体流程和 put 很相似,都是先上锁,然后从队列的头部拿出数据,如果队列为空,会一直阻塞到队列有值为止。
而查看不删除元素更加简单,直接把队列头的数据拿出来即可,我们以 peek 为例,源码如下:
public E peek() {
// count=实际队列大小
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();
}
}