ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java Queue接口深度解析:从数据结构到阻塞队列与线程池实战

Java Queue接口深度解析:从数据结构到阻塞队列与线程池实战 1. Queue接口的设计定位与整体认知1.1 从接口定义看队列的本质说句实在话干了这么多年Java开发面试过不少人也带过不少新人我发现一个很有意思的现象很多写了两三年代码的人对List、Map这些接口如数家珍但一问到Queue反应往往是“哦就是队列嘛先进先出”然后让他手写个生产者消费者模型就卡壳了。Queue接口在Java集合框架里其实是个非常特殊的存在——它既是集合框架的一员又和并发、异步、系统解耦这些重型概念深度绑定堪称Java程序员从“写业务代码”迈向“写系统代码”的一道分水岭。先看这个接口最原始的定位。Queue在java.util包下直接继承自Collection接口。它的javadoc第一句话就写得很明白“A collection designed for holding elements prior to processing.”——这是一个为了“暂存待处理元素”而设计的集合。注意这个“prior to processing”这就是队列和List最本质的区别List是数据的容器你往里面放什么取出来还是什么数据本身是目的而Queue是数据的通道元素在队列里只是“路过”它存在的意义是“被取走、被处理”队列本身不关心数据长什么样只关心数据进出的顺序和节奏。这种定位差异直接体现在方法设计上。Collection接口里那套add、remove、element方法Queue全部重写了语义而且还额外增加了一套offer、poll、peek方法。为什么要搞两套因为队列要应对一个Collection不需要考虑的问题——容量限制。数组实现的队列可能有界内存受限的系统里队列可能装不下并发场景下生产者可能比消费者跑得快这些情况在纯数据容器里几乎不会发生但对队列来说却是家常便饭。所以Queue接口的设计者干脆把“失败”这件事做了两种处理方式一种是用异常告诉你“操作失败了”另一种是返回特殊值false或null让你自己去判断。1.2 六组核心方法的语义与使用场景Queue接口一共定义了六组方法看起来简单但要把它们在实际场景中用对还真得花点心思。我整理成一张表先说结论再展开操作类型抛异常版本返回特殊值版本特殊值的含义入队add(e)offer(e)入队失败返回false出队remove()poll()队列为空返回null查看队首element()peek()队列为空返回null先说add和offer。add是Collection接口带下来的老方法语义是“保证这个元素加进去了加不进去就报错”。offer是Queue接口新引入的语义是“我尝试把这个元素加进去加不进去拉倒你返回个false告诉我一声就行”。在有界队列的场景下这两个方法的差异立竿见影用add往一个满的队列里塞元素会直接抛IllegalStateException用offer则安安静静返回false代码可以继续往下走。再看remove和poll。remove在队列为空时抛NoSuchElementExceptionpoll在队列为空时返回null。这里有个非常经典的坑如果你往队列里存的是null值然后用poll去取取出来一个null你根本分不清到底是队列空了还是你存进去的元素本身就是null。所以Queue接口的javadoc里专门写了一句话LinkedList这种实现是允许存null的但Queue接口的典型实现都不应该允许存null——你看ArrayDeque直接就是禁止null的PriorityQueue也禁止LinkedBlockingQueue同样禁止。我在代码审查里看到有人往队列里塞null还要用poll判空做业务判断的基本都会让他改掉这种写法就是埋雷。至于element和peek这俩都不删除元素只是看一眼队首是什么。区别和前两组一样队列为空时element抛异常peek返回null。从工程实践来说如果你不确定队列是否为空用offer、poll、peek这套返回特殊值的版本永远比抛异常版本更安全。但如果你明确知道队列里一定有数据那用add、remove、element也可以写起来更简洁还能在早期就暴露逻辑错误——这其实就是两种设计哲学的取舍显式地处理边界情况还是让异常机制帮你兜底。2. 队列背后的数据结构设计思路2.1 数组循环队列与链表队列的实现取舍理解了接口还得理解实现。Queue接口是个抽象契约具体怎么存数据不同实现类给出了完全不同的答案。最常见的两种底层结构是数组和链表对应到Queue上就是ArrayDeque和LinkedList。先说数组实现的队列。如果你用数组直接实现队列最朴素的想法是元素从尾部追加从头部取出。但头部取出之后前面的位置就空出来了如果每次都把后面的元素往前搬出队操作就是O(n)的复杂度数据量一大性能直接崩。所以工业级的做法是循环队列用两个指针分别指向队头和队尾入队时队尾指针向后移动出队时队头指针向后移动指针走到数组末尾就绕回开头。这样入队和出队都是O(1)的时间复杂度代价是数组的容量是固定的满了就需要扩容。ArrayDeque就是循环队列的典型实现。它的内部维护了一个Object[]数组和两个索引——head和tail元素存在数组里首尾相接形成一个环。ArrayDeque默认容量是16每次扩容会翻倍而且它保证容量永远是2的幂这样在计算下一个索引位置时可以用位运算(tail 1) (elements.length - 1)替代取模运算性能更好。这里有个细节值得注意ArrayDeque不是一个纯粹的Queue实现它同时实现了Deque接口也就是双端队列既能从头加也能从尾加既能从头取也能从尾取所以它也可以当栈用。不过它有个硬性限制——不允许存null原因上面说过null是poll和peek表达“队列为空”的特殊值不能让业务数据来混淆这个语义。再说链表实现的队列。链表实现的好处是天然支持动态扩容不需要像数组那样频繁搬家每个节点在内存里是分散的插入和删除只需要调整指针引用。LinkedList就是双向链表的实现它同时实现了List和Deque两个接口所以既能当线性表用也能当队列和双端队列用。但这把双刃剑的另外一面是LinkedList每个节点除了存数据还要存前驱和后继两个引用内存占用比数组实现高而且节点在内存里分散存储CPU缓存命中率远不如数组连续存储来得高在大量读操作的场景下性能会差一些。那么实际开发里怎么选我的经验是单端队列场景用ArrayDeque需要从两端操作用ArrayDeque双端能力需要按索引随机访问但又偶尔当队列用才考虑LinkedList。反正从Java 6之后官方自己都在ArrayDeque的javadoc里写了“当栈用的话这个类比LinkedList快当队列用也比LinkedList快”话都说到这个份上了还有啥好纠结的。2.2 为什么接口设计要区分有界和无界Queue接口本身没有定义有界还是无界这是实现类自己的事。但理解“有界”和“无界”的差异对选型至关重要。无界队列意味着理论上可以无限添加元素直到内存耗尽。LinkedList、ArrayDeque、PriorityQueue都属于无界队列。它们的offer方法永远返回trueadd永远不抛空间异常因为空间不够就扩容。听起来很方便但在生产环境里这是很危险的设计——如果消费者处理不过来生产者又不停地往队列里塞内存最终会被撑爆进程直接OOM。我见过一个真实案例一个定时任务从数据库拉数据放到ArrayList当队列用某天上游数据量突增消费者线程又挂了结果内存飙到几个G整个服务被OutOfMemoryError干掉了。有界队列则相反它在创建时就指定了容量上限满了以后怎么处理由你选择的策略决定。ArrayBlockingQueue就是典型的有界队列它的put方法在队列满时会阻塞offer带超时时间的版本会在指定时间内等待空间释放。这种设计把“背压”机制直接做到了数据结构层面生产者发现队列满了要么阻塞等待要么放弃本次入队要么走你自定义的拒绝策略——无论如何系统不会因为积压数据而崩溃。我在项目里给人做代码评审时经常说一句话线上系统的队列默认都应该有界。无界队列只适合你百分百确定消费速度跟得上生产速度的场景但凡存在一点点不确定性请给队列加个容量上限。这也是为什么Java的并发包java.util.concurrent里几乎所有的阻塞队列都是可以设置容量上限的——因为并发环境下不确定性才是常态。2.3 PriorityQueue队列不一定是先进先出很多人以为队列就是先进先出这是对Queue接口最大的误解。Queue接口的方法定义里压根没规定元素必须按插入顺序出队——它只定义了操作的语义具体的排序规则由实现类自己决定。PriorityQueue就是个典型例子它实现的是优先级队列出队顺序由元素的自然顺序Comparable或者你传入的Comparator决定而不是插入顺序。PriorityQueue的底层数据结构是二叉堆具体来说是最小堆——堆顶元素永远是全队列里最小的那个。插入元素时新元素先放到数组末尾然后执行上浮操作不断和父节点比较如果比父节点小就交换位置删除堆顶元素时把数组末尾的元素移到堆顶然后执行下沉操作和两个子节点中较小的那个比较如果比子节点大就交换位置。这两个操作的时间复杂度都是O(log n)所以在n个元素里反复入队出队总体的排序成本比每次全量排序要低得多。PriorityQueue在实际业务里的应用非常广泛。比如定时任务调度每个任务有一个下次执行时间每次从队列里取最近要执行的那个任务这个场景用PriorityQueue就比用普通LinkedList高效得多。再比如Dijkstra最短路径算法核心就是从候选节点集合里反复取距离最短的那个节点教科书上的经典实现就是用优先队列。还有个面试里常问的点PriorityQueue是否线程安全答案是否定的。多线程环境要用PriorityBlockingQueue它是PriorityQueue的线程安全版本take方法在队列为空时会阻塞等待——这个类在实现Dijkstra算法、定时任务调度这类需要并发处理的场景时非常有用。3. 常用实现类与阻塞队列的实战选择3.1 非并发场景的Queue实现类对比先看不需要考虑多线程竞争时你的选择有哪些。除了上面提到的ArrayDeque、LinkedList、PriorityQueue还有一个容易被忽略的ConcurrentLinkedQueue——虽然它的名字里有Concurrent但它其实是一个非阻塞的线程安全队列用的是CAS无锁算法。但从这个类的设计本身来看它适合的是高并发下的无界队列场景不需要线程阻塞等待只需要保证多线程访问的安全性和顺序性。我把这几个非并发场景的常用实现做个对比实现类底层结构是否允许null排序规则线程安全典型场景ArrayDeque循环数组禁止FIFO否栈、双端队列、单线程FIFOLinkedList双向链表允许FIFO否兼容List操作的队列场景PriorityQueue二叉堆禁止优先级排序否任务调度、TopK问题ConcurrentLinkedQueue单向链表 CAS禁止FIFO是无锁高并发无界队列实际选型时我的建议是单线程或不需要线程安全的场景优先ArrayDeque需要按优先级处理的场景用PriorityQueue多线程共享但又不希望线程被阻塞的场景用ConcurrentLinkedQueue。LinkedList除非你确实需要它作为List的那些能力否则从性能和内存占用角度其实没什么理由选它当队列用。3.2 阻塞队列生产消费者模式的核心聊到并发队列就绕不开java.util.concurrent包下的几个阻塞队列。所谓阻塞队列核心在于提供了两类特殊操作一类是put和take队列满时put会阻塞队列空时take会阻塞直到条件满足才返回另一类是带超时时间的offer(e, timeout, unit)和poll(timeout, unit)在指定时间内等不到就返回失败或null。Java提供了好几种阻塞队列实现我按使用频率排个序说说ArrayBlockingQueue有界数组阻塞队列容量必须在构造时指定。内部用一把锁维护公平性默认是非公平锁。适合有界生产消费者模型。LinkedBlockingQueue基于链表的可选有界阻塞队列默认容量是Integer.MAX_VALUE也就是无界。生产和消费各用一把锁锁竞争比ArrayBlockingQueue低吞吐量更高。但默认无界这个特性在线上环境要千万小心。SynchronousQueue不存储元素的队列每个put必须等待一个take反之亦然。它更像一个“交接手递手”的通道没有缓冲能力。Executors.newCachedThreadPool()用的就是它。PriorityBlockingQueue无界的优先级阻塞队列take会阻塞等待但出队顺序按优先级排列。DelayQueue无界阻塞延迟队列每个元素都有过期时间只有过期的元素才能被取出。定时任务调度、缓存过期清理就适合用它。LinkedTransferQueueSynchronousQueue和LinkedBlockingQueue的合体支持transfer方法生产者可以直接把元素交给消费者没有消费者就阻塞。我自己的实践经验是如果只有一个生产者和一个消费者想要最大吞吐量优先考虑LinkedBlockingQueue或ConcurrentLinkedQueue因为锁竞争比ArrayBlockingQueue小如果队列需要有界且要求公平性选ArrayBlockingQueue。业务里最常用的组合是“有界LinkedBlockingQueue 自定义拒绝策略”这样既能避免无界队列的OOM风险又能利用两把锁带来的吞吐量优势。3.3 线程池中的阻塞队列选择ThreadPoolExecutor的构造函数里有一个参数就是BlockingQueueRunnable workQueue这个参数决定了线程池的排队策略。这个知识点几乎是Java面试的必考题也是生产环境线程池配置翻车的重灾区。先梳理清楚线程池的任务处理逻辑核心线程满了吗没满就新建线程执行满了就丢进队列排队。队列满了吗没满就排队等待满了就判断线程数是否达到最大线程数如果还没达到就新建临时线程执行如果达到了就走拒绝策略。注意这个流程里队列的大小直接决定了“核心线程数”和“最大线程数”之间的缓冲区间有多大。常见的选型有三种第一种SynchronousQueue不排队任务直接提交给线程没有空闲线程就尝试新建达到最大线程数就拒绝。Executors.newCachedThreadPool()就是这个配置适合大量短生命周期任务的场景但高峰时期线程数可能飙升得很高。第二种LinkedBlockingQueue无界队列所有任务都排队线程数永远不会超过核心线程数。这就是Executors.newFixedThreadPool()的配置。表面上看起来很稳定实际上非常危险——如果任务处理不过来队列会无限增长内存迟早耗尽。第三种ArrayBlockingQueue有界队列队列有容量上限配合合理的最大线程数和拒绝策略这才是生产环境最可控的方案。我之前排查过一个线上事故一个服务用Executors.newFixedThreadPool(10)处理异步任务某天上游流量突增任务积压速度远大于处理速度结果那个无界LinkedBlockingQueue里堆积了几百万个任务对象直接导致堆内存溢出。从那之后我在任何代码审查里看到Executors工具类创建的线程池基本都会要求改成new ThreadPoolExecutor手动指定参数核心原因就是业务方要清楚自己的队列是有界的还是无界的以及队列满了之后系统会做什么。4. 队列思想在真实业务场景中的应用4.1 用Queue接口实现简易生产者消费者理论说了这么多来点实操。假设你要实现一个简单的日志采集系统多个线程产生日志一个线程负责把日志写入文件。最朴素的做法是直接加锁同步但这样生产线程会阻塞在写文件上日志量大的时候整个业务都会被拖慢。更合理的方案是用一个队列做缓冲生产线程只管往队列里丢日志对象消费线程从队列里取出来异步写文件。生产者的核心逻辑可以写成这样import java.util.concurrent.BlockingQueue; public class LogProducer implements Runnable { private final BlockingQueueLogEntry queue; private final String producerName; public LogProducer(BlockingQueueLogEntry queue, String producerName) { this.queue queue; this.producerName producerName; } Override public void run() { try { for (int i 0; i 1000; i) { LogEntry entry new LogEntry(producerName, System.currentTimeMillis(), log message i); // offer带超时时间避免队列满时永久阻塞 boolean offered queue.offer(entry, 100, TimeUnit.MILLISECONDS); if (!offered) { // 队列已满可以做降级处理这里直接丢弃并计数 System.out.println(producerName dropped entry i); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }消费者的核心逻辑public class LogConsumer implements Runnable { private final BlockingQueueLogEntry queue; public LogConsumer(BlockingQueueLogEntry queue) { this.queue queue; } Override public void run() { try { while (!Thread.currentThread().isInterrupted()) { // take会阻塞等待队列为空时线程挂起 LogEntry entry queue.take(); // 实际写文件或做其他处理 System.out.println(consume: entry); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这段代码里有个容易被忽视的细节我用offer带超时时间而不是put。原因很简单put在队列满时会一直阻塞如果消费线程挂了生产线程会全部卡在put上整个应用就假死了。用带超时的offer至少可以在一段时间后返回false然后走你预设的降级逻辑。这就是“宁可丢弃数据不可阻塞业务”的取舍思路。4.2 从Queue接口到MQ消息队列的工程化演进理解了Queue接口的生产消费者模型再去看业界各种消息队列Message Queue产品你会觉得豁然开朗——它们本质上是把Java里的Queue概念做了分布式和持久化的扩展。Java里的BlockingQueue解决的是进程内的异步解耦问题。一个JVM里多个线程之间传递数据Queue是最自然的缓冲结构。但实际业务系统中生产者和消费者往往不在同一个进程里甚至不在同一台机器上。这时候就要引入消息队列中间件比如RabbitMQ、Kafka、RocketMQ它们做的事情就是把队列从单机内存搬到了网络和磁盘上。从Queue接口到MQ核心变化有几个维度一是持久化消息发出去之后不能丢要能落盘还要能复制多份保证高可用二是分布式队列的容量和吞吐量不能受限于单机内存要能横向扩展三是消费确认消费者处理完一条消息要显式确认没确认的消息要能重新投递这对应着消息队列里“至少一次投递”“幂等消费”这些概念四是延时与重试消息可以延迟发送、失败自动重试这些能力在单机队列里都需要自己实现。所以你会发现如果你把JavaQueue接口的语义理解透了再看消息队列的各种术语其实一点都不陌生生产者就是offer消息进来消费者就是poll或take消息消息积压就是队列长度堆积消费失败就是处理消息时抛了异常。我在招聘Java后端工程师时特别喜欢问面试者“Java里的BlockingQueue和RabbitMQ有什么本质区别”能把这个回答清楚的人说明对队列的理解是真的到位了。4.3 队列在异步化改造中的典型用法还有一个很常见但容易被当面试题背下来的场景接口性能优化。假设有个下单接口核心链路是校验库存、扣减余额、创建订单这些必须同步执行但下单成功后要发短信通知用户、送积分、更新风控数据这些操作耗时不说还依赖第三方服务动不动就几百毫秒。如果全部同步处理接口响应时间直接拉满。典型的优化方式就是把这些非核心操作丢到队列里异步处理。我之前做一个电商项目下单接口里接了一个优惠券发放逻辑结果优惠券服务故障每次调用都超时下单接口整体响应时间从50毫秒飙到2秒。后来就是加了个延迟队列优惠券发放改为异步先返回下单成功系统在后台从队列里取出发券任务重试执行。接口耗时又回到了50毫秒以下用户无感知发券任务哪怕失败也可以反复重试不影响主流程。这就体现了队列的核心价值——削峰填谷、异步解耦、失败缓冲。5. 常见问题与排查技巧实录5.1 空队列与满队列的处理策略实际开发中最容易出问题的就是队列的边界状态空了怎么办满了怎么办很多线上故障都源于这里。先看“空队列”。用poll或take取元素时如果队列为空poll返回nulltake阻塞等待。如果你的业务将null视为合法数据那就要特别小心。我见过一个支付系统的代码从队列里取待处理的支付请求当队列为空时poll返回null代码里没判空就直接拿去调下游接口结果打出了一堆空指针日志。排查了很久才发现根本不是下游接口的问题而是队列空的时候取了个null出来。这个教训写代码的时候记住了poll返回null时先确认到底是队列空还是数据本身是null处理逻辑要分开写。再看“满队列”。有界队列的offer返回false时你的代码必须有一个明确的出路。很多人遇到队列满就Thread.sleep重试看似可行但在高并发下可能造成大量线程同时睡眠、同时唤醒、同时争抢锁反而把系统拖垮。更合理的做法是结合业务选择策略允许丢弃时直接丢弃并记录监控指标不允许丢弃时可以降级为同步处理或者把任务写入本地文件稍后重放还可以把offer的等待时间拉长让生产者喘息一下。重点是要可观测——队列满的事件必须打到监控系统里否则问题发生时你根本不知道瓶颈在哪。5.2 迭代器与视图方法的坑Queue继承了Collection接口所以它也有iterator()方法、contains()方法、toArray()方法。但这里有个反直觉的点Queue的迭代器并不保证按出队顺序遍历。尤其是PriorityQueue它的迭代器遍历顺序和poll()的出队顺序完全不一样——因为二叉堆底层数组的存储顺序不代表元素的大小顺序。如果你用迭代器遍历一个PriorityQueue去判断“下一个出队的元素是什么”结果大概率是错的。正确做法是直接peek()看堆顶元素。LinkedList作为Deque和List的双重实现它的迭代器倒是可以按顺序遍历但如果你在迭代过程中调用add或remove方法会抛出ConcurrentModificationException。这个异常在单线程下也会发生因为LinkedList的迭代器是fail-fast机制的只要结构被修改迭代器就会检测到。我见过有人写循环遍历队列然后删除元素用了迭代器又不调用迭代器自己的remove方法结果莫名其妙抛异常——这里要注意用迭代器删除元素时必须用it.remove()不能用queue.remove()。还有一个比较隐蔽的坑ArrayDeque的remove(Object)方法是Collection接口定义的它从队列里删除第一个匹配的元素这个方法的复杂度是O(n)的因为要遍历整个数组找元素。如果队列里有大量元素频繁调用remove(Object)性能会非常差。所以在设计队列存储的元素类型时尽量避免需要按值删除元素的场景——队列的核心操作只有进出队按值查找和删除本身就不是它的设计目标。5.3 并发场景下选择与性能调优建议并发编程里用队列线程安全是第一优先级但不同的线程安全实现方式带来的性能差异非常大。ConcurrentLinkedQueue用的是无锁的CAS算法适合高频读写但队列通常比较短的场景因为它的size()方法为了拿到准确值需要遍历整个队列O(n)的复杂度用于监控或者判断队列是否为空时居然是性能死角。ConcurrentLinkedQueue的isEmpty()方法是O(1)的所以判断空队列场景尽量用isEmpty()而不是size() 0。LinkedBlockingQueue用了两把锁生产和消费各一把锁竞争比ArrayBlockingQueue的单锁要小。但两把锁也带来了新的问题size()方法的准确性和一致性受影响。LinkedBlockingQueue的size()用一个原子变量维护所以是准的但ArrayBlockingQueue的size()直接返回一个普通int字段也是准的——这在面试里经常被拿来对比。实际场景中我觉得倒不必过度纠结这些微小的性能差异更重要的是理解队列长度本身就是一个动态变化的量做监控时看趋势比看绝对值更有意义。如果你对性能有极致要求并且对并发控制足够熟悉还可以考虑使用Disruptor这种无锁环形队列框架它通过数组预分配内存、序列号对齐、缓存行填充等黑科技把队列的吞吐量推到了千万级每秒。但绝大多数业务系统用不上这种级别的工具ArrayBlockingQueue和LinkedBlockingQueue的性能已经完全够用了。5.4 Queue面试高频考点速查数组和链表实现队列的优缺点是什么循环队列怎么判断队空和队满为什么ArrayDeque相比LinkedList更适合做栈和队列生产消费者模型有哪些实现方式无界队列为什么危险这些问题几乎是面试必问。我整理了一张速查表给正在准备面试的朋友参考问题核心要点Queue和List的区别List关注数据本身Queue关注数据流转顺序和节奏add/offer、remove/poll的区别异常 vs 特殊值有界队列场景差异明显循环队列空和满的判断队空head tail队满(tail 1) % capacity headPriorityQueue底层实现二叉堆最小堆入队上浮、出队下沉O(log n)阻塞队列和非阻塞队列区别阻塞队列满/空时线程挂起非阻塞队列返回特殊值或CAS重试线程池队列怎么选有界ArrayBlockingQueue可控无界LinkedBlockingQueue有OOM风险如何实现延迟队列用DelayQueue或PriorityQueue按执行时间戳排序6. 从Queue接口到实际工程的选型心法6.1 不同业务场景的队列选型建议做技术选型时不要看什么火就上什么而是要根据业务特点来决定。我自己总结了一套比较实用的选型逻辑分享出来供参考。如果是单机内的异步解耦比如日志采集、异步通知、任务积压缓冲首选JUC阻塞队列。具体选哪个主要看三点需不需要有界、需不需要排序、对吞吐量的敏感度。不要求排序就用有界LinkedBlockingQueue或ArrayBlockingQueue要求排序就用PriorityBlockingQueue要延迟执行就用DelayQueue。如果是分布式系统间的消息传递就需要引入消息队列中间件了。这里要理解一个关键差异JUC阻塞队列是JVM内存内的队列进程崩溃数据就没了消息中间件的数据可以持久化做的好的产品能保证消息不丢。选用MQ的标准是跨进程、跨机器、需要消息持久化、需要消费确认机制四个条件满足两个以上就应该考虑引入中间件。如果是线程池的工作队列那就看任务的特点。短任务高频场景用SynchronousQueue直接交付任务量大但希望保持稳定线程数的用有界队列需要任务按优先级执行的用PriorityBlockingQueue。重点永远是一定要设置一个有界的条件让系统在自己失控之前给出信号而不是默默吃掉所有内存然后崩溃。6.2 队列监控与容量规划的实战经验队列用起来了监控必须跟上否则和裸奔没两样。我负责过的系统里队列相关的监控指标一般包括这几项队列当前深度、入队速率TPS、出队速率TPS、offer失败次数、poll返回null次数、队列满触发阻塞的平均等待时间。队列深度是最核心的指标。如果深度长期处于增长趋势说明消费者的处理速度跟不上生产者的生产速度。这时候的应对策略有几种增加消费者实例、优化消费者处理逻辑、在生产者侧做流量控制实在不行就要考虑放弃部分非关键任务。我处理过一次线上事故消费者线程因为外部依赖超时集体阻塞队列深度从几百飙升到几十万监控告警触发后才及时发现就是因为队列深度指标配了告警阈值。容量规划方面有一个经验值可以参考队列容量的上限建议设置为一分钟内生产量的两倍左右。比如你的系统峰值时每分钟产生1万条消息队列容量设在2万左右比较合理。太大浪费内存太小又容易频繁触发满队列。这不是绝对的不同业务差异很大但作为一个起点比拍脑袋定个1000或100万都要靠谱。6.3 我自己踩过的三个队列相关的坑写到最后分享三个真实踩坑经历。第一个是用ArrayList当队列用。当时为了省事直接用ArrayList做FIFO取元素的时候remove(0)数据量小的时候没啥感觉数据量一上来性能直接爆炸——因为每次remove(0)都要把数组里所有元素往前搬一位O(n)的操作频繁执行整个服务CPU被打满。后来换成ArrayDeque问题立刻消失。第二个是生产者消费者模型里用了无界队列。消费者线程挂了一个生产者还在拼命往队列里塞数据等到发现时内存已经用了好几个GGC把CPU占满了整个服务处于半瘫痪状态。恢复过程也相当狼狈先停流量再重启服务才慢慢缓过来。从那以后我写任何并发代码队列一律有界。第三个是PriorityQueue的Comparator写错了。当时给一批任务按优先级排序Comparator里返回的值算反了结果高优先级的任务一直排在最底下低优先级的任务先被处理差点酿成线上事故。这个坑提醒我用优先队列一定要写单元测试验证出队顺序不能只依赖代码审查。队列这个东西表面上是一组接口和实现类实际上背后是计算机科学里最经典的数据结构思想——先进先出、缓冲、削峰填谷、异步解耦。把Queue接口吃透不仅是多掌握一个工具更是建立系统思维的一个很好的起点。
返回列表