第一次认真地啃队列,还是在数据结构课上,教材里冷冷一行“先进先出,尾部插入,头部删除”,当时觉得这结构简单得可以闭着眼睛写了。真正被教育,是在后来的项目里:线程池配好了却不扩容,消息队列重复消费存了一堆脏数据,数组模拟的队列用着用着“变小”。每一个问题往回翻,根因都落到同一个地方——队列。这篇我就顺着自己踩坑的顺序,把队列从实现原理讲到变体应用,再讲到并发环境下的阻塞队列、跨进程的消息队列,帮你真正把这些零散的知识串成一条线。
1. 从生活里的排队到进程里的排队:队列到底在约束什么
1.1 队列的核心限制:只留两个出入口
想象一下食堂打饭的场景。大家排成一条队,先来的人先打,后来的人只能排在队尾。新来的人不能插到中间去,打完饭的人也不会绕回队首再来一份。队列这个数据结构,就是把这种生活规则搬进了计算机:它只允许你在尾部添加元素(入队,也叫 push),在头部移除元素(出队,也叫 pop),中间的元素既看不见也摸不着。你要想拿到队列里的第 5 个元素,没办法像数组一样直接用下标取样,必须把前面 4 个全部排空。
这个“只留两个出入口”的约束,听上去很死板,但它带来一个极其重要的工程价值:确定性。生产者不用担心消费者抢了还没轮到的东西,消费者也不用操心会不会漏掉前面的任务。两边只管在自己那一端做动作,节奏不一致也没关系,队列会自动缓冲。这也解释了为什么后来几乎所有线程池、任务分发系统,都愿意用队列做中间层——它天生自带“解耦”气质。
1.2 和栈放在一起看,队列的公平性就出来了
学队列不可能不撞上栈。栈是后进先出,最新来的人反而最先被处理。最典型的例子是浏览器的后退按钮,你点开的页面被压进栈里,后退时最后打开的页面先出来。栈适合解决需要“回溯”的问题,比如括号匹配、递归调用。
队列刚好反过来,先进先出,先来的任务先被处理。它传递的是一种公平:来得早的,机会就早。这种公平性在操作系统里的体现最直观——打印机任务队列、进程调度里的就绪队列,都在用 FIFO 保证任务不会因为“来得晚”而被活活饿死。而栈就没有这种承诺,后到的任务反而享受到了优先服务,存在饥饿风险。
所以你在判断一个场景该用栈还是队列时,不要只看操作方式,要问自己一句:晚到的任务,应不应该有更高优先级?如果保证不了,那先进先出的队列通常更稳妥。
1.3 队列的天然代价:想插队和想同时处理,它都做不到
队列也不是万能的。它最明显的短板,就是处理速度完全看队头的脸色。队头任务如果特别耗时,后面所有任务都得跟着等。这种“队头阻塞”现象不光出现在数据结构里,在网络通信、磁盘调度里也很常见。
另一个不足是它默认只按顺序处理,不考虑优先级。拿消息队列来说,普通订单消息低优,支付回调却等着被及时处理,这时候普通队列就完全不合适了。所以后面才衍生出优先队列、延迟队列、双端队列这些变体。你完全可以这样理解:所有队列的变体,本质都是对“必须先进先出”这条死磕规则的局部妥协,妥协程度不同,适合的场景也不一样。这也是我为什么建议大家先把普通队列想透再去看阻塞队列,底层逻辑一通,上层全是排列组合。
2. 队列的实现怎么选:链表、数组和那个绕圈跑的循环队列
2.1 链表队列的代码很简单,但“分配内存”这件事不该被忽略
先上一段最直接的链表队列实现,我习惯用 Python 表达思路,因为不用被语言的边界条件绕晕:
class QueueNode: def __init__(self, val): self.val = val self.next = None class LinkedQueue: def __init__(self): self.head = None # 队首 self.tail = None # 队尾 self.count = 0 def push(self, val): node = QueueNode(val) if self.tail: self.tail.next = node self.tail = node if not self.head: self.head = node self.count += 1 def pop(self): if self.head is None: raise IndexError("empty queue") val = self.head.val self.head = self.head.next if self.head is None: self.tail = None self.count -= 1 return val逻辑确实不复杂,两个指针,一个指向队首,一个指向队尾。入队就是在尾指针后面挂新节点,出队就是把头指针往后挪一格。这段代码最容易被忽略的是内存分配:每 push 一个元素,都要创建一次节点;节点的存活时间完全跟着队列走。在长时间运行的服务器程序里,频繁地申请和释放小块内存,会产生大量内存碎片,GC 或内存池压力都会放大。所以链表队列虽然理论操作无限、不怕扩容,但在高吞吐低延迟场景里并不是默认首选。
2.2 数组队列的假溢出,是一切循环队列的出发点
用数组实现队列,第一反应是:开一个足够大的数组,用一个 top 指针记录队尾位置,再用一个 head 指针记录队首位置。push 进来就沿数组往后排,pop 出去就把 head 往后挪。这个朴素方案在反复入队出队之后会露馅:head 不断往右走,最终数组的物理空间明明很大,队列却什么都塞不进去了。
我举一个具体例子。数组长度 m = 5,先 push 五个元素,此时 head = 0,rear = 4。然后 pop 掉最前面的两个元素,head = 2,队列的有效元素只剩三个,但 rear 已经顶到数组末尾了。你再想 push 第六个元素?无论 head 前面空着多少位置,rear 都前进不了。这就叫“假溢出”:队列没满,数组尾部却先满了。解决假溢出的标准方案,就是让 rear 和 head 在数组里绕圈走,头到尾循环移动,把数组的 0 号位置理解为 m 号位置的下一个位置,这就是循环队列。
2.3 循环队列用 rear+length 定位队首:完整推导
循环队列最常见的实现方式,是用两个指针 front 和 rear,配合“牺牲一个存储单元”来判断空或满。但还有一种实现,在很多教材习题里更吃香:用 rear 和 length 两个字段,不浪费数组空间,这就是你看很多题库里“假设以数组 q[m] 存放循环队列中的元素,同时以 rear 和 length 分别指示环形队列中的队尾位置和元素个数”这句话的来源。
推导其实不难,我把公式拆给你看:
rear 表示下一个新元素要写入的位置,范围 0 到 m-1,靠取模保证循环。
length 表示当前队列里元素的真实个数。
队首元素的位置 front,可以从 rear 和 length 反推出来:
front = (rear - length + m) % m
为什么加 m?因为 rear - length 可能算出负数,先补一个 m 再取模,保证结果落在合法下标范围内。
入队操作:
- 先判断 length == m,等于说明队列已满。
- 数组下标 rear 的位置写入新元素。
- rear = (rear + 1) % m。
- length++。
出队操作:
- 先判断 length == 0,等于说明队列为空。
- front = (rear - length + m) % m 算出队首位置。
- 取出 q[front]。
- length--。
我试一个例子验证。m = 5,初始 rear = 0,length = 0。连续入队 A、B,写入 q[0]、q[1],rear 变 2,length 变 2。此时要出队,front = (2 - 2 + 5) % 5 = 0,取出的就是 q[0],也就是 A,完全正确。再入队 C,q[2] = C,rear 变 3,length 变 2,再出队,front = (3 - 2 + 5) % 5 = 1,取到 q[1] = B。一切自洽。
这种实现有一个直观好处:判断空和满不需要比较 front 和 rear,直接看 length 是 0 还是 m。很多教材里那种牺牲一个槽位的做法,其实是为了避免存储 length 带来的额外空间,在低级语言里可以省。但在如今内存宽裕的环境下,用 length 反而更清晰,也更加不容易写错。
2.4 手写队列的价值只剩理论?未必
一说手写队列,很多人第一反应是“生产环境谁会自己写”。绝大多数情况下确实不会,Java 里可以直接用 ArrayDeque,C++ 有 std::queue,Python 有 collections.deque,Redis 里拿 List 也能当队列用。我自己也只在教学和比赛练习里手写过队列。
但理解实现细节决定的,是你在选型时能不能反推别人的行为。比如 ArrayDeque 为什么推荐用来做队列而不是 LinkedList?因为 ArrayDeque 底层是循环数组,内存紧凑、缓存命中率高,而 LinkedList 每个节点都是独立对象,链表结构的指针跳跃在大多数情况下都比不上数组连续内存。这些判断没法靠背标题获得,必须真的懂循环队列那一套逻辑。所以我建议哪怕不手写,至少把循环数组的公式推导完整走一遍,别只记住结论。
3. 变体里的新语义:双端队列、优先队列、单调队列
3.1 双端队列:给两头开权限后的世界
双端队列(Deque)是普通队列的直系亲属,区别在于它解除了头尾的限制:允许从头部插入、头部删除,也允许从尾部插入、尾部删除。理论上它一个结构就能兼顾栈和队列,加上能从两端操作,使用起来自由度明显更大。
工程上,双端队列最常见的应用是“滑动窗口”类算法和“撤销重做”类系统。撤销重做好理解:用户的操作历史存在双端队列里,撤销从尾部弹出,重做时又能从头部推回去。滑动窗口则需要配合单调队列使用,这个稍后专门展开。
用 Java 的 ArrayDeque,它其实就是个双端队列的循环数组实现。很多人拿它替代 stack 或 queue 使用,边界操作都是 O(1),比某些并发容器里还带锁的接口更适合单线程场景。需要注意的细节是,ArrayDeque 不允许存 null,因为源码里用 null 作为特殊标记判断队列是否为空,你要是把 null 当业务数据存进去,程序会直接报错,第一次用的时候容易踩。
3.2 优先队列:排序规则内置的“分类插队”
优先队列(PriorityQueue)已经不满足先进先出了,它允许高优先级的元素插到前面先被取走。底层结构通常用堆实现,Java 里是 PriorityQueue,C++ 里是 priority_queue,Python 里是 heapq。堆的插入和删除都是 O(logn),比普通队列的 O(1) 慢了一些,但它带来的“按优先级调度”能力,在很多场景里完全值得。
比较经典的应用是 Dijkstra 最短路径算法。朴素做法每次从所有未访问节点里找距离最小的节点,复杂度 O(V²),用优先队列维护候选节点,能压到 O(ElogV)。另一个典型是合并 K 个有序数组:把所有数组的头元素放进最小堆,每次弹出最小的,再从同一个数组里补充下一个,能稳定地以 O(nlogk) 完成合并。
这里有一个使用盲区:堆并不保证全局有序,它只保证堆顶极值,底层是一个数组模拟的完全二叉树,堆里的元素没有严格的线性顺序。所以千万别因为从优先队列里取出来的元素看起来“不够有序”就觉得实现出 bug 了,这是堆的正常表现。
3.3 单调队列:滑动窗口最大值的标准解法
单调队列这个名字,很多人在算法题里见过,实际思路可能一知半解。它本质是一个双端队列,但内部维护了额外的单调性规则。以滑动窗口最大值问题为例:数组 [1,3,-1,-3,5,3,6,7],窗口大小为 3,需要输出每个窗口内的最大值。
朴素解法是每个窗口都重新扫描一遍,复杂度 O(nk),n 和 k 一大就崩。单调队列的解法是:维护一个存数组下标的双端队列,让这些下标对应的数值在队列里保持单调递减。每当新元素进入窗口时,先从队尾弹出所有值比新元素小的下标,再把新元素下标放入队尾;然后检查队头下标是否已经滑出窗口,滑出就弹掉。这样队头永远是当前窗口的最大值,整个过程中每个元素最多进队列一次、出队列一次,总体复杂度是 O(n)。
from collections import deque def maxSlidingWindow(nums, k): dq = deque() res = [] for i, v in enumerate(nums): while dq and nums[dq[-1]] <= v: dq.pop() dq.append(i) if dq[0] <= i - k: dq.popleft() if i >= k - 1: res.append(nums[dq[0]]) return res为什么要把比自己小的元素从队尾弹走?因为只要这些旧元素还留在队列里,它们已经不可能成为后续窗口的最大值了。新元素值更大、下标更新,属于“全方位碾压”的候选,留着它们纯粹浪费时间。真正能和老元素竞争的,只有那些数值更大或者至少相等的“老资格”,它们在后续窗口中还有机会保留价值。想明白这一条,单调队列的直觉就建立起来了。
4. 并发环境下的队列:阻塞队列、无锁队列和线程池选型
4.1 阻塞队列的行为差异:满和空不再是返回错误,而是等待
普通队列在多线程环境下直接使用会出事,两个线程同时 push、同时 pop,会造成数据竞争。解决方式之一是加锁,给整段入队出队操作套一个互斥量,这是比较粗暴但稳定的做法。阻塞队列在此基础上加了一层语义:当队列为空时,消费者线程调用 take() 会被挂起,直到有生产者把数据放进来;当队列满时,生产者线程调用 put() 也会被阻塞,直到消费者腾出空间。
这种“不返回错误,而是原地等待”的机制,对程序员来说是个巨大的心智减负。你不需要在业务代码里反复写“队列满了,稍后再试”的循环,线程自己会睡、自己会醒。Java 的 BlockingQueue 接口下有很多实现,我列一张常用的表,方便对比:
| 队列实现 | 存储特性 | 阻塞行为 | 典型使用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界,基于数组固定容量 | 容量满时 put 阻塞,空时 take 阻塞 | 任务量可控的线程池、生产者消费者模型 |
| LinkedBlockingQueue | 默认无界,也可指定容量 | 无界时 put 几乎不阻塞,take 空时阻塞 | 后台异步任务队列 |
| SynchronousQueue | 零容量,不缓存数据 | put 必须等 take,直接交付 | 线程池需要快速拉起新线程 |
| DelayQueue | 无界 | 元素到期才可见,take 会等待到期 | 定时任务、延迟重试队列 |
这张表不是让背的,是让在真实场景里对照着挑的。我自己至少见过两次因为选错阻塞队列而导致的线上事故,一次是线程池用无界队列导致 OOM,一次是用 SynchronousQueue 后线程数一路涨到上限,后面细说。
4.2 线程池里的队列选择:它决定线程到底扩不扩
线程池的处理模型并不复杂:提交一个任务,先看核心线程池是否已经跑满。没满,直接开新线程执行;满了,任务就先扔进工作队列,等待空闲线程来取。当工作队列也满了,线程池才会考虑创建超出核心线程数的额外线程,直到达到最大线程数。
这个模型里,工作队列的类型直接决定了线程数策略。如果用了默认无界队列 LinkedBlockingQueue,队列永远不会满,线程池自然永远不会走到“创建额外线程”那一步。也就是说,就算你把 maximumPoolSize 配成 100,只要核心线程不够用,任务都会积压到队列里,实际并发数永远保持核心线程数。这种配置在任务量突然暴涨时特别危险,队列里的任务越积越多,内存被活活吃满,最后 OOM。出过事后我才意识到,无界队列只是表面安全,实际是把风险转移到了堆内存上。
反过来,如果用 SynchronousQueue,因为它不缓存在何任务,每个任务提交时如果没有空闲线程,立即尝试创建新线程,线程数很容易冲到 maximumPoolSize。这适合那些每个任务执行时间都很短、需要极低延迟的场景,但线程创建本身有开销,动不动几千个线程也扛不住。
更稳妥的选择通常是有界队列 ArrayBlockingQueue,配合合理的 corePoolSize 和 maximumPoolSize:核心线程处理不过来,任务先排队;队列排满,再开额外线程;额外线程也忙不过来,就会触发拒绝策略。这是一种“先缓冲、后扩容、再拒绝”的梯度机制,等于给了系统明确的呼吸节奏,比直接依赖无界队列靠内存兜底要可控得多。
4.3 C++ 原子操作与无锁队列:传得神,坑也多
再聊一个工程圈讨论很多的方向:无锁队列。核心思路是不用互斥锁,而是借助 CPU 提供的原子指令完成多线程对共享数据的同步。C++ 里的 std::atomic 把 CAS、fetch_add 这些能力包装成了跨平台 API。拿最经典的单生产者单消费者环形队列举例,两个线程各自维护自己的读写位置,只要确保写位置、读位置的更新是原子的,数据区域用内存序做同步,就可以做到一个非常轻量的 SPSC 队列,在音频处理、网络收包这类延迟敏感的场景里很有价值。
但无锁队列不是银弹。多生产者多消费者场景下,问题立刻变复杂:多个线程同时 CAS 同一个位置,可能出现 ABA 问题——某个位置的值从 A 变成 B 又变回 A,CAS 检查时以为没变过,实际中间发生了其他操作。处理 ABA 常用额外的版本号或标签来区分。更麻烦的是,无锁队列中弹出的节点不能立刻释放,因为其他线程可能还持有它的指针在进行 CAS,这就引入了内存回收问题。业界有不少方案,比如延迟回收、危险指针,都各有代价。多数情况下,一个设计良好的无锁队列比锁版本复杂好几个数量级,但性能提升并没有想象中显著,尤其在锁竞争本来就不强的场景里,锁的开销很小,反而稳定、容易调。
我现在的态度是:默认就用成熟并发库提供的队列,比如 Java 的 ConcurrentLinkedQueue 或 Disruptor 这种经过大量实践验证的组件。除非你确实在做超高并发、超低延迟的底层组件,并且愿意为复杂性和调试难度买单,否则没有必要自己造无锁队列。
5. 从进程内队列到消息队列:重复消费这个绕不开的话题
5.1 消息队列不是“更大的队列”:多了个 Broker 以后语义完全不同
把队列搬出进程,放到独立的中间件上,就变成了消息队列。常见的有老牌的 RabbitMQ、RocketMQ,也有很多人用的 Redis List、Redis Stream。搜索相关消息里出现过的 MSMQ、Windows 消息队列,属于比较早期的跨进程队列方案,在 Windows 生态里做过大量分布式任务分发,现在这些体系已经被更多云原生消息中间件替代。还有 PHP 项目里简单用 Redis 做队列的玩法,本质也是把 List 当成跨请求的临时队列用。
消息队列相比进程内队列,多了一个角色:Broker。生产者把消息发到 Broker,消费者从 Broker 拉取或订阅消息。这份中间层的引入带来了三项进程内队列不具备的能力:
- 持久化。进程退出、机器宕机,消息不一定丢,可以靠磁盘日志恢复。
- 异步解耦。生产者发出消息后不需要知道消费者是谁、在哪个机器上运行,直接返回。
- 分发和路由。一条消息可以被多个消费者按不同条件消费,实现广播、订阅、分组等模型。
但注意,这里的“队列”已经变了含义。一个消息中间件内部可能有队列模型、主题模型、分区模型,消费方式也有推和拉的区别,消息被消费后的状态跟踪从内存里的标志位变成了 broker 端的 ack 机制。你不能再把它简单理解成一个大内存队列,否则后面出问题根本不知道去哪排查。
5.2 重复消费常见的三个来源
消息队列最著名的一个坑就是重复消费。与其在网上搜一堆零碎答案,不如把重复消息的三个产生环节一次理清:
生产者重试导致重复发送。生产者发送消息时网络抖动,超时了,但消息实际上已经到达 broker。生产者不确定结果,自动重试了一次,于是同一条业务消息被发了两遍。
Broker 重新投递。消费者处理完消息后,还没来得及给 broker 回 ack,就因为进程崩溃、网络断开等原因断了连接。broker 收不到 ack,按照语义会判定消费失败,把这条消息重新投递给其他消费者。
消费者本地提交失败。有些框架先把消息取出来处理,处理完再提交 offset。如果处理完业务、提交 offset 前发生异常,重启后又会从旧 offset 开始重新拉取。
这三条链路里,除了第一条可以从生产者侧用幂等发送去规避,后两条在分布式环境下几乎无法彻底消除。你不能指望“框架保证不重复”,必须让消费者自己具备抵御重复消息的能力,这就是幂等。
5.3 幂等设计:消息队列实践里最该先想清楚的一件事
幂等的核心含义是:同一条消息处理多次,效果和处理一次完全一样。这个设计不是消息队列特有的,但它是消费端最重要的一个防御。
最稳妥也最常用的是业务幂等键。拿订单支付通知来说,每条消息带一个业务唯一标识,比如 orderId 加事件类型,消费端先拿这个唯一标识去数据库里做唯一索引插入,成功说明第一次处理,继续后续流程;如果插入报 DuplicateKey,直接判定重复,丢弃消息。代码逻辑是这样的示意:
-- 消费前先执行的幂等保护 INSERT INTO t_message_dedup (biz_id, handle_time) VALUES (?, NOW()) -- 如果收到 DuplicateKey 异常,说明这条消息已经处理过如果不想依赖数据库,Redis 的 setnx 可以做同样的事:
SET dedup_key 1 NX EX 3600第一次拿到 1,继续;返回 0,说明处理过。注意 key 的选择:最好用“业务单号+事件类型”组合,而不是直接用消息中间件的 messageId。因为消息中间件的 messageId 只能保证同一条消息自身唯一,但一个业务动作可能产生多条不同的消息,比如“订单创建”“订单状态变更”,它们是不同的消息,在业务层面却需要按同一单据去重。只用 messageId 会导致重复的整条消息能挡住,业务级重复却挡不住。
我踩过最直观的一次坑,就是只拿 messageId 做了幂等。后来业务系统重放历史消息,同一条订单状态变更消息被重发了多次,幂等表判断不出来,订单状态被反复覆盖,最后只能人工订正。从那以后,幂等键我统一用业务唯一维度组合,不再偷懒。还有一点提醒:如果消费逻辑本身存在“先查后写”的组合操作,务必要用锁或事务保证这两个动作的原子性,否则幂等键只能挡住并发写,挡不住并发读后重复判定的漏洞。
队列这个东西,看起来越简单,展开越深。从循环数组的下标推导,到线程池阻塞队列选择,再到消息队列的幂等,本质上都在回答一个问题:一批需要排队的数据,在各种边界条件下如何仍然保持有序、无冲突、不丢失。先把这根主线想明白,遇到具体队列组件时,就只剩下查文档的功夫了。