目录
3.2.3 ArrayBlockingQueue与LinkedBlockingQueue对比
1 简介
我们先来解释一下阻塞队列:
如上图
- 生产线程1往阻塞队列里面添加新的数据,当阻塞队列满的时候(针对有界队列),生产线程1将会处于阻塞状态,直到消费线程2从队列中取走一个数据;
- 消费线程2从阻塞队列取数据,当阻塞队列空的时候,消费线程2将会处于阻塞状态,直到生产线程把一个数据放进去。
阻塞队列的基本原理就这样,至于队列是用什么数据结构进行存储的,这里并没有规定,所以后面我们可以看到很多阻塞队列的实现。
从上图我们可以很清楚看到,通过一个共享的队列,可以使得数据由队列的一端输入,从另外一端输出;
常用的队列主要有以下两种:(当然通过不同的实现方式,还可以延伸出很多不同类型的队列,DelayQueue就是其中的一种)
先进先出(FIFO):先插入的队列的元素也最先出队列,类似于排队的功能。从某种程度上来说这种队列也体现了一种公平性。
后进先出(LIFO):后插入队列的元素最先出队列,这种队列优先处理最近发生的事件。
BlockingQueue即阻塞队列,它是基于ReentrantLock,依据它的基本原理,我们可以实现Web中的长连接聊天功能,当然其最常用的还是用于实现生产者与消费者模式,大致如下图所示:
在Java中,BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue、 LinkedBlockingDeque、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等,它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于take与put操作的原理,却是类似的。
2 常用方法
在线程池中会经常使用BlockingQueue,BlockingQueue是一种阻塞队列,阻塞队列的特性:我在放的时候别人不能放,我在取的时候别人不能取,满的时候就不能再添加,等待有人取走,才能放
public interface BlockingQueue<E> extends Queue<E> {
// 添加成功返回true,否则抛出异常
boolean add(E e);
// 添加成功返回true,失败返回false
boolean offer(E e);
boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException;
// 添加时如果没有空间则调用此方法的线程来阻断知道队列有空间再继续
void put(E e) throws InterruptedException;
// 取走队列里的首位对象,如果不能立即取出,阻断进入等待状态直到队列有新的对象加入为止
E take() throws InterruptedException;
// 取走首位对象,若不能立即取出,可以等unit时间,取不到返回null
E poll(long timeout, TimeUnit unit) throws InterruptedException;
boolean remove(Object o);
// 队列剩余容量
int remainingCapacity();
// 是否包含对象
public boolean contains(Object o);
// 移除次队列中所有可用的元素,并将它们添加到指定的集合中
int drainTo(Collection<? super E> c);
// 指定最大移动元素的个数
int drainTo(Collection<? super E> c, int maxElements);
}
入队
offer(E e):如果队列没满,立即返回true; 如果队列满了,立即返回false-->不阻塞
put(E e):如果队列满了,一直阻塞,直到队列不满了或者线程被中断-->阻塞
offer(E e, long timeout, TimeUnit unit):在队尾插入一个元素,,如果队列已满,则进入等待,直到出现以下三种情况:-->阻塞
被唤醒
等待时间超时
当前线程被中断
出队
poll():如果没有元素,直接返回null;如果有元素,出队
take():如果队列空了,一直阻塞,直到队列不为空或者线程被中断-->阻塞
poll(long timeout, TimeUnit unit):如果队列不空,出队;如果队列已空且已经超时,返回null;如果队列已空且时间未超时,则进入等待,直到出现以下三种情况:
被唤醒
等待时间超时
当前线程被中断
3 BlockingQueue的实现
下图展示了主要的BlockingQueue的实现类:BlockingQueue底层也是基于AQS实现的,队列的阻塞使用ReentrantLock的Condition实现的。
3.1 ArrayBlockingQueue
ArrayBlockingQueue使用的数据结构是数组:
Object[capacity]
容量大小有构造函数的capacity参数决定。
3.1.1 put方法
public void put(E e) throws InterruptedException {
checkNotNull(e);
// 获取ReentrantLock锁
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 如果队列满了,则进入条件队列进行等待
while (count == items.length)
notFull.await();
// 队列不满,或者被取数线程唤醒了,那么会继续执行
// 这里会往阻塞队列添加一个数据,然后唤醒等待时间最长的取数线程
enqueue(e);
} finally {
// 释放ReentrantLock锁
lock.unlock();
}
}
- 只有获取到了ReentrantLock锁之后,才可以操作队列;
- 队列满了会阻塞进入条件队列等待;
- 队列不满则添加数据,并且唤醒等待时间最长的取数线程。
3.1.2 take方法
获取小顶堆最小的元素,获取之后会重新构造小顶堆。
public E take() throws InterruptedException {
// 获取ReentrantLock锁
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 如果队列空了,则进入条件队列进行等待
while (count == 0)
notEmpty.await();
// 队列不空,或者被存数线程唤醒了,那么会继续执行
// 这里会从阻塞队列取一个数据,然后唤醒等待时间最长的存数线程
return dequeue();
} finally {
// 释放ReentrantLock锁
lock.unlock();
}
}
- 只有获取到了ReentrantLock锁之后,才可以操作队列;
- 队列空了会阻塞进入条件队列等待;
- 队列不满则取数据,并且唤醒等待时间最长的存数线程。
注意:ArrayList中的数据取数和存数都是依次遍历一个一个取或者存,直到队尾之后,从头开始继续。代码如下:
private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
notEmpty.signal();
}
private E dequeue() {
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
notFull.signal();
return x;
}
如下图:
这里put和take使用了同一个ReentrantLock,不能并发执行。
有没有办法能够做到让put和take能够并发执行呢?接下来我们就来看看LinkedBlockingQueue。
3.2 LinkedBlockingQueue
LinkedBlockingQueue的put方法和take方法分别使用了不同的ReentrantLock,put和take可以并发执行,但是不能并发执行put或者take操作。
LinkedBlockingQueue底层使用的数据结构是单向链表:
transient Node head;
private transient Node last;
容量大小可以由构造函数的capacity设定,默认为:Integer.MAX_VALUE
3.2.1 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;
// 使用AtomicInteger保证原子性
final AtomicInteger count = this.count;
// 获取put锁
putLock.lockInterruptibly();
try {
// 如果队列满了,则进入put条件队列等待
while (count.get() == capacity) {
notFull.await();
}
// 队列不满,或者被取数线程唤醒了,那么会继续执行
// 这里会往阻塞队列末尾添加一个数据
enqueue(node);
c = count.getAndIncrement();
// 如果队列不满,则唤醒等待时间最长的put线程
if (c + 1 < capacity)
notFull.signal();
} finally {
// 释放put锁
putLock.unlock();
}
// 如果队列为空,再次获取put锁,然后唤醒等待时间最长的put线程
if (c == 0)
signalNotEmpty();
}
3.2.2 take方法
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
// 获取take锁
takeLock.lockInterruptibly();
try {
// 如果队列空了,则进入take条件队列等待
while (count.get() == 0) {
notEmpty.await();
}
// 获取到第一个节点,非哑节点
x = dequeue();
// 阻塞队列数量减1
c = count.getAndDecrement();
// 如果阻塞队列数量不为空,那么唤醒等待时间最长的take线程
if (c > 1)
notEmpty.signal();
} finally {
// 释放take锁
takeLock.unlock();
}
// 如果队列满了,再次获取take锁,然后唤醒等待时间最长的take线程
if (c == capacity)
signalNotFull();
return x;
}
take和put操作如下图所示:
- 队列第一个节点为哑节点,占位用的;
- put操作一直往链表后面追加节点;
- take操作从链表头取节点;
3.2.3 ArrayBlockingQueue与LinkedBlockingQueue对比
队列 | 是否阻塞 | 是否有界 | 线程安全 | 适用场景 |
---|---|---|---|---|
ArrayBlockingQueue | √ | √ | 一把ReentrantLock锁 | 生产消费模型,平衡处理速度 |
LinkedBlockingQueue | √ | 可配置 | 两把ReentrantLock锁 | 生产消费模型,平衡处理速度 |
ArrayBlockingQueue:
- 数据结构:数组,存储空间预先分配,无需动态申请空间,使用过程中内存开销较小;
LinkedBlockingQueue:
- 数据结构:单项链表,存储空间动态申请,会增加JVM垃圾回收负担;
- 两把锁,并发性能较好;
- 可设置为无界,吞吐量比较大,但是不稳定,入队速度太快有可能导致内存溢出。
3.3 LinkedBlockingDeque
与LinkedBlockingQueue类似,只不过底层的数据结构是双向链表,并且增加了可以从队列两端插入和移除元素的方法,支持FIFO和FILO。相关方法定义:
- putFirst(E e)
- putLast(E e)
- E getFirst()
- E getLast()
- E takeFirst()
- E takeLast()
- …
LinkedBlockingQueue与LinkedBlockingDeque对比
LinkedBlockingQueue:
- FIFO;
- 读写分开两个ReentrantLock;
LinkedBlockingDeque:
- FIFO & FILO;
- 全局一把ReentrantLock;
3.4 PriorityBlockingQueue
是一个无界队列
存储结构:
private transient Object[] queue;
内部会构造为一颗平衡的二叉小顶堆,根据构造函数中传入的Comparator进行排序或者没有传的情况下使用自然的排序方法,数组的第一个元素为最小的元素。
全局一把ReentrantLock锁。
3.4.1 put方法
无界队列,一定可以添加成功,无需阻塞。容量不够则扩容,put完会重新构建小顶堆。关键代码如下:
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
final ReentrantLock lock = this.lock;
// 尝试获取锁
lock.lock();
int n, cap;
Object[] array;
// 如果数组空间不够,尝试扩容:通常会扩大约50%
while ((n = size) >= (cap = (array = queue).length))
tryGrow(array, cap);
try {
// 往小顶堆插入元素
Comparator<? super E> cmp = comparator;
if (cmp == null)
siftUpComparable(n, e, array);
else
siftUpUsingComparator(n, e, array, cmp);
// 元素个数+1
size = n + 1;
// 唤醒等待最久的取数线程
notEmpty.signal();
} finally {
// 释放锁
lock.unlock();
}
return true;
}
3.4.2 take方法
队列为空的时候进入条件等待,take完元素之后,立刻重新构建小顶堆。
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
// 获取锁
lock.lockInterruptibly();
E result;
try {
// 尝试获取最小元素,即小顶堆第一个元素,然后重新排序,如果不存在表示队列暂无元素,进行阻塞等待。
while ( (result = dequeue()) == null)
notEmpty.await();
} finally {
// 释放锁
lock.unlock();
}
return result;
}
3.5 SynchronousQueue
通过使用SynchronousQueue,我们可以在线程之间安全的传递变量,A线程把需要传递的变量放入SynchronousQueue,B线程读取。该队列特点如下:
- 容量永远为0;
- put操作阻塞,直到另一个线程取走了队列中的元素;
- take操作阻塞,直到另一个线程put一个元素到队列中;
- 任何线程只能取得其他线程put进去的元素。
与其他阻塞队列不同的是,SynchronousQueue不依赖与AQS实现,而是直接使用CAS操作实现的,这导致代码中有大量的判断是否数据被并发改写了,并做相应的处理。
我们不推荐使用的无界线程池Executors.newCachedThreadPool()
底层就是用到了SynchronousQueue
来实现的。
SynchronousQueue具有公平模式和非公平模式的区别,两者的实现不太一样,接下来就介绍一下。
3.5.1 公平模式
公平模式下,底层的数据结构是一个单向链表,对应实现类为:TransferQueue。
底层数据结构与LinkedBlockingQueue类似,只不过阻塞的条件不同:
- LinkedBlockingQueue在队列满的时候put线程会阻塞,在队列空的时候,take线程会阻塞;
- SynchronousQueue put进去的元素没有被take的时候,put线程阻塞,take线程获取不到元素的时候,take线程阻塞;
如下图,刚开始有三个线程执行了put操作,都阻塞等待了:
然后有一个新的线程4执行了take操作,这里是FIFO队列,匹配上了线程1,于是线程1取了线程1节点的数据,然后同时唤醒了线程1,头节点向前推进:
这种FIFO的模式真是公平的体现。
大致执行流程就是这样子,比使用了AQS的简单,取而代之的是使用CAS,通过大量的检验节点是否变更和处理,以达到更高put和take的性能,不过代码就自然会变得很复杂了,感兴趣的朋友可以前往查看源码,这里就不做详细的解读了。
3.5.2 非公平模式
非公平模式,底层的数据结构是一个栈。代码也是比较复杂的,这里我直接用图来描述下其执行原理。
如下图,线程1、线程2、线程3依次执行put操作,入栈情况如下图,结果三个线程都阻塞了:
这个时候线程4执行take操作,会入栈,与栈顶的栈帧进行匹配:
匹配成功之后,唤醒匹配上的线程3,然后从栈中移除线程3和线程4
可以发现非公平模式下是LIFO的队列。
3.6 DelayQueue
延迟队列,提供给了在指定时间内才能获取队列元素的功能。
底层是通过PriorityQueue实现的:
- put元素,触发Delayed接口的compareTo方法重新排序PriorityQueue小顶堆,让最小的元素(最快到期)排在最前面;一定会put成功,容量不够则扩容;
- take元素,判断第一个元素是否到期,到期了则把原始poll出来(同时会重新构造小顶堆),否则会执行awaitNanos(delay),等待头节点元素到期之后,再重新获取元素。
3.7 各种阻塞锁对比
阻塞锁 | 数据结构 | 是否有界 | 线程安全 | 适用场景 |
---|---|---|---|---|
ArrayBlockingQueue | 数组 | 有界 | 一把ReentrantLock锁控制put和take。 | 生产消费模型,平衡处理速度 |
LinkedBlockingQueue | 单向链表 | 可配置 | 两把ReentrantLock锁,put和take可以并发执行。 | 生产消费模型,平衡处理速度 |
LinkedBlockingDeque | 双向链表 | 可配置 | 一把ReentrantLock锁控制put和take。 | 生产消费模型,平衡处理速度 |
优先级队列 PriorityBlockingQueue | 二叉小顶堆 | 无界,会自动扩容 | 一把ReentrantLock锁控制put和take,队列为空的时候take进入条件等待; | 短信队列中的验证码短信优先发送 |
同步队列 SynchronousQueue | 单向链表或者栈 | 容量为1 | CAS,put和take都会阻塞,直到配对成功为止; | 线程之间传递数据 |
延迟队列 DelayQueue | PriorityQueue,二叉小顶堆 | 无界,会自动扩容 | 一把ReentrantLock锁控制put和take,一次只能一个线程take,其他线程进入条件等待; | 关闭超时空连接,任务超时处理… |
4 使用实例
案例一:生产者消费者
下面是一个使用案例,使用到了阻塞队列和CountDownLatch。这个程序模拟:
- 一个生产者线程,往阻塞队列中依次存入20条消息;
- 一个消费者线程,一直尝试从阻塞线程中取出消息进行消费。
} catch (InterruptedException ex) {
案例二:优先级阻塞队列
下面是一个PriorityBlockingQueue的使用例子,可以发现每次put一个元素之后会自动排序,小顶堆第一个元素总是最小的那个,每次take出来的元素也是最小的,take出来之后也会再次自动排序:
public class PriorityBlockingQueueTest {
public static void main(String[] args) throws InterruptedException {
PriorityBlockingQueue<UserInfo> queue = new PriorityBlockingQueue<>();
queue.put(new UserInfo(10, "User1"));
System.out.println("priorityBlockingQueue: " + queue);
queue.put(new UserInfo(5, "User2"));
System.out.println("priorityBlockingQueue: " + queue);
queue.put(new UserInfo(2,"User3"));
System.out.println("priorityBlockingQueue: " + queue);
queue.put(new UserInfo(4,"User4"));
System.out.println("priorityBlockingQueue: " + queue);
System.out.println("take data: " + queue.take());
System.out.println("priorityBlockingQueue: " + queue);
System.out.println("take data: " + queue.take());
System.out.println("priorityBlockingQueue: " + queue);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
class UserInfo implements Comparable<UserInfo>{
private int id;
private String name;
@Override
public String toString() {
return this.id + "";
}
@Override
public int compareTo(UserInfo person) {
return this.id > person.getId() ? 1 : ( this.id < person.getId() ? -1 :0);
}
}
输出结果:
priorityBlockingQueue: [10]
priorityBlockingQueue: [5, 10]
priorityBlockingQueue: [2, 10, 5]
priorityBlockingQueue: [2, 4, 5, 10]
take data: 2
priorityBlockingQueue: [4, 10, 5]
take data: 4
priorityBlockingQueue: [5, 10]
案例三:SynchronousQueue的使用
下面使用SynchronousQueue的使用案例。大家可以替换成公平模式或者非公平模式,来查看程序输出结果,看看是FIFO还是LIFO:
ExecutorService executor = Executors.newFixedThreadPool(10);
// 构造函数参数设置公平模式或者非公平模式
SynchronousQueue<Integer> queue = new SynchronousQueue<>(false);
Runnable producer = () -> {
Integer producedElement = ThreadLocalRandom
.current()
.nextInt();
try {
System.out.println(Thread.currentThread().getName() + " put " + producedElement);
queue.put(producedElement);
System.out.println(Thread.currentThread().getName() + " put finished");
} catch (InterruptedException ex) {
ex.printStackTrace();
}
};
Runnable consumer = () -> {
try {
TimeUnit.SECONDS.sleep(1);
Integer consumedElement = queue.take();
System.out.println(Thread.currentThread().getName() + " take " + consumedElement);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
};
executor.execute(producer);
executor.execute(producer);
executor.execute(producer);
executor.execute(consumer);
executor.shutdown();
System.out.println(queue.size());
参考