队列在日常工作中使用的没有集合多,但是同样特别重要,我们平时使用到的线程池、读写锁、消息队列等等技术和框架,底层原理都是队列J,队列是很多高级 API 的基础,学好队列,对自己深入 Java 学习非常重要。AVA中常用的队列有LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、DelayQueue,下面简单介绍下这四种队列各种的特别以及应用场景。本文只做队列的入门级学习,不具体研究源码。
Queue接口
Queue接口是队列最基础的接口,基本所有的队列实现类都会实现这个接口,该接口定义了队列的三大类操作:
- 新增操作
- add方法,当队列满了的时候抛出异常;
- offer方法,当队列满了的时候返回false;
- 查看并删除操作
- remove方法,当队列为空的时候抛出异常;
- poll方法,当队列为空的时候返回false;
- 只查看不删除操作
- element方法,当队列为空的时候抛出异常;
- peek方法,当队列为空的时候返回false;
BlockingQueue接口
BlockingQueue在Queue的基础上增加了阻塞的概念,可以一直阻塞或阻塞一段时间,Queue的常用方法如下:
抛异常 | 特殊值 | 一直阻塞 | 阻塞一段时间 | |
---|---|---|---|---|
新增操作–队列满 | add | offer 返回 false | put | offer 过超时时间返回 false |
查看并删除操作–队列空 | remove | poll 返回 null | take | poll 过超时时间返回 null |
只查看不删除操作–队列空 | element | peek 返回 null | 暂无 | 暂无 |
PS: remove 方法,BlockingQueue 类注释中定义的是抛异常,但 LinkedBlockingQueue 中 remove 方法实际是返回 false。
LinkedBlockingQueue
LinkedBlockingQueue中文叫做链表阻塞队列,从命名上就可以知道其底层数据结构是链表,并且是可阻塞的队列,架构图如下(IDEA可以使用Ctrl+Alt+U查看类的架构图):
从架构图中可以得知,LinkedBlockingQueue继承了AbstractCollection,因此拥有集合的一些功能,实现了Iterable,所以可以使用迭代器遍历,并且实现了BlockingQueue接口。
从LinkedBlockingQueue的类注释上可以得知:
- 是基于链表的阻塞队列,其底层数据结构是链表;
- 链表维护先入先出队列,新元素被放到队尾,从队头获取元素;
- 链表的大小在初始化的时候可以设置,并且大小一旦指定就不能被修改,默认为Integer的最大值;
- 实现了Collection和Iterator 接口,可以使用 两个接口的所有操作;
源码解析
LinkedBlockingQueue 内部构成简单来说,分成三个部分:链表存储 + 锁 + 迭代器,源码如下:
// 链表结构 begin
//链表的元素
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;
// 链表结构 end
// 锁 begin
//take 时的锁
private final ReentrantLock takeLock = new ReentrantLock();
// take 的条件队列,condition 可以简单理解为基于 ASQ 同步机制建立的条件队列
private final Condition notEmpty = takeLock.newCondition();
// put 时的锁,设计两把锁的目的,主要为了 take 和 put 可以同时进行
private final ReentrantLock putLock = new ReentrantLock();
// put 的条件队列
private final Condition notFull = putLock.newCondition();
// 锁 end
// 迭代器
// 实现了自己的迭代器
private class Itr implements Iterator<E> {
………………
}
从源码可以看出,结构是非常清晰的,三种结构各司其职:
- 链表的作用是为了保存当前节点,节点中的数据可以是任意东西,是一个泛型,比如说队列被应用到线程池时,节点就是线程,比如队列被应用到消息队列中,节点就是消息,节点的含义主要看队列被使用的场景;
- 锁有 take 锁和 put 锁,是为了保证队列操作时的线程安全,设计两种锁,是为了 take 和 put 两种操作可以同时进行,互不影响;
初始化
初始化有三种方式:
- 指定容量初始化;
- 不指定容量初始化,容量大小为Integer的最大值;
- 指定集合初始化,容量大小为集合的大小;
源码如下:
// 不指定容量,默认 Integer 的最大值
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
// 指定链表容量大小,链表头尾相等,节点值(item)都是 null
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;
putLock.lock(); // Never contended, but necessary for visibility
try {
int n = 0;
for (E e : c) {
// 集合内的元素不能为空
if (e == null)
throw new NullPointerException();
// capacity 代表链表的大小,在这里是 Integer 的最大值
// 如果集合类的大小大于 Integer 的最大值,就会报错
// 其实这个判断完全可以放在 for 循环外面,这样可以减少 Integer 的最大值次循环(最坏情况)
if (n == capacity)
throw new IllegalStateException("Queue full");
enqueue(new Node<E>(e));
++n;
}
count.set(n);
} finally {
putLock.unlock();
}
}
从源码可以得知:
- 初始化时会创建一个值为null的节点,队头head和队尾last都指向这个节点;
- 初始化时,容量大小是不会影响性能的,只影响在后面的使用,因为初始化队列太小,容易导致没有放多少就会报队列已满的错误;
阻塞新增
LinkedBlockingQueue新增元素的方法有add、offer、put,三者的区别上文有说,主要看下put方法的逻辑,源码如下:
// 把e新增到队列的尾部。
// 如果有可以新增的空间的话,直接新增成功,否则当前线程陷入等待
public void put(E e) throws InterruptedException {
// e 为空,抛出异常
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 {
// 队列满了
// 当前线程阻塞,等待其他线程的唤醒(其他线程 take 成功后就会唤醒此处被阻塞的线程)
while (count.get() == capacity) {
// await 无限等待
notFull.await();
}
// 队列没有满,直接新增到队列的尾部
enqueue(node);
// 新增计数赋值,注意这里 getAndIncrement 返回的是旧值
// 这里的 c 是比真实的 count 小 1 的
c = count.getAndIncrement();
// 如果链表现在的大小 小于链表的容量,说明队列未满
// 可以尝试唤醒一个 put 的等待线程
if (c + 1 < capacity)
notFull.signal();
} finally {
// 释放锁
putLock.unlock();
}
// c==0,代表队列里面有一个元素
// 会尝试唤醒一个take的等待线程
if (c == 0)
signalNotEmpty();
}
// 入队,把新元素放到队尾
private void enqueue(Node<E> node) {
last = last.next = node;
}
从源码中可以总结以下几点:
- put的数据不能是null,否则会抛出异常;
- 队列新增数据,第一步是上锁,所以新增数据是线程安全的;
- 新增时,如果队列满了,当前线程是会被阻塞,阻塞的底层实现使用的是锁,并使用Condition来实现锁的同步和通信;
- 新增的数据放入到队尾;
- 新增数据成功后,在适当时机,会唤起 put 的等待线程(队列不满时),或者 take 的等待线程(队列不为空时),这样保证队列一旦满足 put 或者 take 条件时,立马就能唤起阻塞线程,继续运行,保证了唤起的时机不被浪费;
阻塞删除
LinkedBlockingQueue删除元素的方法有remove、poll、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();
// 减一计算,注意 getAndDecrement 返回的值是旧值
// c 比真实的 count 大1
c = count.getAndDecrement();
// 如果队列里面有值,从 take 的等待线程里面唤醒一个。
// 意思是队列里面有值啦,唤醒之前被阻塞的线程
if (c > 1)
notEmpty.signal();
} finally {
// 释放锁
takeLock.unlock();
}
// 如果队列空闲还剩下一个,尝试从 put 的等待线程中唤醒一个
if (c == capacity)
signalNotFull();
return x;
}
// 队头中取数据
private E dequeue() {
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
first.item = null;// 头节点指向 null,删除
return x;
}
整体流程和 put 很相似,都是先上锁,然后从队列的头部拿出数据,如果队列为空,会一直阻塞到队列有值为止。dequeue方法的逻辑稍微有点绕,可以在纸上画一下head和last的指向。
查看不删除元素
LinkedBlockingQueue查看不删除元素的方法有element、peek,以peek方法为例,源码如下:
// 查看并不删除元素,如果队列为空,返回 null
public E peek() {
// count 代表队列实际大小,队列为空,直接返回 null
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();
}
}
可以看到,peek方法中没有阻塞,并且不会改变队头head和队尾last的指向。
ArrayBlockingQueue
ArrayBlockingQueue同LinkedBlockingQueue一样,也是阻塞队列,因此也具有阻塞的功能,下面具体来了解下ArrayBlockingQueue的底层架构以及特性。
ArrayBlockingQueue中文叫做数组阻塞队列,从命名上就可以知道其底层数据结构是数组,并且是可阻塞的队列,架构图如下:
ArrayBlockingQueue的架构跟LinkedBlockingQueue是一样的,都继承了AbstractQueue,实现了BlockingQueue和Collection接口,功能跟LinkedBlockingQueue差不多。
源码解析
部分源码如下:
// 队列存放在 object 的数组里面
// 数组大小必须在初始化的时候手动设置,没有默认大小
final Object[] items;
// 下次拿数据的时候的索引位置
int takeIndex;
// 下次放数据的索引位置
int putIndex;
// 当前已有元素的大小
int count;
// 可重入的锁
final ReentrantLock lock;
// take的队列
private final Condition notEmpty;
// put的队列
private final Condition notFull;
ArrayBlockingQueue有以下特性:
- 元素是有顺序的,按照先入先出进行排序,从队尾插入数据数据,从队头拿数据;
- 队列满时,往队列中 put 数据会被阻塞,队列空时,往队列中拿数据也会被阻塞;
- 由于put和take共用一个锁;
- takeIndex 和 putIndex,分别表示下次拿数据和放数据的索引位置,所以在新增数据和拿数据时,都无需计算,就能知道应该新增到什么位置,应该从什么位置拿数据;
初始化
初始化时,有两个重要的参数,数组的大小、是否是公平,源码如下:
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
// 使用公平锁还是非公平锁
lock = new ReentrantLock(fair);
// 队列不为空 Condition,在 put 成功时使用
notEmpty = lock.newCondition();
// 队列不满 Condition,在 take 成功时使用
notFull = lock.newCondition();
}
public ArrayBlockingQueue(int capacity, boolean fair,
Collection<? extends E> c) {
this(capacity, fair);
final ReentrantLock lock = this.lock;
lock.lock(); // Lock only for visibility, not mutual exclusion
try {
int i = 0;
try {
for (E e : c) {
checkNotNull(e);
items[i++] = e;
}
} catch (ArrayIndexOutOfBoundsException ex) {
throw new IllegalArgumentException();
}
count = i;
putIndex = (i == capacity) ? 0 : i;
} finally {
lock.unlock();
}
}
从源码中可以看出:
- 第二个参数是否公平,主要用于读写锁是否公平,如果是公平锁,那么在锁竞争时,就会按照先来先到的顺序,如果是非公平锁,锁竞争时随机的。对于锁公平和非公平,我们举个例子:比如说现在队列是满的,还有很多线程执行put操作,必然会有很多线程阻塞等待,当有其它线程执行take时,会唤醒等待的线程,如果是公平锁,会按照阻塞等待的先后顺序,依次唤醒阻塞的线程,如果是非公平锁,会随机唤醒沉睡的线程。所以说队列满很多线程执行put操作时,如果是公平锁,数组元素新增的顺序就是阻塞线程被释放的先后顺序,是有顺序的,而非公平锁,由于阻塞线程被释放的顺序是随机的,所以元素插入到数组的顺序也就不会按照插入的顺序了。ArrayBlockingQueue 通过锁的公平和非公平,轻松实现了数组元素的插入顺序的问题 ;
- 默认是使用的非公平锁,因为非公平锁的性能更高;
- 初始化时,如果指定了容量和初始化集合,集合中元素不能为null,并且当集合的大小大于指定容量时会抛出异常,比如capacity等于10,而集合中的元素数量为15,那么只会将集合中的前十个元素放入到队列中,并且会抛出异常;
新增数据
数据新增都会按照 putIndex 的位置进行新增,源码如下:
// 新增,如果队列满,无限阻塞
public void put(E e) throws InterruptedException {
// 元素不能为空
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 队列如果是满的,就无限等待
// 一直等待队列中有数据被拿走时,自己被唤醒
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
private void enqueue(E x) {
// assert lock.getHoldCount() == 1; 同一时刻只能一个线程进行操作此方法
// assert items[putIndex] == null;
final Object[] items = this.items;
// putIndex 为本次插入的位置
items[putIndex] = x;
// ++ putIndex 计算下次插入的位置
// 如果下次插入的位置,正好等于队尾,下次插入就从 0 开始
if (++putIndex == items.length)
putIndex = 0;
count++;
// 唤醒因为队列空导致的等待线程
notEmpty.signal();
}
从源码中,我们可以看出,其实新增就两种情况:
- 次新增的位置居中,直接新增,下图演示的是 putIndex 在数组下标为 5 的位置,还不到队尾,那么可以直接新增,计算下次新增的位置应该是 6;
- 新增的位置到队尾了,那么下次新增时就要从头开始了,示意图如下:
上面这张图演示的就是这行代码:if (++putIndex == items.length) putIndex = 0;
可以看到当新增到队尾时,下次新增会重新从队头重新开始。
拿数据
拿数据都是从队头开始拿数据,源码如下:
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 如果队列为空,无限等待
// 直到队列中有数据被 put 后,自己被唤醒
while (count == 0)
notEmpty.await();
// 从队列中拿数据
return dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
final Object[] items = this.items;
// takeIndex 代表本次拿数据的位置,是上一次拿数据时计算好的
E x = (E) items[takeIndex];
// 帮助 gc
items[takeIndex] = null;
// ++ takeIndex 计算下次拿数据的位置
// 如果正好等于队尾的话,下次就从 0 开始拿数据
if (++takeIndex == items.length)
takeIndex = 0;
// 队列实际大小减 1
count--;
if (itrs != null)
itrs.elementDequeued();
// 唤醒被队列满所阻塞的线程
notFull.signal();
return x;
}
从源码中可以看出,每次拿数据的位置就是 takeIndex 的位置,在找到本次该拿的数据之后,会把 takeIndex 加 1,计算下次拿数据时的索引位置,有个特殊情况是,如果本次拿数据的位置已经是队尾了,那么下次拿数据的位置就要从头开始,就是从 0 开始了。
删除数据
删除数据的源码如下:
// 一共有两种情况:
// 1:删除位置和 takeIndex 的关系:删除位置和 takeIndex 一样,比如 takeIndex 是 2, 而要删除的位置正好也是 2,那么就把位置 2 的数据置为 null ,并重新计算 takeIndex 为 3。
// 2:找到要删除元素的下一个,计算删除元素和 putIndex 的关系
// 如果下一个元素不是 putIndex,就把下一个元素往前移动一位
// 如果下一个元素是 putIndex,把 putIndex 的值修改成删除的位置
void removeAt(final int removeIndex) {
final Object[] items = this.items;
// 情况1 如果删除位置正好等于下次要拿数据的位置
if (removeIndex == takeIndex) {
// 下次要拿数据的位置直接置空
items[takeIndex] = null;
// 要拿数据的位置往后移动一位
if (++takeIndex == items.length)
takeIndex = 0;
// 当前数组的大小减一
count--;
if (itrs != null)
itrs.elementDequeued();
// 情况 2
} else {
final int putIndex = this.putIndex;
for (int i = removeIndex;;) {
// 找到要删除元素的下一个
int next = i + 1;
if (next == items.length)
next = 0;
// 下一个元素不是 putIndex
if (next != putIndex) {
// 下一个元素往前移动一位
items[i] = items[next];
i = next;
// 下一个元素是 putIndex
} else {
// 删除元素
items[i] = null;
// 下次放元素时,应该从本次删除的元素放
this.putIndex = i;
break;
}
}
count--;
if (itrs != null)
itrs.removedAt(removeIndex);
}
notFull.signal();
}
删除数据的情况比较复杂,从源码可以得知,删除一共分为两种情况:
- 第一种情况是 takeIndex == removeIndex,此时会将位置 takeIndex 的数据置为 null ,并重新计算 takeIndex 为 takeIndex + 1;
- 第二中情况又分两种:
- 如果 removeIndex + 1 != putIndex 的话,就依次把removeIndex到putIndex之间的元素往前移动一位,然后把putIndex前一位的值设置为null,putIndex为putIndex-1,注意原来putIndex位置的值就为null,所以不需要再设置;
- 如果 removeIndex + 1 == putIndex 的话,就把 putIndex 的值修改成removeIndex,并且把removeIndex 处的值设置成null;
LinkedBlockingQueue与ArrayBlockingQueue的对比
- LinkedBlockingQueue底层数据结构是链表,而ArrayBlockingQueue底层数据结构是数组;
- ArrayBlockingQueue可以指定使用公平锁还是非公平锁,而LinkedBlockingQueue没提供对应的构造方法;
- LinkedBlockingQueue通过头尾节点的指向来新增或获取队列中的元素,而ArrayBlockingQueue是通过数组的下标来实现的;
- 性能方法,暂无比较数据;
SynchronousQueue
SynchronousQueue 是比较独特的队列,其本身是没有容量大小,比如我放一个数据到队列中,我是不能够立马返回的,我必须等待别人把我放进去的数据消费掉了,才能够返回。SynchronousQueue 在消息队列技术中间件中被大量使用。
SynchronousQueue的特性如下:
- 队列不存储数据,所以没有大小,无法迭代;
- 插入操作的返回必须等待另一个线程完成对应的删除操作,反之亦然;
- 队列由两种数据结构组成,分别是后入先出的堆栈和先入先出的队列,堆栈是非公平的,队列是公平的;
SynchronousQueue的架构图如下:
从架构图可以得知,SynchronousQueue也实现了BlockingQueue接口,所以也具有阻塞功能,虽然SynchronousQueue实现了Collection、Iterable接口,但因为其不储存数据结构,有一些方法是没有实现的,比如说 isEmpty、size、contains、remove 和迭代等方法,这些方法都是默认实现,如下图所示:
源码解析
SynchronousQueue部分源码如下:
// 堆栈和队列共同的接口
// 负责执行 put or take
abstract static class Transferer<E> {
// e 为空的,会直接返回特殊值,不为空会传递给消费者
// timed 为 true,说明会有超时时间
abstract E transfer(E e, boolean timed, long nanos);
}
// 堆栈 后入先出 非公平
// Scherer-Scott 算法
static final class TransferStack<E> extends Transferer<E> {
}
// 队列 先入先出 公平
static final class TransferQueue<E> extends Transferer<E> {
}
private transient volatile Transferer<E> transferer;
// 无参构造器默认为非公平的
public SynchronousQueue(boolean fair) {
transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
从源码中可以得知已下几点:
- 堆栈和队列都有一个共同的接口,叫做 Transferer,该接口有个方法:transfer,该方法很神奇,会承担 take 和 put 的双重功能;
- 在初始化的时候,是可以选择是使用堆栈还是队列的,如果不选择,默认的就是堆栈,堆栈的效率比队列更高;
非公平的堆栈
首先来看下堆栈的整体结构,如下:
从上图中我们可以看到,我们有一个大的堆栈池,池的开口叫做堆栈头,put 的时候,就往堆栈池中放数据。take 的时候,就从堆栈池中拿数据,两者操作都是在堆栈头上操作数据,从图中可以看到,越靠近堆栈头,数据越新,所以每次 take 的时候,都会拿到堆栈头的最新数据,这就是我们说的后入先出,也就是非公平的。
公平的队列
公平主要体现在,每次 put 数据的时候,都 put 到队尾上,而每次拿数据时,并不是直接从队头拿数据,而是从队尾往前寻找第一个被阻塞的线程,这样就会按照顺序释放被阻塞的线程。
SynchronousQueue的源码比较复杂,暂不做底层讨论。
DelayQueue
前面的三种队列都是阻塞队列,在资源足够时都是立马执行,而DelayQueue队列比较特殊,是一种延迟队列,意思是延迟执行,并且可以设置延迟多久之后执行,比如设置过 5 秒钟之后再执行,在一些延迟执行的场景被大量使用,比如说延迟对账等等。
DelayQueue 延迟队列底层使用的是锁的能力,比如说要在当前时间往后延迟 5 秒执行,那么当前线程就会沉睡 5 秒,等 5 秒后线程被唤醒时,如果能获取到资源的话,线程即可立马执行。
DelayQueue的特性如下:
- 队列中的元素将在过期时被执行,越靠近队头的元素越早过期;
- 未过期的元素不能被take;
- 不允许空元素;
DelayQueue的架构图如下:
可以看到DelayQueue的架构跟其他的三种队列是差不多的,只不过是多实现了Delayed接口,DelayQueue类上是有泛型的,类定义源码如下:
public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
implements BlockingQueue<E> {
从泛型中可以看出,DelayQueue 中的元素必须是 Delayed 的子类,Delayed 是表达延迟能力的关键接口,其继承了 Comparable 接口,并定义了还剩多久过期的方法,如下:
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
也就是说 DelayQueue 队列中的元素必须是实现 Delayed 接口和 Comparable 接口的,并覆写了 getDelay 方法和 compareTo 的方法才行,不然在编译时,编译器就会提醒我们元素必须强制实现 Delayed 接口。
使用方式
DelayQueue 的使用方式案例如下:
public class DelayQueueDemo {
// 队列消息的生产者
static class Product implements Runnable {
private final BlockingQueue queue;
public Product(BlockingQueue queue) {
this.queue = queue;
}
@Override
public void run() {
try {
log.info("begin put");
long beginTime = System.currentTimeMillis();
queue.put(new DelayedDTO(System.currentTimeMillis() + 2000L,beginTime));//延迟 2 秒执行
queue.put(new DelayedDTO(System.currentTimeMillis() + 5000L,beginTime));//延迟 5 秒执行
queue.put(new DelayedDTO(System.currentTimeMillis() + 1000L * 10,beginTime));//延迟 10 秒执行
log.info("end put");
} catch (InterruptedException e) {
log.error("" + e);
}
}
}
// 队列的消费者
static class Consumer implements Runnable {
private final BlockingQueue queue;
public Consumer(BlockingQueue queue) {
this.queue = queue;
}
@Override
public void run() {
try {
log.info("Consumer begin");
((DelayedDTO) queue.take()).run();
((DelayedDTO) queue.take()).run();
((DelayedDTO) queue.take()).run();
log.info("Consumer end");
} catch (InterruptedException e) {
log.error("" + e);
}
}
}
@Data
// 队列元素,实现了 Delayed 接口
static class DelayedDTO implements Delayed {
Long s;
Long beginTime;
public DelayedDTO(Long s,Long beginTime) {
this.s = s;
this.beginTime =beginTime;
}
// 元素的过期策略
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(s - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
// 队列中的元素的排序策略,快过期的元素越靠近队头
@Override
public int compareTo(Delayed o) {
return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
}
public void run(){
log.info("现在已经过了{}秒钟",(System.currentTimeMillis() - beginTime)/1000);
}
}
// demo 运行入口
public static void main(String[] args) throws InterruptedException {
BlockingQueue q = new DelayQueue();
DelayQueueDemo.Product p = new DelayQueueDemo.Product(q);
DelayQueueDemo.Consumer c = new DelayQueueDemo.Consumer(q);
new Thread(c).start();
new Thread(p).start();
}
}
打印出来的结果如下:
06:57:50.544 [Thread-0] Consumer begin
06:57:50.544 [Thread-1] begin put
06:57:50.551 [Thread-1] end put
06:57:52.554 [Thread-0] 延迟了2秒钟才执行
06:57:55.555 [Thread-0] 延迟了5秒钟才执行
06:58:00.555 [Thread-0] 延迟了10秒钟才执行
06:58:00.556 [Thread-0] Consumer end
队列应用的场景
我们学习了 LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、DelayQueue 四种队列,四种队列底层数据结构各不相同,使用场景也不相同,下面对各种四种队列的使用场景进行分析。
LinkedBlockingQueue
适合对生产的数据大小不定(时高时低),数据量较大的场景,比如说我们在淘宝上买东西,点击下单按钮时,对应着后台的系统叫做下单系统,下单系统会把下单请求都放到一个线程池里面,这时候我们初始化线程池时,一般会选择 LinkedBlockingQueue,并且设置一个合适的大小,此时选择 LinkedBlockingQueue 主要原因在于:在不高于我们设定的阈值内,队列里面的大小可大可小,不会有任何性能损耗,正好符合下单流量的特点,时大时小。
一般工作中,大多数都会选择 LinkedBlockingQueue 队列,但会设置 LinkedBlockingQueue 的最大容量,如果初始化时直接使用默认的 Integer 的最大值,当流量很大,而消费者处理能力很差时,大量请求都会在队列中堆积,会大量消耗机器的内存,就会降低机器整体性能甚至引起宕机,一旦宕机,在队列中的数据都会消失,因为队列的数据是保存在内存中的,一旦机器宕机,内存中的数据都会消失的,所以使用 LinkedBlockingQueue 队列时,建议还是要根据日常的流量设置合适的队列的大小。
ArrayBlockingQueue
一般用于生产数据固定的场景,比如说系统每天会进行对账,对账完成之后,会固定的产生 100 条对账结果,因为对账结果固定,我们就可以使用 ArrayBlockingQueue 队列,大小可以设置成 100。
DelayQueue
主要用于任务不想立马执行,想等待一段时间才执行的场景,比如延迟对账。
我们在工作中曾经遇到过这样的场景:我们在淘宝上买东西,弹出支付宝付款页面,在我们输入指纹的瞬间,流程主要是前端 -》交易后端 -》支付后端,交易后端调用支付后端主要是为了把我们支付宝的钱划给商家,而交易调用支付的过程中,有小概率的情况,因为网络抖动会发生超时的情况,这时候就需要通过及时的对账来解决这个事情(对账只是解决这个问题的手段之一),流程图如下:
面试题
1、说说对队列的理解,队列与集合的区别
对队列的理解:
- 队列本身也是个容器,部分队列也可以存储数据,底层也是通过使用不同的数据结构实现各自的功能,比如LinkedBlockingQueue底层的数据结构是链表,通过链表维持先入先出的顺序;
- 队列把数据生产者和数据消费者进行了解耦,生产者只管将生产的数据放入队列中,消费者只需要从队列中获取数据,两者之间没有必然的联系,队列就像生产者和消费者之间的数据通道一样;
- 队列还可以对消费者和生产者进行管理,比如队列满了,有生产者还在不停投递数据时,队列可以使生产者阻塞住,让其不再能投递,比如队列空时,有消费者过来拿数据时,队列可以让消费者 hodler 住,等有数据时,唤醒消费者,让消费者拿数据返回;
队列和集合的区别:
-
和集合的相同点,队列(部分例外)和集合都提供了数据存储的功能,底层使用的也都是基本的数据结构,比如说 LinkedBlockingQueue 和 LinkedHashMap 底层都使用的是链表,ArrayBlockingQueue 和 ArrayList 底层使用的都是数组。
-
和集合的区别:
-
应用场景不同,集合只是用于存储数据,不会对生产者和消费者进行管理;而队列主要的功能是将生产者与消费者进行解耦,并且提供了阻塞的功能,能对消费者和生产者进行简单的管理,队列空时,会阻塞消费者,有其他线程进行 put 操作后,会唤醒阻塞的消费者,让消费者拿数据进行消费,队列满时亦然;
-
解耦了生产者和消费者,队列就像是生产者和消费者之间的管道一样,生产者只管往里面丢,消费者只管不断消费,两者之间互不关心
-
2、队列底层是如何实现阻塞的
是通过锁+Condition实现的,利用了Condition的等待唤醒机制,当队列满了,往队列中put数据时会沉睡,当有其他线程消费了队列中的数据,会唤醒之前的put线程,反之亦然。
3、往队列里面 put 数据是线程安全的么?为什么?
是线程安全的,在 put 之前,队列会自动加锁,put 完成之后,锁会自动释放,保证了同一时刻只会有一个线程能操作队列的数据,以 LinkedBlockingQueue 为例子,put 时,会加 put 锁,并只对队尾 tail 进行操作,take 时,会加 take 锁,并只对队头 head 进行操作,remove 时,会同时加 put 和 take 锁,所以各种操作都是线程安全的。
4、队列在take 的时候也会加锁么?既然 put 和 take 都会加锁,是不是同一时间只能运行其中一个方法
- 是的,take 时也会加锁的,像 LinkedBlockingQueue 在执行 take 方法时,在拿数据的同时,会把当前数据删除掉,就改变了链表的数据结构,所以需要加锁来保证线程安全。
- 这个需要看情况而言,对于 LinkedBlockingQueue 来说,队列的 put 和 take 都会加锁,但两者的锁是不一样的,所以两者互不影响,可以同时进行的,对于 ArrayBlockingQueue 而言,put 和 take 是同一个锁,所以同一时刻只能运行一个方法,线程沉睡时会释放CPU,所以不会造成死锁。
5、工作中经常使用队列的 put、take 方法有什么危害,如何避免
队列满时,使用 put 方法,会一直阻塞到队列不满为止。当队列空时,使用 take 方法,会一直阻塞到队列有数据为止。
两个方法都是无限(永远、没有超时时间的意思)阻塞的方法,容易使得线程全部都阻塞住,大流量时,导致机器无线程可用,所以建议在流量大时,使用 offer 和 poll 方法来代替两者,我们只需要设置好超时阻塞时间,这两个方法如果在超时时间外,还没有得到数据的话,就会返回默认值(LinkedBlockingQueue 为例),这样就不会导致流量大时,所有的线程都阻塞住了。
6、把数据放入队列中后,有木有办法让队列过一会儿再执行?
可以的,DelayQueue 提供了这种机制,可以设置一段时间之后再执行,该队列有个唯一的缺点,就是数据保存在内存中,在重启和断电的时候,数据容易丢失,所以定时的时间我们都不会设置很久,一般都是几秒内,如果定时的时间需要设置很久的话,可以考虑采取延迟队列中间件(这种中间件对数据会进行持久化,不怕断电的发生)进行实现。
7、DelayQueue 对元素有什么要求么,我把 String 放到队列中去可以么?
DelayQueue 要求元素必须实现 Delayed 接口,Delayed 本身又实现了 Comparable 接口,Delayed 接口的作用是定义还剩下多久就会超时,给使用者定制超时时间的,Comparable 接口主要用于对元素之间的超时时间进行排序的,两者结合,就可以让越快过期的元素能够排在前面。
所以把 String 放到 DelayQueue 中是不行的,编译都无法通过,DelayQueue 类在定义的时候,是有泛型定义的,泛型类型必须是 Delayed 接口的子类才行。
8、DelayQueue 如何让快过期的元素先执行的?
DelayQueue 中的元素都实现 Delayed 和 Comparable 接口的,其内部会使用 Comparable 的 compareTo 方法进行排序,我们可以利用这个功能,在 compareTo 方法中实现过期时间和当前时间的差,这样越快过期的元素,计算出来的差值就会越小,就会越先被执行。
9、如果想使用固定大小的队列,有几种队列可以选择,有何不同?
可以使用 LinkedBlockingQueue 和 ArrayBlockingQueue 两种队列。
前者是链表,后者是数组,链表新增时,只要建立起新增数据和链尾数据之间的关联即可,数组新增时,需要考虑到索引的位置(takeIndex 和 putIndex 分别记录着下次拿数据、放数据的索引位置),如果增加到了数组最后一个位置,下次就要重头开始新增。
10、ArrayBlockingQueue 可以动态扩容么?用到数组最后一个位置时怎么办?
不可以的,虽然 ArrayBlockingQueue 底层是数组,但不能够动态扩容的。
假设 put 操作用到了数组的最后一个位置,那么下次 put 就需要从数组 0 的位置重新开始了。假设 take 操作用到数组的最后一个位置,那么下次 take 的时候也会从数组 0 的位置重新开始。
11、ArrayBlockingQueue take 和 put 都是怎么找到索引位置的?是利用 hash 算法计算得到的么?
ArrayBlockingQueue 有两个属性,为 takeIndex 和 putIndex,分别标识下次 take 和 put 的位置,每次 take 和 put 完成之后,都会往后加一,虽然底层是数组,但和 HashMap 不同,并不是通过 hash 算法计算得到的。