前言
关于阻塞队列的使用,其实之前的文章已经提到过:三种方式实现生产者-消费者模型,最后一种方式就是用阻塞队列实现的。仔细观察会发现,前两种也是在用wait/notify
和ReentrantLock/Condition
模拟阻塞队列。
本篇主要从源码、核心方法、设计思想等方面全面解析LinkedBlockingQueue
有本篇涉及到单链表的操作以及ReentrantLock
和Condition
的使用,所以提前了解一下可以做到事半功倍的效果。
- 图解数据结构:数组和单链表:这篇文章较为深入的讲解了数组和单链表相关的基本操作
- ReentrantLock功能详解:这篇文章讲解了ReentrantLock和Condition的使用
阻塞队列
LinkedBlockingQueue
是阻塞队列中的一种,见名知意,由链表实现的阻塞队列。类的继承结构图如下:
可以看到LinkedBlockingQueue
实现了BlockingQueue
,相当于实现了Queue
接口。因为阻塞队列也满足FIFO(先入先出)特性,能操作的只有队列的头部和尾部,所以相对来说,LinkedBlockingQueue
接口对外提供的方法比较少,主要是入队和出队操作。
内部结构
要想知道LinkedBlockingQueue
的内部结构,得先了解类的定义和成员变量/常量。
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
private static final long serialVersionUID = -6903933977591709194L;
/**
* Linked list node class
* 链表内部节点定义
* 根据节点的定义可以看出LinkedBlockingQueue由单链表实现的
*/
static class Node<E> {
// 数据域
E item;
// 指针域
Node<E> next;
Node(E x) { item = x; }
}
/** The capacity bound, or Integer.MAX_VALUE if none */
// 队列容量,不指定的话就是Integer.MAX_VALUE,也就是无界队列
private final int capacity;
/** Current number of elements */
// 队列中元素的个数
private final AtomicInteger count = new AtomicInteger();
/**
* Head of linked list.
* Invariant: head.item == null
* 队列(链表)头节点
*/
transient Node<E> head;
/**
* Tail of linked list.
* Invariant: last.next == null
* 队列(链表)尾节点
*/
private transient Node<E> last;
/** Lock held by take, poll, etc */
// 从队列中取元素的时候的锁
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
// 线程可以取元素
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
// 往队列中放入元素时候的锁
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
// 线程可以放元素
private final Condition notFull = putLock.newCondition();
}
从类的定义及成员变量来看,基本上可以猜测LinkedBlockingQueue
底层数据结构是单链表,存/取元素通过ReentrantLock + Condition
来保证线程安全以及实现线程阻塞。
构造方法
LinkedBlockingQueue
构造方法有三个:
public LinkedBlockingQueue() {
// Integer.MAX_VALUE
this(Integer.MAX_VALUE);
}
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
// 初始化时,头指针和尾指针都指向同一个空节点
last = head = new Node<E>(null);
}
public LinkedBlockingQueue(Collection<? extends E> c) {
this(Integer.MAX_VALUE);
final ReentrantLock putLock = this.putLock;
// 加锁(put锁)
putLock.lock(); // Never contended, but necessary for visibility
try {
int n = 0;
// 遍历入队
for (E e : c) {
if (e == null)
throw new NullPointerException();
if (n == capacity)
throw new IllegalStateException("Queue full");
enqueue(new Node<E>(e));
++n;
}
// 初始化元素个数
count.set(n);
} finally {
// 解锁
putLock.unlock();
}
}
构造方法比较简单,只需要关注一点:如果不指定队列容量,默认就是Integer.MAX_VALUE
,无界队列。
核心方法
队列的核心操作只有三个:入队、出队、查看队首元素。LinkedBlockingQueue
的入队、出队操作对应实现了三组API。分别在队列满和队列空时有不同的表现。
三组API对应的表现如下:
(队列满或者空)抛异常 | (队列满或者空)返回特殊值 | (队列满或者空)阻塞 | |
---|---|---|---|
入队 | add 抛出异常 |
offer 返回false |
put |
出队 | remove 抛出异常 |
poll 返回null |
take |
查看队首元素 | element 抛出异常 |
peek 返回null |
– |
add/offer/put
三个方法都不支持传入null,会抛出空指针异常。
对于三个抛出异常的方法add/remove/element
都在父类AbstractQueue
中实现,分别是offer/poll/peek
方法的语法糖。
offer(E e)方法
offer(E e)
方法用于入队操作,其源码如下
public boolean offer(E e) {
// 不能放入null
if (e == null) throw new NullPointerException();
// 队列中元素个数
final AtomicInteger count = this.count;
if (count.get() == capacity)
// 元素个数等于队列容量,队列已满,返回特殊值false
return false;
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// 加锁(put锁)
putLock.lock();
try {
if (count.get() < capacity) {
// 双重校验,队列此时未满,入队
enqueue(node);
// count++
c = count.getAndIncrement();
if (c + 1 < capacity)
// 入队一个元素后,队列依然未满,唤醒因为调用put方法而被阻塞的线程
notFull.signal();
}
} finally {
// 解锁
putLock.unlock();
}
if (c == 0)
// 队列中有一个元素,唤醒因为调用take方法而被阻塞的线程
signalNotEmpty();
return c >= 0;
}
了解了整体执行逻辑,再具体看看入队方法enqueue
,其源码如下:
private void enqueue(Node<E> node) {
// assert putLock.isHeldByCurrentThread();
// assert last.next == null;
// 节点插在链表尾部,并把尾指针指向新插入的节点
last = last.next = node;
}
因为有尾指针的存在,所以在单链表的尾部插入元素的时间复杂度是O(1),非常高效。
LinkedBlockingQueue
还提供了一个重载的offer
方法offer(E e, long timeout, TimeUnit unit)
,执行逻辑差不多,只是加了超时时间。
poll()方法
poll()
方法用于出队操作,从队列头部取元素。其源码如下:
public E poll() {
// 获取队列元素个数
final AtomicInteger count = this.count;
if (count.get() == 0)
// 队列为空(没有元素),返回特殊值null
return null;
// 要返回的结果
E x = null;
int c = -1;
final ReentrantLock takeLock = this.takeLock;
// 加锁(take锁,和put不是同一把锁)
takeLock.lock();
try {
if (count.get() > 0) {
// 双重校验,队列中有元素,执行出队操作
x = dequeue();
// count--
c = count.getAndDecrement();
if (c > 1)
// 队列中还有元素,唤醒因为调用take方法而被阻塞的线程
notEmpty.signal();
}
} finally {
// 解锁
takeLock.unlock();
}
if (c == capacity)
// 队列还能入队一个元素,唤醒因为调用put方法而被阻塞的线程
signalNotFull();
// 返回出队的元素
return x;
}
了解整体逻辑之后再来具体看下出队方法dequeue
,其源代码定义如下:
private E dequeue() {
// assert takeLock.isHeldByCurrentThread();
// assert head.item == null;
// head的数据域为null,相当于虚拟头节点,next指向的才是队列真正的第一个元素
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
first.item = null;
return x;
}
出队操作是从单链表的头部删除一个节点,时间复杂度也是O(1),可以看出入队和出队操作都是非常高效的。
LinkedBlockingQueue
也提供了一个重载的poll
方法E poll(long timeout, TimeUnit unit)
。
offer/poll
只是现场安全的实现了队列的基本操作,光有这组操作的队列不能算是阻塞队列,所以再来看看入队、出队的阻塞式实现
put(E e)方法
put(E e)
方法和offer(E e)
方法一样,都是往队列尾部插入元素(入队)。不同的是,当队列满了的时候,put
方法会阻塞当前线程,直到有线程从队列中取出元素,队列还有剩余空间的时候才会继续进行入队操作;而offer
方法直接返回特殊值false。put
方法的源代码如下:
public void put(E e) throws InterruptedException {
// 不能放入空元素
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// 队列中元素个数
final AtomicInteger count = this.count;
// 加锁(put锁),lockInterruptibly是可中断锁
putLock.lockInterruptibly();
try {
while (count.get() == capacity) {
// 队列已满,阻塞当前线程
notFull.await();
}
// 队列未满,入队
enqueue(node);
// count++
c = count.getAndIncrement();
if (c + 1 < capacity)
// 入队一个元素后,队列依然未满,唤醒因调用put方法而阻塞的线程
notFull.signal();
} finally {
// 解锁
putLock.unlock();
}
if (c == 0)
// 队列中还有一个元素,唤醒因调用take方法而被阻塞的线程
signalNotEmpty();
}
可以看到整体逻辑是用ReentrantLock
锁 + Condition
实现:队列满时,阻塞当前线程,直到队列非满。
E take()方法
E take()
方法和poll()
方法一样,都是删除并返回队列头部元素(出队)。不同的是,当队列为空的时候,take
方法会阻塞当前线程,直到有线程往队列中放入元素,才会继续进行出队操作;而poll
方法直接返回特殊值null。take
方法的源代码如下:
public E take() throws InterruptedException {
// 要返回的元素
E x;
int c = -1;
// 队列中元素的个数
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
// 加锁(take锁,不同于put锁),lockInterruptibly是可中断锁
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
// 队列为空,阻塞当前线程
notEmpty.await();
}
// 队列非空,直接出队
x = dequeue();
// count--
c = count.getAndDecrement();
if (c > 1)
// 队列中还有元素,唤醒因为调用take方法而被阻塞的线程
notEmpty.signal();
} finally {
// 解锁
takeLock.unlock();
}
if (c == capacity)
// 队列中还能入队一个元素,唤醒因为调用put方法而被阻塞的线程
signalNotFull();
// 返回出队的元素
return x;
}
可以看到整体逻辑是用ReentrantLock
锁 + Condition
实现:队列为空时,阻塞当前线程,直到队列非空。
peek()方法
除了入队和出队,队列还有一个基本操作就是查看队首元素(和出队操作的区别是:是否删除队首元素),方法名是peek
,源代码如下:
public E peek() {
if (count.get() == 0)
// 队列为空,返回特殊值null
return null;
final ReentrantLock takeLock = this.takeLock;
// 加锁
takeLock.lock();
try {
// 取队列头部元素
// head相当于虚拟头节点,head.next才是队列真正的第一个元素
Node<E> first = head.next;
if (first == null)
return null;
else
return first.item;
} finally {
// 解锁
takeLock.unlock();
}
}
基本是只是一个读操作,但是也加锁了。是为了防止在读的过程中,有线程执行了出队操作。
总结
总的来说LinkedBlockingQueue
可以总结出以下特点:
- 底层用带有头指针和尾指针的单链表实现,入队/出队都非常高效
- 用
ReentrantLock
锁实现入队、出队、查看队首元素等操作的线程安全 - 用两把
ReentrantLock
操作入队、出队线程,使出队和入队可以同时进行,并发度更高 - 用
Condition
类实现有条件的分组唤醒 - 入队操作是把元素插在链表尾部,出队操作是把链表头部元素删除并返回
LinkedBlockingQueue
使用非常广泛,线程池中的缓冲队列,生产者/消费者模型的实现都离不开它。