一:什么是队列
队列实质就是一种存储数据的结构
通常用链表或者数组实现
一般而言队列具备FIFO先进先出的特性,当然也有双端队列(Deque)优先级队列
主要操作:入队(EnQueue)与出队(Dequeue)
下面展示的是队列的基本操作,包括插入、新增、删除操作
public interface Queue<E> extends Collection<E> {
//插入元素,成功返回true,失败抛出异常
boolean add(E e);
//插入元素,成功返回true,失败返回false或抛出异常
boolean offer(E e);
//取出并移除头部元素,空队列抛出异常
E remove();
//取出并移除头部元素,空队列返回null
E poll();
//取出但不移除头部元素,空队列抛出异常
E element();
//取出但不移除头部元素,空队列返回null
E peek();
//删除并返回队头元素,当队列为空,则会阻塞等待
E take();
}
在操作队列时,相同的操作也会有多种方法,比如poll和take都是获取数据,但是take是阻塞的,下面展示了每种操作的方法特性。一般情况下 offer() 和 poll() 方法配合使用,put() 和 take() 阻塞方法配合使用,add() 和 remove() 方法会配合使用,程序中常用的是 offer() 和 poll() 方法,因此这两个方法比较友好,不会报错。
| |抛出异常 | 特殊值 | 阻塞 | 超时 | |--|--|--|--|--|--|--|--|--|--| |插入 | add(e) |offer(e) | put(e) | offer(e, time, unit) | |移除 | remove() |poll() | | take() | poll(time, unit) | |检查 | element() |peek() |
二:队列的分类
Java 中的这些队列可以从不同的维度进行分类,例如可以从阻塞和非阻塞进行分类,也可以从有界和无界进行分类,而本文将从队列的功能上进行分类,例如:优先队列、普通队列、双端队列、延迟队列等
1、阻塞队列和非阻塞队列
1.1: 阻塞队列
阻塞队列(Blocking Queue)提供了可阻塞的 put 和 take 方法,它们与可定时的 offer 和 poll 是等价的。如果队列满了 put 方法会被阻塞等到有空间可用再将元素插入;如果队列是空的,那么 take 方法也会阻塞,直到有元素可用。当队列永远不会被充满时,put 方法和 take 方法就永远不会阻塞。在java包"java.util.concurrent"中,提供六个实现了"BlockingQueue"接口的阻塞队列。
- ArrayBlockingQueue 用数组实现的有界阻塞队列,默认情况下不保证线程公平的访问队列(按照阻塞的先后顺序访问队列),队列可用的时候,阻塞的线程都可以争夺队列的访问资格,当然也可以使用以下的构造方法创建一个公平的阻塞队列。ArrayBlockingQueue blockingQueue2 = new ArrayBlockingQueue<>(10, true)。(其实就是通过将ReentrantLock设置为true来 达到这种公平性的:即等待时间最长的线程会先操作)。用ReentrantLock condition 实现阻塞。有界就是队列的长度有限制,例如数组队列,在构建的时候就指定了长度。无界就是可以无限地添加
public static void main(String[] args) throws InterruptedException {
/**
* 用数组实现的有界阻塞队列,默认情况下不保证线程公平的访问队列(按照阻塞的先后顺序访问队列)
* 队列可用的时候,阻塞的线程都可以争夺队列的访问资格,当然也可以使用以下的构造方法创建
* 一个公平的阻塞队列。
* ArrayBlockingQueue blockingQueue = new ArrayBlockingQueue<>(10, true)。
* (其实就是通过将ReentrantLock设置为true来 达到这种公平性的:即等待时间最长的线程会先操作)。
* 用ReentrantLock condition 实现阻塞
*/
CountDownLatch countDownLatch = new CountDownLatch(2);
ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(2);
CompletableFuture.runAsync(()->{
int i=0;
while (true){
System.out.println("======存放数据:"+i);
try {
arrayBlockingQueue.put(i++);
} catch (InterruptedException e) {
e.printStackTrace();
countDownLatch.countDown();
}
}
});
CompletableFuture.runAsync(()->{
while (true){
Object poll = arrayBlockingQueue.poll();
if( poll!= null){
System.out.println("取出数据:"+poll);
}
}
});
countDownLatch.await();
System.out.println(arrayBlockingQueue.size());
}
- LinkedBlockingQueue 基于链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。这个队列的实现原理和ArrayBlockingQueue实现基本相同。也是采用ReentrantLock 控制并发,不同的是它使用两个独占锁来控制消费和生产。即用takeLock和putlock,这样的好处是消费者和生产者可以并发执行,对吞吐量有提升
- PriorityBlockingQueue PriorityBlockingQueue是一个带优先级的队列,而不是先进先出队列。元素按优先级顺序被移除,该队列也没有上限(PriorityBlockingQueue是对 PriorityQueue的再次包装,是基于堆数据结构的,而PriorityQueue是没有容量限制的,与ArrayList一样,所以在优先阻塞 队列上put时是不会受阻的。虽然此队列逻辑上是无界的,但是由于资源被耗尽,所以试图执行添加操作可能会导致 OutOfMemoryError),但是如果队列为空,那么取元素的操作take就会阻塞,所以它的检索操作take是受阻的。也是用ReentrantLock控制并发。
public static void main(String[] args) throws InterruptedException {
PriorityBlockingQueue<Integer> priorityBlockingQueue = new PriorityBlockingQueue<>();
CountDownLatch countDownLatch = new CountDownLatch(2);
CompletableFuture.runAsync(()->{
for (int i = 1000; i>0 ; i--) {
System.out.println("存放数据:"+i);
priorityBlockingQueue.put(i);
}
});
Thread.sleep(5000);
CompletableFuture.runAsync(()->{
while (true){
try {
System.out.println("消费:"+priorityBlockingQueue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
countDownLatch.await();
}
- DelayQueue是在PriorityQueue基础上实现的,底层也是数组构造方法,是一个存放Delayed 元素的无界阻塞队列,只有在延迟期满时才能从中提取元素。该队列的头部是延迟期满后保存时间最长的 Delayed 元素。如果延迟都还没有期满,则队列没有头部,并且poll将返回null。当一个元素的 getDelay(TimeUnit.NANOSECONDS) 方法返回一个小于或等于零的值时,则出现期满,poll就移除这个元素了。此队列不允许使用 null 元素。
@Data
public static class MovieTiket implements Delayed {
//延迟时间
private final long delay;
//到期时间
private final long expire;
//数据
private final String msg;
//创建时间
private final long now;
/**
* @param msg 消息
* @param delay 延期时间
*/
public MovieTiket(String msg , long delay) {
this.delay = delay;
this.msg = msg;
expire = System.currentTimeMillis() + delay; //到期时间 = 当前时间+延迟时间
now = System.currentTimeMillis();
}
/**
* 获得延迟时间 用过期时间-当前时间,时间单位毫秒
* @param unit
* @return
*/
public long getDelay(TimeUnit unit) {
return unit.convert(this.expire
- System.currentTimeMillis() , TimeUnit.MILLISECONDS);
}
/**
* 用于延迟队列内部比较排序 当前时间的延迟时间 - 比较对象的延迟时间
* 越早过期的时间在队列中越靠前
* @param delayed
* @return
*/
public int compareTo(Delayed delayed) {
return (int) (this.getDelay(TimeUnit.MILLISECONDS)
- delayed.getDelay(TimeUnit.MILLISECONDS));
}
@Override
public String toString() {
return "MovieTiket{" +"delay=" + delay + ", expire=" + expire +
", msg='" + msg + '\'' + ", now=" + now +'}';
}
}
public static void main(String[] args) {
DelayQueue<MovieTiket> delayQueue = new DelayQueue<MovieTiket>();
MovieTiket tiket = new MovieTiket("电影票0,售出时间:"+ LocalDateTime.now(),10000);
delayQueue.put(tiket);
MovieTiket tiket1 = new MovieTiket("电影票1,售出时间:"+ LocalDateTime.now(),5000);
delayQueue.put(tiket1);
MovieTiket tiket2 = new MovieTiket("电影票2,售出时间:"+ LocalDateTime.now(),8000);
delayQueue.put(tiket2);
System.out.println("message:--->入队完毕");
while( delayQueue.size() > 0 ){
try {
tiket = delayQueue.take();
System.out.println("电影票出队:"+tiket.getMsg()+",出队时间:"+LocalDateTime.now());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- SynchronousQueue 一个没有容量的队列 ,不会存储数据,每执行一次put就要执行一次take,否则就会阻塞。未使用锁。通过cas实现,吞吐量异常高。内部采用的就是ArrayBlockingQueue的阻塞队列,所以在功能上完全可以用ArrayBlockingQueue替换,但是SynchronousQueue是轻量级的,SynchronousQueue不具有任何内部容量,我们可以用来在线程间安全的交换单一元素。所以功能比较单一,优势就在于轻量。
- LinkedBlockingDeque LinkedBlockingDeque是双向链表实现的双向并发阻塞队列。该阻塞队列同时支持FIFO和FILO两种操作方式,即可以从队列的头和尾同时操作(插入/删除);并且,该阻塞队列是支持线程安全,当多线程竞争同一个资源时,某线程获取到该资源之后,其它线程需要阻塞等待。此外,LinkedBlockingDeque还是可选容量的(防止过度膨胀),即可以指定队列的容量。如果不指定,默认容量大小等于Integer.MAX_VALUE。
1.2:非阻塞队列
所有不带BlockingQueue关键字的队列都是非阻塞队,并且它不会包含 put 和 take 方法,java中提供了基于CAS非阻塞算法实现的队列,比较有代表性的有ConcurrentLinkedQueue它们的性能一般比阻塞队列的好。
public static void main(String[] args) throws InterruptedException {
/**
* 是一个基于链接节点的无界线程安全的队列,按照先进先出原则对元素进行排序。
* 新元素从队列尾部插入,而获取队列元素,则需要从队列头部获取。
*
*ConcurrentLinkedQueue由head节点和tail节点组成,
* 每个节点(Node)由节点元素(item)和指向下一个节点的引用(next)组成,
* 节点与节点之间就是通过这个next关联起来,从而组成一张链表结构的队列
*
* size()方法用来获取当前队列的元素个数,但在并发环境中,其结果可能不精确,
* 因为整个过程都没有加锁,所以从调用size方法到返回结果期间有可能增删元素,导致统计的元素个数不精确。
*/
CountDownLatch countDownLatch = new CountDownLatch(2);
ConcurrentLinkedQueue concurrentLinkedQueue = new ConcurrentLinkedQueue();
CompletableFuture.runAsync(() -> {
IntStream.range(0, 100).forEach(x -> {
concurrentLinkedQueue.add(x);
});
countDownLatch.countDown();
});
CompletableFuture.runAsync(()->{
IntStream.range(1,100).forEach(x->{
concurrentLinkedQueue.add(x);
});
countDownLatch.countDown();
});
countDownLatch.await();
System.out.println(concurrentLinkedQueue.size());
}
2、有界队列和无界队列
2.1: 有界队列
是指有固定大小的队列,比如设定了固定大小的 ArrayBlockingQueue,又或者大小为 0 的 SynchronousQueue。
2.2: 无界队列
指的是没有设置固定大小的队列,但其实如果没有设置固定大小也是有默认值的,只不过默认值是 Integer.MAX_VALUE,当然实际的使用中不会有这么大的容量(超过 Integer.MAX_VALUE),所以从使用者的角度来看相当于 “无界”的。
3、双端队列
双端队列(Deque)是指队列的头部和尾部都可以同时入队和出队的数据结构,如下图所示:
import java.util.concurrent.LinkedBlockingDeque;
/**
* 双端队列示例
*/
static class LinkedBlockingDequeTest {
public static void main(String[] args) {
// 创建一个双端队列
LinkedBlockingDeque deque = new LinkedBlockingDeque();
deque.offer("offer"); // 插入首个元素
deque.offerFirst("offerFirst"); // 队头插入元素
deque.offerLast("offerLast"); // 队尾插入元素
while (!deque.isEmpty()) {
// 从头遍历打印
System.out.println(deque.poll());
}
}
}
以上代码的执行结果如下: offerFirst offer offerLast
4、优先队列
优先队列(PriorityQueue)是一种特殊的队列,它并不是先进先出的,而是优先级高的元素先出队。
优先队列是根据二叉堆实现的,二叉堆的数据结构如下图所示:
二叉堆分为两种类型:一种是最大堆一种是最小堆。以上展示的是最大堆,在最大堆中,任意一个父节点的值都大于等于它左右子节点的值。
5、延迟队列
延迟队列(DelayQueue)是基于优先队列 PriorityQueue 实现的,它可以看作是一种以时间为度量单位的优先的队列,当入队的元素到达指定的延迟时间之后方可出队,例如上面的代码
三:队列的使用场景
在我们开发中,实际使用到的场景并不是很多,但是或多或少都会用到,最典型的就是线程池,不同的线程池都是基于不同的队列来实现多任务等待的。下面就介绍几种队列的使用场景,或许不是很实际,仅作参考
-
LinkedBlockingQueue使用场景 分析: 1.基于链表,数据的新增和移除速度比数组快,但是每次存储/取出数据都会有Node对象的新建和移除,所以也存在由于GC影响性能的可能 2.默认容量非常大,所以存储数据的线程基本不会阻塞,但是如果消费速度过低,内存占用可能会飙升。 3.读/取操作锁分离,所以适合有并发和吞吐量要求的项目中 使用场景: 在项目的一些核心业务且生产和消费速度相似的场景中:订单完成的邮件/短信提醒。 订单系统中当用户下单成功后,将信息放入ArrayBlockingQueue中,由消息推送系统取出数据进行消息推送提示用户下单成功。如果订单的成交量非常大,那么使用ArrayBlockingQueue就会有一些问题,固定数组很容易被使用完,此时调用的线程会进入阻塞,那么可能无法及时将消息推送出去,所以使用LinkedBlockingQueue比较合适,但是要注意消费速度不能太低,不然很容易内存被使用完(一般而言不会时时刻刻生产消息, 但是需要预防消息大量堆积) 比较ArrayBlockingQueue: 实际上对于ArrayBlockingQueue和LinkedBlockingQueue在处理普通的生产者-消费者问题时,两者一般可互相替换使用。 这里也赘述下,有人可能会问为什么不用MQ,或者Redis 笔者认为:很多技术知识有相同的使用场景,是很常见的,使用MQ/Redis也好,阻塞队列也罢,我们需要考虑项目中采用哪种方案是最合适的的,如果我们有现成的MQ/Redis,且公司前辈对于功能的使用有一个很好的封装,或者业务要求必须使用MQ,那我们项目使用这些也没有问题,但是如果没有现成的MQ/Redis或者没有现成的使用封装,业务又相对单一,那我们用阻塞队列简单的写一个小功能去实现也是很不错的,当然如果你是为了学习这些中间件那就另当别论了。
-
PriorityBlockingQueue使用场景 分析 优先级阻塞队列中存在一次排序,根据优先级来将数据放入到头部或者尾部 排序带来的损耗因素,由二叉树最小堆排序算法来降低 使用场景: 在项目上存在优先级的业务:VIP排队购票 用户购票的时候,根据用户不同的等级,优先放到队伍的前面,当存在票源的时候,根据优先级分配
-
DelayQueue使用场景 分析: 由于是基于优先级队列实现,但是它比较的是时间,我们可以根据需要去倒叙或者正序排列(一般都是倒叙,用于倒计时) 使用场景:订单超时取消功能、网站刷题倒计时 用户下订单未支付开始倒计时,超时则释放订单中的资源,如果取消或者完成支付,我们再讲队列中的数据移除掉。
-
SynchronousQueue使用场景 分析: 相当于是交换通道,不存储任何元素,提供者和消费者是需要组队完成工作,缺少一个将会阻塞线程,直到等到配对为止 使用场景:参考线程池newCachedThreadPool()。 如果我们不确定每一个来自生产者请求数量但是需要很快的处理掉,那么配合SynchronousQueue为每个生产者请求分配一个消费线程是最简洁的办法。Executors.newCachedThreadPool()就使用了SynchronousQueue,这个线程池根据需要(新任务到来时)创建新的线程,如果有空闲线程则会重复使用,线程默认空闲了60秒后会被回收。 轻量级别的任务转交 比如会话转交,通常坐席需要进行会话转交,如果有坐席在线那么会为我们分配一个客服,但是如果没有,那么阻塞请求线程,一段时间后会超时或者提示当前坐席已满。