如果你打开任何一个外卖平台的订单处理系统,或者看了看操作系统的进程调度器,再或者瞄一眼 Redis 里那个经常被用来做异步任务的 List,你会发现最核心的排队结构,就是那个在大学课本里看起来平平无奇的队列(Queue)。
数据结构里的队列,定义和操作都非常简单:先进先出,FIFO。但这东西一旦落到真实场景,从线程池的阻塞队列选型,到 Spring Boot 里用 Redis Stream 拉取消息,再到消息队列重复消费问题,水比想象中深得多。这篇文章我不打算按教科书目录给你念一遍,而是从“为什么队列会有这么多变种”这个角度切入,把顺序队列、循环队列、链式队列、优先队列、阻塞队列、延迟队列、单调队列,以及它们背后的工程场景串起来讲。适合正在复习数据结构与算法的同学,也适合那些在项目里用了消息队列但一直没搞明白内部原理的工程师。
1. 队列不只是“排队”:从餐厅叫号到系统内核
1.1 什么是队列:先来后到的公平性
队列这个词,英文叫 Queue,你把它类比成餐厅门口取号排队的人就行。先到的人先拿号,先叫到号,先坐下吃饭,后来的人只能排在队伍后面。计算机里的队列就是这个逻辑,数据从队尾(rear)进入,从队头(front)离开,谁先来谁先走,不允许插队。
这个“不让插队”的特性,在计算机世界里有很现实的价值。比如打印机任务队列,如果后提交的任务可以先打印,那前面的人辛辛苦苦排了半小时队,结果被后面的人插队了,这谁受得了。再比如操作系统的 CPU 调度,多个进程同时就绪,如果采用先来先服务(FCFS)策略,队列就是最直观的数据结构。
队列最基本的操作只有几个:入队(enqueue),把元素放到队尾;出队(dequeue),把队头元素取走;判空(isEmpty);查看队头元素(peek/front)。这些操作的时间复杂度,无论是数组实现还是链表实现,都能做到 O(1)。这也是为什么队列在系统底层被大量使用的原因之一——快,且行为可预测。
1.2 队列家族:从基础结构到变种的演进
很多人学队列,学到数组实现、链表实现就停了,然后去做两道 LeetCode 题,觉得完事了。但真实情况是,队列在工程上已经演化出了一整个家族,每一个变种都是为了解决一类特定问题:
- 循环队列(Circular Queue):解决数组队列“假溢出”问题,用取模运算把数组头尾相连,是环形缓冲区的核心。
- 链式队列(Linked Queue):用链表节点存储,没有容量限制,适合长度不确定的场景。
- 双端队列(Deque):头尾都能插入和删除,比普通队列更灵活,Java 里的
ArrayDeque、LinkedList都是典型实现,工作窃取算法也依赖它。 - 优先队列(Priority Queue):出队顺序不按入队时间,而按优先级,底层通常是二叉堆。
- 阻塞队列(Blocking Queue):队列满时入队操作会阻塞,队列空时出队操作会阻塞,天生就是生产者-消费者模型的好搭档。
- 延迟队列(Delay Queue):元素到指定延迟时间后才能取出,常用于订单超时关闭、定时任务调度。
- 单调队列(Monotonic Queue):队列内部元素保持单调递增或递减,用来解决滑动窗口最值问题。
你在面试里看到的“用两个栈实现队列”“队列和栈的区别”这些八股题,其实只是第一层。真正有区分度的考点,全在这些变种和工程应用上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个数组模拟的坑:顺序队列与循环队列
2.1 顺序队列的假溢出问题
用数组模拟队列,最直白的做法是申请一个固定大小的数组,然后维护两个指针:front 指向队头,rear 指向队尾的下一个位置。入队时把元素写到 rear 位置,然后 rear++;出队时把 front 指向的元素返回,然后 front++。
听起来没问题,但跑几下就出事了。假设数组容量是 5,你连续入队了 a、b、c、d,再连续出队 a、b,此时 front=2, rear=4,数组下标 0 和 1 的位置空了,但你再想入队 e 时,rear 已经等于数组容量 5,判断队列已满,无法写入。可实际上前面还有两个空位置,这种情况就叫“假溢出”。
很多初学者在这里卡住,觉得数组队列就是这么笨。其实不是数组的问题,是你要么得考虑数据搬移,要么就得上循环队列。数据搬移的思路类似于动态数组的扩容缩容,出队时把所有元素往前移动,把 rear 更新为 rear-front,这样空间利用是好了,但出队操作退化成了 O(n)。频繁出入队的场景下,这个开销不可接受。
2.2 循环队列:取模运算解决空间浪费
循环队列的思路很有意思:把数组当成一个首尾相连的圆环,当 rear 到达数组末尾时,如果数组开头有空位,就让它继续往后绕回下标 0。用取模运算实现:rear = (rear + 1) % capacity,front = (front + 1) % capacity。
这样一来,判断队满的条件需要仔细设计。如果用 front == rear 判断队空,那队满时会遇到一个问题:当队列真的被填满时,rear 绕一圈后会和 front 重合,和队空条件完全相同,无法区分。所以常见的做法是牺牲一个数组存储单元,当 (rear + 1) % capacity == front 时认为队满,也就是说最多只能存 capacity - 1 个元素,始终留一个空格子做区分。
除这种牺牲一格空间的做法外,还有两个思路:一是额外维护一个 size 变量记录当前元素个数,入队 size++,出队 size--,用 size == capacity 判满;二是加一个 flag 标记位,记录最后一次操作是入队还是出队。工程上很多环形缓冲区选择第一种方案,逻辑更直观,而且可以精确知道队列长度,不用自己拿 (rear - front + capacity) % capacity 去算。
2.3 工程里的环形缓冲区:音频数据和日志系统的常客
循环队列在工程中最经典的存在就是环形缓冲区(Ring Buffer)。一个典型的场景是音频采集:声卡持续不断地产生数据,应用层可能来不及立刻处理,这时候需要一个中间缓冲区暂存音频样本。如果每次来一个样本就写入一个普通队列,出队时还要搬移数据,性能完全顶不住。环形缓冲区通过两个指针和固定大小的内存块,实现 O(1) 的读写,数据从头到尾覆盖式写入,读走之后空间自动释放。
另外一个真实场景是日志系统。如果日志直接写入磁盘,高频日志下磁盘 I/O 会成为瓶颈。很多高性能日志库的做法是:先把日志写入内存环形缓冲区,后台线程再从缓冲区批量刷盘。Redis 的主从复制缓冲区、Linux 内核的环形键盘缓冲区,本质上也是一样的结构。
提示:面试里问到“为什么循环队列要浪费一个空间”,不要只说“为了区分空和满”,要补一句“如果不牺牲这个空间,就只能用计数器或标志位来区分,工业实现两者都有”。能说出这句话,面试官就知道你真正理解了这个设计取舍。
2.4 循环队列的代码骨架
以 C 语言为例,一个标准的循环队列核心逻辑是这样:
c复制#define MAX_SIZE 6
typedef struct {
int data[MAX_SIZE];
int front; // 队头下标
int rear; // 队尾下标的下一个位置
} CircularQueue;
void initQueue(CircularQueue *q) {
q->front = 0;
q->rear = 0;
}
int isEmpty(CircularQueue *q) {
return q->front == q->rear;
}
int isFull(CircularQueue *q) {
return (q->rear + 1) % MAX_SIZE == q->front;
}
int enqueue(CircularQueue *q, int value) {
if (isFull(q)) {
return -1; // 队列已满
}
q->data[q->rear] = value;
q->rear = (q->rear + 1) % MAX_SIZE;
return 0;
}
int dequeue(CircularQueue *q, int *value) {
if (isEmpty(q)) {
return -1; // 队列为空
}
*value = q->data[q->front];
q->front = (q->front + 1) % MAX_SIZE;
return 0;
}
这里 MAX_SIZE 设为 6,但实际最多只能存 5 个元素,因为要留一格区分空和满。如果你用 size 计数方案,MAX_SIZE 可以存满 6 个,但每次入队出队都要维护 size,算是有得有失。
3. 链表队列与双端队列:什么时候别用数组
3.1 链式队列:无容量限制的弹性
如果数据的数量不确定,或者波动很大,链式队列比循环队列更合适。链式队列的每个节点包含数据和指向下一个节点的指针,队列维护两个指针:front 指向队头节点,rear 指向队尾节点。入队在 rear 后面挂新节点,出队从 front 摘除节点。
链式队列有一个容易写错的细节:什么时候需要单独处理。当队列为空时,front 和 rear 都是 NULL,出队要返回空;当队列只有一个节点时,出队后 front 和 rear 都要置为 NULL;当队列从空变为非空时,front 和 rear 都要指向新节点。每个边界条件漏掉一个,程序就会在特定情况下崩掉。
为什么有的教科书说不带头结点的链队列操作麻烦?因为插入删除时都需要判断 front 是否为空,代码里全是 if (queue->front == NULL)。带头结点的链队列,头结点的 next 指向实际队头,判空只需要看 head->next == NULL,插入和删除的统一性更好。我建议自己动手实现时直接带头结点,能省掉不少边界分支。
链式队列的代价是每个节点多存一个指针,内存占用比数组大,而且节点分散在堆上,CPU 缓存不友好。队列长度基本稳定、可以预估上限的场景,优先用循环队列;队列长度忽高忽低、无法预估上限的场景,用链式队列。
3.2 双端队列:头尾都能操作的“全能选手”
双端队列(Deque,全称 Double-Ended Queue)允许在队头和队尾两端插入和删除,在 Java 中 ArrayDeque 是底层用循环数组实现的双端队列,LinkedList 是底层用链表实现的双端队列。ArrayDeque 在大多数场景下比 LinkedList 性能更好,因为它对 CPU 缓存更友好,而且不需要为每个元素单独分配节点。
双端队列最重要的工程应用之一是实现“工作窃取”(Work-Stealing)算法。ForkJoinPool 中每个工作线程都维护一个双端队列,线程自己从队尾取任务,相当于栈的行为——后加入的任务先执行,利用局部性;其他空闲线程从队头“窃取”任务,相当于队列的行为——最早加入的任务被偷走。一个数据结构同时提供 LIFO 和 FIFO 两种访问模式,这就是双端队列的微妙之处。
双端队列在算法题里也高频出现,最典型的是“滑动窗口最大值”问题。单调队列的实现就需要一个双端队列,既能从队尾弹出元素,又能从队头弹出过期元素。
4. 变种才是重头戏:优先队列、阻塞队列、延迟队列
4.1 优先队列:不按先来后到,按优先级出队
优先队列(Priority Queue)打破了 FIFO 的规则:入队顺序不重要,出队时优先级最高的先出来。底层结构通常是二叉堆,堆又分大顶堆和小顶堆。大顶堆最大值在堆顶,小顶堆最小值在堆顶。
堆的插入和删除都涉及到“上浮”和“下沉”操作。插入时把新元素放到数组末尾,然后不断和父节点比较,如果比父节点优先级高就交换位置,直到满足堆的性质;删除堆顶时,把数组末尾元素移到堆顶,然后不断和子节点比较,选优先级更高的子节点交换,下沉到合适位置。这两个操作的时间复杂度都是 O(log n)。
Java 里的 PriorityQueue 默认是小顶堆,传入自定义 Comparator 可以实现大顶堆。Redis 中的有序集合(ZSet)虽然本质是跳表,但也能当优先队列用:score 作为优先级,ZRANGEBYSCORE 取出指定分数范围的元素。如果只是需要“每次从一堆任务里取最紧急的”,用优先队列比每次全量排序高效得多。
4.2 阻塞队列:生产者和消费者的“缓冲区”
阻塞队列(Blocking Queue)在普通队列基础上增加了阻塞能力:队列空时,消费者线程调用 take() 会被阻塞,直到有生产者写入;队列满时,生产者线程调用 put() 会被阻塞,直到消费者取走元素。这种机制天然实现了线程间通信和解耦,不需要自己写 wait/notify。
Java 工程里最常见的三个阻塞队列分别是:
| 实现类 | 底层结构 | 是否有界 | 特点 |
|---|---|---|---|
ArrayBlockingQueue |
循环数组 | 有界 | 初始化时必须指定容量,公平性可配置 |
LinkedBlockingQueue |
单向链表 | 默认无界,可指定容量 | 吞吐量通常高于数组实现,但存在更多内存分配开销 |
SynchronousQueue |
无缓冲 | 不存储元素 | put 后必须等待 take,实现“直接交接” |
线程池 ThreadPoolExecutor 的选择就依赖这些特性。比如核心线程数不够用,任务要先进入阻塞队列排队:
java复制new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000)
);
如果用无界队列,maximumPoolSize 就形同虚设,因为队列永远不会满,新任务永远进队列,不会创建额外线程。用有界队列时,如果队列满了,会触发拒绝策略。这些细节,面试八股文中经常把“队列选择”单独拎出来问。
ForkJoinPool 里的阻塞实现就更复杂,每个线程有自己的双端队列,头尾双向操作。这是阻塞队列和双端队列思想结合的产物。
4.3 延迟队列:到点了才能取
延迟队列(Delay Queue)的元素带有一个过期时间,只有时间到了才能被取走。Java 的 DelayQueue 内部用优先队列实现,按延迟时间从短到长排列,take() 时先检查队首元素的延迟时间,未到期就等待。
业务上最常见的场景是订单超时关闭。用户下单后 15 分钟未支付,系统要自动关闭订单。如果每次用定时任务全表扫描订单表,数据量大了之后效率极低。用延迟队列:订单创建成功后放入队列,设置 15 分钟延迟时间,消费者线程阻塞在 take() 上,一旦有订单到期,立刻取出来处理,精确且高效。
另一个场景是缓存过期清理。每次缓存写入时,同时向延迟队列放入一个“清理任务”,延迟时间为过期时间。队列消费者收到任务后删除对应缓存,比定期扫描缓存更精确。
Redis 实现延迟队列的常见方式是使用 ZSet,把执行时间戳作为 score,定时轮询取 score <= 当前时间 的元素。虽然不完全是同一数据结构,但思想一脉相承。
5. 从 Redis 到消息中间件:队列思想的工程落地
5.1 Redis 里的队列全家桶
Redis 作为内存数据库,因为数据结构和操作丰富,经常被用来搭轻量级队列。最朴素的做法是用 List 实现队列:LPUSH 生产消息,BRPOP 消费消息。BRPOP 是阻塞版本,没有消息时消费者会阻塞等待,避免空轮询浪费 CPU。
但要小心,List 队列至少有两个问题:一是消息可能重复消费,二是没有消费者组的概念,多个消费者同时 BRPOP 时,一条消息只会被其中一个消费者拿到,如果业务需要广播给所有人,就无能为力。
Redis Stream 是 5.0 引入的更专业的队列数据结构,核心命令包括 XADD 追加消息、XREAD 读取消息、XGROUP CREATE 创建消费者组、XREADGROUP 以消费者组形式读取消息。它解决了刚才说的两个问题:消费者组可以多实例并行消费同一批消息,每个消费者拿到不同消息;每条消息有唯一的 ID,消费后需要 XACK 确认,未确认的消息会留在 Pending Entries List(PEL)中,可以重新消费。
Spring Boot 里用 Redis Stream 拉取队列消息,通常这样写:
java复制@StreamListener("order-stream")
public void onOrderMessage(OrderMessage message) {
// 处理订单消息
}
底层通过 StreamMessageListenerContainer 自动调用 XREADGROUP,以消费者组的方式从 stream 中读取消息并自动 ack。默认的 autoAck 行为可能会丢消息,如果你的业务不能容忍丢失,需要把 autoAck 设为 false,手动确认。
5.2 消息队列重复消费问题:为什么队列越可靠,你越要防重
几乎所有主流消息中间件都承诺至少一次投递(at-least-once),这意味着消费者可能收到重复消息。要么是生产端重试时重复发送,要么是消费端处理完但 ack 响应超时,消息被再次投递。
解决重复消费问题,光靠消息队列本身是不够的,消费端必须做幂等设计。最常用的方法是给消息加唯一业务 ID,消费时先查数据库判断是否处理过:
sql复制INSERT INTO msg_record (msg_id, content)
VALUES (#{msgId}, #{content})
ON DUPLICATE KEY UPDATE msg_id = msg_id;
利用数据库唯一索引去重,重复插入会因为主键冲突而失败,从而跳过处理。也可以用 Redis 的 SETNX 做去重标记,但要注意设置合理的过期时间,太短会放过重复消息,太长会白白占用内存。
另一个经验是:消费逻辑尽量设计成天然幂等。比如“把订单状态更新为已支付”,执行一次和重复执行多次,结果一样。但“给用户账户增加 10 元余额”这样的操作就不是天然幂等,必须加唯一请求号和去重机制。
5.3 冷热分离与数据库里的“队列影子”
热搜里有一个词很有意思:“mysql 底层原理 队列 冷热分离”。虽然数据库不会直接用队列存储数据,但很多架构设计里会把“数据变更”当成消息,通过队列做冷热数据的异步迁移。比如订单表数据量巨大时,把近 30 天的高频热数据放在 MySQL,把历史冷数据归档到对象存储或分析型数据库。归档操作由一个消费者从队列中拉取订单变更事件,异步完成搬迁。
这种模式和队列的“解耦”本质有关:上游业务不需要关心数据最终被存到哪里,只需要把变更事件写入队列;下游消费者可以独立扩展,随时调整冷热分离的策略。如果没有队列,业务代码里直接写死冷热数据路由逻辑,后期改一次策略就要动一次业务主链路。
MySQL 内部的 redo log 刷盘、binlog 同步,虽然没有直接叫“队列”,但用的是类似的 FIFO 缓冲思想。理解队列,其实是在理解计算机系统里无处不在的“生产-消费-缓冲”模型。
6. 单调队列:面试高频的非典型队列
6.1 从暴力解到堆,再到单调队列
单调队列(Monotonic Queue)不是一个独立的“新增结构”,而是一种队列使用技巧:让队列内部的元素保持单调递增或递减。最经典的题目是“滑动窗口最大值”:给定一个数组和窗口大小 k,每次窗口向右滑动一位,求每个窗口内的最大值。
暴力法对每个窗口扫描一次,时间复杂度 O(nk)。用一个大小为 k 的大顶堆维护窗口内元素,可以优化到 O(n log k)。但还有更优的 O(n) 解法,就是单调队列。
单调队列的思路是:用一个双端队列保存“窗口内可能成为最大值的元素下标”。队列内部元素对应的数组值从队头到队尾是单调递减的,因此队头永远是当前窗口的最大值。每次滑动窗口时做三件事:
- 队列不为空且队头元素已经滑出窗口(
deque.peekFirst() < i - k + 1),弹出队头。 - 新元素入队前,把队尾所有小于等于新元素的值全部弹出,因为在新元素过期之前,这些较小值永远不可能成为窗口最大值。
- 新元素入队,队头就是当前窗口最大值。
这其实是“谁更强谁留下,弱的直接淘汰”的思想。
6.2 代码实现与复杂度分析
Java 实现:
java复制public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) {
return new int[0];
}
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>(); // 存下标
for (int i = 0; i < n; i++) {
// 弹出已经滑出窗口的队头
while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 弹出队尾所有小于等于当前值的元素
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
// 当前元素入队
deque.offerLast(i);
// 窗口成形后,记录结果
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
每个元素最多入队一次、出队一次,所以整体时间复杂度 O(n),空间复杂度 O(k)。相比大顶堆的 O(log k),单调队列在滑动窗口这类固定连续区间问题上优势非常明显。
6.3 单调栈和单调队列,别记混了
很多初学者学到“单调栈”和“单调队列”时容易混淆。单调栈用于解决“找某个元素左边或右边第一个比它大/小的元素”这类问题,比如柱状图中最大的矩形、接雨水;单调队列用于“固定长度滑动窗口内求最大/最小值”这类问题,比如滑动窗口最大值、可见山峰对数量。
区别在于:单调栈只在数组遍历方向的一端维护单调性,元素入栈时弹出破坏单调性的栈顶;单调队列需要双端维护,既要从队尾弹出破坏单调性的元素,又要从队头弹出过期元素。单调栈适用“探测前一个更大/更小值”,单调队列适用“窗口内最值随窗口移动更新”。
7. 八股文之外,我对队列的几点实在经验
回到最开始那个问题:数据结构之队列为什么值得花这么多篇幅去写?因为它是少数几个直接从生活经验映射到计算机世界的结构,门槛低,但天花板很高。我在实际项目中踩过几个坑,分享出来供你参考。
第一个坑是选型时忽略容量约束。开发消息推送服务时,一开始用无界队列缓存待发送任务,结果上游接口突然抖动,任务积压了几百万条,内存差点被打爆。后来改成有界阻塞队列,队列满了直接触发降级策略,系统反而更稳了。队列容量不是拍脑袋定的,要结合生产速率、消费速率和最大可接受延迟反推。
第二个坑是忘记处理消费失败的消息。如果消费逻辑抛异常,很多队列框架会把消息重新放回队列,如果这条消息本身有问题,就会反复消费,形成死循环。建议消费失败时先判断是“临时失败”还是“永久失败”,临时失败可以重试,永久失败要进入死信队列或直接记录日志人工处理。
第三个坑是测试时只测了 happy path。队列是异步的,你很难通过简单的单元测试发现时序问题。比如消费者处理速度慢于生产者,队列会持续积压;或者消费者先启动,生产者后启动,有些队列实现会在无消费者时丢弃消息。写队列相关代码时,一定要做压力测试和故障注入。
说回学习路径:如果你是刚开始接触数据结构,建议先手写一遍循环队列和链式队列,把入队、出队、判空、判满四个操作写熟;然后做 2~3 道经典题,比如用队列实现栈、用栈实现队列、滑动窗口最大值;最后再去看线程池和消息中间件的源码,把阻塞队列的真实用法和数据结构里的概念对应起来。这样学下来,你对队列的理解就不再只是“先进先出”这四个字,而是能看到它在一整个系统里扮演的缓冲、解耦、削峰的角色。我个人最大的体会是,数据结构学到后面不是知识点的堆叠,而是学会在合适的场景选择合适的东西,队列就是那个你永远绕不开、又永远能玩出新花样的基础结构。
