1. 先说清楚:栈和队列到底是什么东西
数据结构这个东西,很多初学者一开始就把自己绕晕了。我当年学的时候也这样,书翻了好几遍,严蔚敏那本C语言版都快翻烂了,链表、树、图一个个背得滚瓜烂熟,但一到问“这玩意儿到底拿来干嘛”就卡壳。尤其是栈和队列,名字听着特别抽象,什么先进后出、先进先出,上课时觉得自己懂了,回头写代码又不知道往哪儿用。
后来我慢慢想明白了一个道理:栈和队列不是“某种具体的技术”,而是两种极其基本的数据组织规则。它们不负责存储什么类型的数据——存整数、存对象、存消息都行——它们真正定义的是数据的出入顺序。栈规定了“后到的先走”,队列规定了“先到的先走”。就这么两句话的事,但这两条规则就是整个计算机世界里无数系统和算法的地基。
如果你去看招聘要求,栈和队列几乎是所有技术岗笔试里避不开的考点,从软考到算法面试,从消息队列到线程池,底层都跟这两个基础结构有关。栈的经典场景像函数调用、浏览器后退、表达式求值;队列的经典场景像打印任务排队、线程池任务调度、消息推送。你平时点的外卖、刷的视频、敲的代码,背后不知道跑着多少个栈和队列。
这篇内容我就按照自己做了多年技术、带过不少新人的经验,把这个话题完完整整拆开讲一遍。不管你是刚学数据结构的在校生,还是想补基础的自学党,又或者是工作中突然发现自己对某些框架原理说不清楚的开发者,这篇文章的定位就是帮你把栈和队列彻底搞透,并且知道它们在真实项目里到底是怎么被用起来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈:后进先出,一种“反悔”的智慧
2.1 从一摞盘子理解栈的核心规则
想象你家里厨房有个盘子收纳架,你洗完一个盘子就往上一放,要用的时候从最上面拿。后放上去的盘子,永远先被拿走。这就是栈的全部规则:后进先出(LIFO, Last In First Out)。
这个规则有两个关键动作:
- 入栈(push):把新元素放到栈顶。
- 出栈(pop):把栈顶元素取走。
还有一个常用的读取动作叫取栈顶(peek/top),只看看栈顶是谁,但不把它拿走。
为什么说栈是一种“反悔”的智慧?因为栈天然适合处理需要“回退”的场景。你写代码的时候,函数A调用了函数B,B执行完必须回到A刚才继续往下走的位置——这个“回去”的动作,靠的就是栈。浏览器里你从页面1跳到页面2再跳到页面3,点返回按钮能一级级退回去,也是因为浏览器把你的访问历史压进了一个栈。你编辑文档时按Ctrl+Z撤销,本质上也是一步步把之前的状态从栈里弹出来。
所以栈这东西,说白了就是帮计算机记着“我之前走到哪了,接下来该怎么回去”。它的结构和这种需求精确匹配,这就是为什么几乎所有编程语言的函数调用机制都在用栈,而不是用一个链表或者其他什么结构。
2.2 栈的底层实现:数组栈与链表栈怎么选
栈是一个规则层,它本身不规定底层必须用什么存储。用数组能做栈,用链表也能做栈,各有取舍。
数组栈是一种非常常见的实现方式。你可以预分配一块连续内存,用一个int类型的top变量记录栈顶位置。入栈时检查容量是否足够,不够就扩容;出栈时直接把top减一就行。数组栈的优势是内存连续、CPU缓存友好,访问速度极快,而且实现简单。缺点是容量固定,频繁扩容有开销,插入删除如果发生在栈顶则没这个问题,因为永远只动尾部。
链表栈则是用节点一个个串起来,每次入栈就是在链表头部插入一个新节点,出栈就是删除头节点。它的优势是没有容量上限,天然动态扩展,不存在扩容拷贝的问题,适合无法预估最大深度的场景。缺点是每个节点需要额外的指针空间,而且节点在内存里分散分布,遍历时对缓存没那么友好。
实操中,绝大多数通用场景我会建议直接用数组栈,因为效率高、代码简单,而且大部分情况下你完全知道或者可以估算出最大栈深度。只有在栈深度不可预估、可能极其大的场景下才考虑链表栈。比如自己写一个函数调用栈的模拟器,深度可能上千上万,这时链表栈更稳妥。
关于栈顶的方向,数组栈需要注意一个细节:你可以让栈顶在数组头部,也可以让栈顶在数组尾部。如果栈顶在头部,入栈出栈就要移动整个数组,代价是O(n);如果栈顶在尾部,入栈出栈就是纯O(1)。所以数组栈的正确姿势永远是:下标越大的位置越靠近栈顶,新元素从尾部追加。
2.3 手写一个数组栈:一次到位的代码范例
这里我用C语言写一个最朴素的数组栈,因为C语言没有内置的高级容器,反而能把底层逻辑看得更清楚。虽然现在写业务代码大概率用Java、Python或Go,不会用C去实现栈,但原理是通用的。
c复制#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef struct {
int *data;
int capacity;
int top; // 栈顶下标,空栈时 top = -1
} ArrayStack;
void stackInit(ArrayStack *s, int capacity) {
s->data = (int *)malloc(sizeof(int) * capacity);
s->capacity = capacity;
s->top = -1;
}
bool isEmpty(ArrayStack *s) {
return s->top == -1;
}
bool isFull(ArrayStack *s) {
return s->top == s->capacity - 1;
}
void push(ArrayStack *s, int value) {
if (isFull(s)) {
printf("栈已满,无法入栈: %d\n", value);
return;
}
s->data[++(s->top)] = value;
}
int pop(ArrayStack *s) {
if (isEmpty(s)) {
printf("栈为空,无法出栈\n");
return -1;
}
return s->data[(s->top)--];
}
int peek(ArrayStack *s) {
if (isEmpty(s)) {
printf("栈为空\n");
return -1;
}
return s->data[s->top];
}
void stackFree(ArrayStack *s) {
free(s->data);
s->data = NULL;
s->capacity = 0;
s->top = -1;
}
有几个细节必须提醒:
- top初始化为-1意味着空栈。入栈时先让top加1再写入数据,出栈时先取数据再把top减1。
- 判断空栈不能用top==0,因为top==0说明栈里还有一个元素,位置在0号下标。
- 扩容策略我没有写进去,实际项目中如果要自动扩容,一般按1.5倍或2倍扩容,和动态数组的扩容策略一致,推荐1.5倍的原因是可以复用之前释放的内存块,扩容后新容量小于已释放的相邻内存,内存分配器更可能直接复用旧空间。
- 使用完记得free,C语言中内存泄漏就是这么来的。
如果你用Java,直接使用java.util.ArrayDeque来当栈用,比Stack类更推荐。Stack类继承了Vector,所有方法都加了synchronized锁,单线程下白白浪费性能。ArrayDeque底层是循环数组,无锁,性能更好,而且接口也更现代,有push、pop、peek方法,语义清晰。
Python中可以用列表模拟栈,列表的append就是入栈,pop()就是出栈,这已经是Python中最常用的栈写法,不需要额外引库。
2.4 栈的经典应用:函数调用、括号匹配与表达式求值
栈的应用场景,我挑三个最典型的展开讲。
第一个是函数调用链。这是栈最底层的应用,但平时很多人看不见。当程序执行一个函数时,系统会把函数的参数、局部变量、返回地址压入系统栈,函数返回时再从栈顶弹出恢复现场。递归为什么容易栈溢出?就是因为每递归一层就往系统栈里压一层数据,递归太深,栈空间就爆了。这个概念理解透之后,你能解释很多实际问题,比如为什么无限递归会崩、为什么递归可以转换成循环。
第二个是括号匹配。检查一个字符串里的括号是否成对且嵌套正确,比如({[]})合法,([)]不合法。思路很清晰:遍历字符串,遇到左括号就入栈;遇到右括号时,从栈顶弹出一个左括号,看类型是否匹配,不匹配则直接判定非法;最后如果栈不为空,说明有左括号没被闭合,也判非法。这个算法是所有编译器、IDE、代码编辑器的语法高亮和错误检查的底层逻辑之一,你写的每一行代码,编译器和编辑器都在用栈帮你检查括号问题。LeetCode上的第20题“有效的括号”,就是这个思路,属于面试中的高频题。
第三个是表达式求值。中缀表达式(就是我们平时写的1 + 2 * 3)人类看着舒服,但计算机处理起来很麻烦,因为要考虑运算符优先级和括号。一个典型方案是先把中缀表达式转成后缀表达式(逆波兰式),比如1 2 3 * +,再对后缀表达式用栈求值。转后缀时,栈用来暂存运算符;求值时,遇到数字入栈,遇到运算符就弹出两个操作数计算,再把结果入栈。整个过程中栈的“后进先出”刚好满足了运算符优先级的要求。你可能会想,现在谁还手写表达式求值?确实不多了,但这套东西在数据库查询优化器、规则引擎、脚本解释器里依然广泛存在。
2.5 栈的一个常见坑:递归转循环时怎么手动模拟栈
很多人在刷算法题时会遇到“递归转迭代”的需求,因为递归虽然代码简洁,但在深度较大时容易爆栈,而且有些环境下不允许递归过深。把递归改成循环,最直接的办法就是手动维护一个栈来模拟系统调用栈。
以二叉树的中序遍历为例,递归版写起来很顺:
c复制void inorder(TreeNode *root) {
if (root == NULL) return;
inorder(root->left);
printf("%d ", root->val);
inorder(root->right);
}
改成用栈模拟后,关键是理解递归调用发生的时机。中序遍历是先一路往左走,走到底才开始访问节点,访问完再处理右子树。这正是栈的“先压进去,等时机到了再弹出来”的流程:
c复制void inorderIterative(TreeNode *root) {
TreeNode *stack[1000];
int top = -1;
TreeNode *cur = root;
while (cur != NULL || top != -1) {
while (cur != NULL) {
stack[++top] = cur; // 先入栈,不访问
cur = cur->left; // 走到最左边
}
cur = stack[top--]; // 弹出最左侧节点
printf("%d ", cur->val); // 访问
cur = cur->right; // 再处理右子树
}
}
这个例子能很好说明栈“暂存现场、后到先处理”的价值。很多所谓“进阶算法”其实都是这种思路的延伸。我第一次写这个迭代版中序遍历时,也卡了很久,后来发现只要把递归执行时系统栈的行为画一遍,就通了。
面试和考试里常犯的错误是:递归的入栈顺序和实际处理顺序没搞清,导致弹出的节点时机不对。我的经验是先画调用树,标出每个节点何时被访问,再对比栈操作序列,不要凭空想象。
3. 队列:先进先出,一种“排队”的公平
3.1 队列的核心语义与应用场景
队列比栈更好理解,因为它就是我们日常排队的映射。你去银行取号,先来的人先办业务;你去食堂打饭,排在前面的人先打到饭。队列的核心规则是先进先出(FIFO, First In First Out)。
队列的四个核心操作:
- 入队(enqueue):新元素加入队尾。
- 出队(dequeue):队头元素被取走。
- 查看队头(front/peek):看看队头是谁。
- 判空(isEmpty):队列是否为空。
队列的意义在于公平和顺序。许多系统需要保证请求是按到达顺序被处理的,否则就会出现“后来者插队”的不公平现象,甚至会引发逻辑错误。比如打印任务,假设文档A在文档B之前发送给打印机,结果B打印出来了而A还卡着,这就乱了。
真实场景里队列无处不在:操作系统进程调度队列、网络数据包缓冲、多个微服务之间传递消息用的消息队列(Kafka、RabbitMQ等)、线程池里的阻塞队列、外卖平台里的订单排队、服务器对数据库写入请求的排队……可以说凡是涉及“先来先处理”的系统设计,背后都站着一个队列。
从基础的数据结构面试角度来看,队列本身实现并不难,难的是它与其他概念组合后出现的变形——循环队列、双端队列、优先队列、阻塞队列、延迟队列等,这些在框架源码里非常常见。
3.2 顺序队列的隐患:假溢出与循环队列
队列听起来很简单,但如果你用数组实现队列,立刻会碰到一个经典问题,叫假溢出。
假设数组长度是5,队头front=0,队尾rear=0。先入队5个元素,rear到5,队列满了;再出队5个元素,front到5,队列空了。但这时你会发现rear==5,如果还想入队,代码可能直接提示“队列已满”。可实际上数组前5个位置全是空的啊,只是front和rear都跑到数组末尾了——这就是假溢出,明明有空间,却用不了。
解决假溢出的方案有好几种:
- 出队时移动元素,把后面的元素往前挪。麻烦在于每次出队都是O(n)操作,效率太低。
- 使用循环队列,这是最经典的方案,让数组的头尾衔接起来,逻辑上构成一个环,用取模运算
(rear + 1) % capacity来移动下标。
循环队列的实现思路是这样:队列为空的条件是front == rear,但队列满的条件如果也是front == rear,就会和“空”混淆。所以一般会牺牲一个存储单元,让队列满的条件变成(rear + 1) % capacity == front。也就是说,数组中最多只能存capacity-1个元素。如果你实在想把最后一个位置也用上,就需要额外加一个size字段来记录当前元素个数,用size判断空满,但绝大多数教材和实际编码中,牺牲一个单元是最简洁的做法。
用Java写一个循环队列的核心逻辑大致如下:
java复制class CircularQueue {
private int[] data;
private int front;
private int rear;
private int capacity;
public CircularQueue(int k) {
capacity = k + 1; // 多留一个空位,用于区分空和满
data = new int[capacity];
front = 0;
rear = 0;
}
public boolean enQueue(int value) {
if (isFull()) return false;
data[rear] = value;
rear = (rear + 1) % capacity;
return true;
}
public boolean deQueue() {
if (isEmpty()) return false;
front = (front + 1) % capacity;
return true;
}
public boolean isEmpty() {
return front == rear;
}
public boolean isFull() {
return (rear + 1) % capacity == front;
}
}
3.2.1 循环队列判空判满为什么难
如果你第一次接触循环队列,一定会在“判空判满”上栽一次跟头。原因在于数组是线性的、有边界的,而队列的语义是逻辑上的环。当front和rear相等时,可能是空,也可能是满。必须人为指定一个判别规则。
教材上最常见的方案是“牺牲一个存储单元”。这个方案的理解难点在于,你会觉得“明明还有一个位置能放元素,为什么非留着不用”?本质原因是,如果不留这一个位置,那么“front == rear”就有两种可能,程序无法通过一个条件同时区分空和满,除非额外维护size。如果维护size,逻辑当然更直观,代码也更易读,但会多一个字段的内存与维护成本。工程里两种方案都有人用,如果代码是给新手看,我倾向于用size字段,因为更好懂;如果追求极致的省内存且逻辑成熟,就用牺牲一个单元的方法。
3.3 链表队列:另一种更灵活的实现
链表队列比数组队列更容易理解,不需要处理假溢出,也不需要取模运算。每次入队在链表尾部插入节点,每次出队删除链表头部节点。为了让入队操作达到O(1),需要维护一个tail指针指向队尾,否则每次入队都遍历到尾部,复杂度会变成O(n)。
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct {
Node *front;
Node *rear;
} LinkedQueue;
void enqueue(LinkedQueue *q, int value) {
Node *newNode = (Node *)malloc(sizeof(Node));
newNode->data = value;
newNode->next = NULL;
if (q->rear == NULL) {
q->front = q->rear = newNode;
return;
}
q->rear->next = newNode;
q->rear = newNode;
}
int dequeue(LinkedQueue *q) {
if (q->front == NULL) return -1;
Node *tmp = q->front;
int value = tmp->data;
q->front = q->front->next;
if (q->front == NULL) q->rear = NULL;
free(tmp);
return value;
}
链表队列的优点是理论上没有容量限制,入队出队都是O(1),不存在“满了还要扩容”的问题。缺点是每个节点需要额外的指针内存,节点分散导致缓存命中率较低。实际工程中,如果队列长度波动很大,用链表队列更灵活;如果队列上限可以预估并且追求高吞吐,用循环数组队列更好。
3.4 优先队列:谁说队列一定得先进先出
标准队列是严格的先进先出,但现实中有一种场景并不希望绝对公平,而希望优先级高的先处理。比如医院急诊,不是所有病人都按先来后到,危重病人必须优先抢救。操作系统里的进程调度也是,高优先级的进程应该获得更多CPU时间。这种需求被抽象成了优先队列(Priority Queue)。
优先队列的出队顺序不取决于入队时间,而取决于元素优先级。通常用**堆(Heap)**实现,堆是一种特殊的完全二叉树结构,最大堆保证堆顶是最大元素,最小堆保证堆顶是最小元素。入堆和出堆的时间复杂度都是O(log n),比每次都排序要高效得多。
拿Java的PriorityQueue举例:
java复制PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(5);
pq.offer(1);
pq.offer(3);
while (!pq.isEmpty()) {
System.out.println(pq.poll()); // 输出 1, 3, 5
}
注意PriorityQueue默认是小顶堆,也就是说poll()每次返回最小的元素。如果你想实现“最大先出”,需要传入自定义比较器。
优先队列在真实项目中的身影非常多——定时任务调度器、Dijkstra最短路径算法、Top K问题、合并K个有序链表、线程池中任务按优先级调度等。可以说优先队列是“队列”这个大家族里综合含金量最高的一个变种,值得花最多时间研究。
3.5 双端队列、阻塞队列和延迟队列:不同领域的队列变体
除了基础队列和优先队列,工程中还经常遇到三种队列变体,我逐个说明。
双端队列(Deque),就是队头和队尾都可以入队出队的队列。它比普通队列灵活得多,既能当栈用(只从一端进出),也能当普通队列用(一端入另一端出)。Java里的ArrayDeque、Python里的collections.deque都是双端队列,底层用循环数组实现,两端操作都是O(1)。在做滑动窗口类算法、实现撤销/重做双向功能、缓存LRU淘汰策略时,双端队列非常方便。
阻塞队列(Blocking Queue),是并发编程下的产物。普通队列在多线程环境下会有竞争问题,如果多个线程同时入队出队,数据可能错乱。阻塞队列在入队和出队操作上增加了“阻塞”语义:当队列为空时,出队线程会阻塞等待,直到有元素入队;当队列满了时,入队线程会阻塞等待,直到有空间腾出。Java的LinkedBlockingQueue、ArrayBlockingQueue就是这样的实现,它们天然缓解了生产者-消费者模型的线程同步问题,不需要你手写wait/notify逻辑。在线程池中,等待执行的任务就是放在一个阻塞队列里的,这也是搜索引擎热词里会出现“线程池的阻塞队列选择”的原因。
延迟队列(Delay Queue),在普通队列基础上增加了“延迟时间”的概念:元素不能立刻被取出,必须等到指定延迟时间到达之后才对外可见。Java中的DelayQueue底层使用优先队列,按照每个元素设定的到期时间排序,最先到期的元素排在队头,但只有在到期后poll()才能取到它。延迟队列非常适合实现订单超时关闭、缓存自动过期清除、定时任务的延迟执行等业务场景。比如你在电商平台下单但没付款,系统规定15分钟后自动取消订单,这种需求就可以用一个延迟队列来实现——把订单塞进延迟队列,15分钟后到期,一个消费者线程取出来执行关单逻辑。
这类带各种限定条件的队列变体是校招和社招面试的重头戏,你不光要知道FIFO的基础规则,更要知道工业界在什么样的场景下会打破FIFO规则、用什么思路去扩展,这是“数据结构”和“系统设计”之间的一座桥梁。
4. 栈和队列的对比、转化与应用场景实战
4.1 一张表看清栈和队列的差异
栈和队列最核心的差异就是数据的进出顺序,别的差异大多由这一点派生出来。我整理了一张表方便你对比:
| 对比维度 | 栈 | 队列 |
|---|---|---|
| 英文 | Stack | Queue |
| 核心规则 | 后进先出(LIFO) | 先进先出(FIFO) |
| 插入操作 | push,压入栈顶 | enqueue,进入队尾 |
| 删除操作 | pop,弹出栈顶 | dequeue,离开队头 |
| 访问位置 | 只能访问栈顶 | 队头可出,队尾可入 |
| 典型场景 | 函数调用、括号匹配、表达式求值、浏览记录后退 | 任务调度、消息缓冲、打印队列、进程排队 |
| 底层常用实现 | 数组或链表 | 循环数组或链表 |
| 主要变体 | 单调栈 | 循环队列、优先队列、双端队列、阻塞队列、延迟队列 |
理解这张表之后,你在选择数据结构时就有了一个基本判断:需要“最近的状态”就选栈,需要“最早的等待”就选队列,需要“最紧急的元素”就选优先队列,需要“两端都能操作”就选双端队列。
4.2 经典面试题:用两个栈实现队列,用两个队列实现栈
面试里很爱考一类题型:用另一种数据结构模拟当前数据结构。这看似是“变魔术”,实则是考察你对两者内部运行机制的掌握程度。我在面试时也常拿这个当敲门砖。
用两个栈实现队列的思路:
一个栈专门负责入队,叫stackIn;另一个栈专门负责出队,叫stackOut。入队时直接往stackIn压栈。出队时,如果stackOut不为空,直接从stackOut弹栈;如果stackOut为空,先把stackIn里的所有元素依次弹出并压入stackOut,然后再从stackOut弹栈。
为什么这个逻辑能成立?因为栈是后进先出,元素从stackIn弹出来再压入stackOut,顺序会被倒转一次。倒转之后,原本先入队的元素就跑到了stackOut的栈顶,出队时先弹走的自然就是最早入队的元素了。这一步是整个题目的灵魂。
java复制class MyQueue {
Deque<Integer> in = new ArrayDeque<>();
Deque<Integer> out = new ArrayDeque<>();
public void push(int x) {
in.push(x);
}
public int pop() {
if (out.isEmpty()) {
while (!in.isEmpty()) out.push(in.pop());
}
return out.pop();
}
}
用两个队列实现栈的思路:
两个队列q1和q2,入栈时把元素放入非空的那个队列(或者始终放入q1),然后保证q1中除了最后一个元素外全部移到q2,最后从q1取出那个“最后一个元素”作为出栈结果。更简洁的一种实现是:入栈时先把元素放入空队列,再把另一个队列的全部元素移入这个队列,保证新元素永远在队头。出栈直接从队头出队即可。这种实现的核心是把“队尾”转成“栈顶”,通过倒腾队列元素实现的。
这类题看似“非主流”,但能很好地帮你把栈和队列的操作过程在脑子里过一遍,所以刷题阶段我非常推荐亲自动手写一遍,而不是只看答案。
4.3 栈在算法题中的进阶:单调栈
很多人在学栈的时候觉得它简单,真到刷LeetCode中等题时又会碰到一类叫单调栈的技巧,瞬间傻眼。单调栈不是一种新的数据结构,而是在栈的基础上维护栈内元素的单调性:要么从栈底到栈顶单调递增,要么单调递减。
单调栈最常见的应用是求解“左边或右边第一个比当前元素大/小的元素”问题。比如LeetCode 739题“每日温度”,给一个温度数组,要计算每一天要等多少天才能等到更高温度。暴力解法是O(n^2),两层循环嵌套,数据量一大就超时。单调栈可以把复杂度降到O(n)。
思路是:遍历温度数组,维护一个单调递减栈(栈底到栈顶温度递减)。遍历每个新温度时,只要栈不为空并且当前温度大于栈顶下标对应的温度,就说明栈顶元素已经找到了“右边第一个更高温度”,可以弹出并计算结果;然后把当前下标入栈。所有遍历结束后还留在栈里的下标,说明后面没有更高温度了,结果就是0。
这个案例的价值在于让你看到:栈不仅仅是“存数据”的容器,更可以承载一种动态比较与淘汰的逻辑。单调栈里弹出元素的过程,实际上是在为之前等待的元素找到一个答案。这种“来一个新元素,立刻处理掉一批老元素”的场景,用栈做非常顺手。
4.4 队列在真实系统里的经典画面:从线程池到消息队列
如果你觉得“队列”只存在于教材的练习题里,那你就大错特错了。我前面提过线程池和消息队列,这里再展开讲一讲。
线程池是后端开发里极其常见的一个组件。线程的创建和销毁代价很大,所以需要一堆线程常驻,任务来了分配给空闲线程执行。但任务到达速度和线程处理速度不可能完全一致,所以中间需要有一个缓冲地带,任务先排队,线程处理完一个再从队列里取下一个。这个缓冲地带就是一个阻塞队列。Java内置的ThreadPoolExecutor就允许你传入不同的阻塞队列实现,用ArrayBlockingQueue还是LinkedBlockingQueue,直接影响任务排队策略和拒绝策略。关键字“线程池的阻塞队列选择”在热词里出现,说明这就是实际开发里大家都会遇到的困惑。
消息队列更是队列思想的规模化应用。在微服务架构中,服务A不需要直接调用服务B,而是把一条消息发到消息队列里,服务B自己去队列里拉取或者订阅。这样A和B就解耦了:A不用关心B是否在线,B的负载波动也不会直接拖垮A。Kafka、RabbitMQ、RocketMQ、Redis Stream本质上都是把“先进先出”这个基础队列思想,加上持久化、高可用、分区、消费组等机制,扩展成了完整的中间件系统。热词里出现“消息队列重复消费问题”、“spring boot redis stream 如何拉取队列消息”,说明很多人实际上是在工作中一边用消息队列一边补数据结构的基础知识。
数据结构不是脱离实际的理论玩具。你理解了最朴素的FIFO队列之后,再看Kafka的分区顺序、看RocketMQ的重复消费与幂等设计、看Redis的Stream消费组机制,都会有一种“原来如此”的感觉,因为它们的底层规则,依然是在回答“谁先谁后、如何公平、失败了怎么办”这些最基础的问题。
5. 栈和队列的代码实现细节与避坑指南
5.1 C语言版链表实现中的内存管理
用C语言写链表栈或链表队列,最重要的就是内存分配与释放的对称性。分配一个节点使用malloc,释放一个节点使用free,每个malloc都必须对应一次free。但很多人写链表队列时会在删除节点那里出问题——先free了节点,然后又访问了节点的next指针;或者先移动了front指针,结果没有保存旧节点的地址,导致free不了。
我的习惯是:
- 用临时指针
tmp保存要删除的节点地址。 - 更新队列头指针到下一个节点。
- 最后再
free(tmp)。
顺序不要反。如果你直接free(q->front)再q->front = q->front->next,那第二行代码就是在访问一块已经释放的内存,行为未定义,可能偶尔能跑通,但迟早会崩,而且崩溃的位置往往离问题代码很远,排查起来很痛苦。
另一个容易被忽略的问题是链表队列出队最后一个元素时,要记得把rear指针也置空。只更新front会导致rear变成野指针,下次入队时再访问q->rear->next就直接段错误。我当年写这个bug时,调试了整整一个晚上。
5.2 动态扩容时的容量计算
如果用数组实现栈或队列,扩容就是一个绕不开的话题。Java的ArrayList容量不够时会扩容到原来的1.5倍,这个数值不是随手写的,而是Java工程师在内存利用率和扩展开销之间做的平衡。扩容因子太小(比如1.1),会导致频繁扩容,每次扩容都要把旧数据拷贝到新数组;扩容因子太大(比如2倍以上),会浪费较多内存。
以栈为例,假设当前容量是10,已满,扩容到20。需要做的是:
- 分配一块能容纳20个元素的新内存。
- 把旧数组里前10个数据复制过去。
- 释放旧内存。
- 更新容量字段。
如果是循环队列,扩容时不能简单地按顺序复制,因为队列里的元素在物理上可能并没有从数组头开始排。扩容时必须重新计算每个元素的真实位置,然后按“从队头到队尾”的逻辑顺序搬到新数组的前段,否则front和rear的相对含义会错乱。很多循环队列的实现里,扩容部分代码比基本操作还长,原因就在于此。
所以我的建议是:如果项目里队列容量波动不大,优先用定长循环队列并评估最大容量,避免扩容带来的麻烦;如果必须动态扩容,请一定写单元测试覆盖“扩容前队列已满”和“扩容后元素顺序不改变”这两个场景。
5.3 栈的深度溢出的现实场景
函数调用栈的大小在主流操作系统上通常默认是8MB左右,具体可以查,但不管哪个平台,都有限制。Java中线程栈大小可以通过-Xss参数调整,Linux下也可以用ulimit -s查看和修改进程栈大小。
在实际项目的代码评审中,我看到许多人写递归时完全没考虑深度问题。比如遍历一个深度未知的JSON树结构,直接用递归去处理。如果这个JSON嵌套深度达到几千层甚至上万层,系统栈就会溢出,程序直接崩溃。这种情况下,无论递归写法多简洁多优雅,都不如改成显式栈的迭代写法更安全。
具体实现要点我已经在2.5节里介绍了,核心就是自己管理一个栈结构,在堆上分配空间,绕开系统栈的深度限制。堆空间比系统栈大得多,你就有了更大的操作余地。
5.4 并发环境下的栈和队列
写并发代码时,普通的栈和队列都不是线程安全的。Java中的Stack虽然加了锁,但它是粗粒度地把所有方法都锁住,并发量上来之后锁竞争严重。更好的选择是使用ConcurrentLinkedQueue、LinkedBlockingQueue等JUC包下的并发容器,它们内部用CAS或者显式锁做了精细化的并发控制。
如果你的场景只是单线程读、单线程写,那么ConcurrentLinkedQueue也许就够了;如果是典型的生产者-消费者模型,且需要阻塞等待语义,那么LinkedBlockingQueue或ArrayBlockingQueue是最常见的选型。这里又回到热词“线程池的阻塞队列选择”上了:当线程池的核心线程全部繁忙、队列也满了的时候,线程池会执行拒绝策略,这个“满了”的判断逻辑跟队列的容量直接相关,所以你在初始化线程池时就要想清楚队列容量设多大、满了之后是丢弃任务还是调用方自己处理。
我自己做项目时的经验是:默认使用有界阻塞队列,容量设为核心线程数的数倍到数十倍之间,具体数值取决于任务的平均执行时长和可接受的排队延迟。宁可让任务队列短一点触发拒绝策略,也不要让任务无限堆积最终拖垮内存。很多人初学时喜欢用无界队列图省事,结果一旦上游流量突增,内存就被任务占满了,整个应用被OOM干掉,这种事情在线上是特别惨痛的教训。
6. 手写几个工程中真正会用的队列场景
6.1 用队列实现一个简单的任务调度器
假设你需要在程序里定期执行一批任务,但同一时刻最多只能执行两个任务,其他任务排队等待。用阻塞队列可以轻松实现:
java复制import java.util.concurrent.*;
public class TaskScheduler {
private final ExecutorService executor;
private final BlockingQueue<Runnable> taskQueue;
public TaskScheduler(int poolSize, int queueCapacity) {
this.executor = new ThreadPoolExecutor(
poolSize, poolSize, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queueCapacity)
);
this.taskQueue = new LinkedBlockingQueue<>();
}
public void submitTask(Runnable task) {
taskQueue.offer(task);
}
public void start() {
while (true) {
try {
Runnable task = taskQueue.take();
executor.submit(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
这个示例虽然简单,但代表了生产者-消费者模型的核心逻辑:任务生产者把任务放入队列,消费者从队列中取出并执行。在真实项目里,生产者可能是接收HTTP请求的Web服务,消费者可能是处理订单的工作线程。
6.2 用延迟队列实现订单超时关闭
订单超时关闭是电商系统里一个高频问题。最简单的实现是定时扫描数据库,把超过15分钟未支付的订单全部关掉。这个方案在小数据量下没问题,但数据量大了之后,全表扫描成本太高,而且做不到精确定时。
用延迟队列可以做到“某个订单到了指定时间才被处理”。Java里有DelayQueue,元素需要实现Delayed接口。如果你不想引入额外的消息中间件,用这种方式做订单超时关闭,代价很小、机制清晰。当然,生产环境中更可靠的做法是使用RocketMQ等消息队列的定时消息,或者Redis的过期监听机制。但理解DelayQueue的原理,能帮助你判断上述方案各自适用在什么场景。
6.3 栈在“撤销/重做”功能里的使用
编辑器或绘图软件的“撤销/重做”功能,用双栈简直完美。一个是撤销栈undoStack,一个是重做栈redoStack。每次操作后,把操作记录压入undoStack,清空redoStack。撤销时,从undoStack弹出最近操作并执行反向逻辑,同时把该操作压入redoStack。重做时,从redoStack弹出操作并重新执行,同时压回undoStack。
在设计这个功能时,要注意操作的粒度。如果你的操作粒度是“光标移动一下”就压栈一次,那么用户按一下撤销可能只回退了一个字符,体验很差。通常的做法是合并相邻的输入操作,比如用户在200毫秒内输入的内容算同一次操作。类似这样的细节,才是干活时真正需要琢磨的地方。
7. 学习路径建议:从理论到刷题再到框架源码
7.1 在校生和自学者怎么安排顺序
如果你现在刚接触数据结构,我的建议是按这条路线走:
- 先把栈和队列的基础操作写熟。用C语言写数组版和链表版的栈、队列、循环队列,至少各写一遍。
- 把括号匹配、用队列实现栈、用栈实现队列、循环队列这几道题做掉。这些题能帮你在代码层面建立直观感觉。
- 去看
java.util.ArrayDeque和java.util.concurrent.LinkedBlockingQueue的源码,了解Java官方容器是怎么处理扩容、判空、并发锁的。源码虽然比教科书长,但注释和变量命名都很规范,学习价值极高。 - 结合实际项目去理解,比如消息队列里的分区消费组、Redis的Stream消费、线程池的阻塞队列选型,这时候你会有一种“把知识串起来了”的顿悟感。
7.2 栈和队列与算法面试的关系
算法面试里,栈和队列单独出现的题目难度不算高,但它们经常作为其他复杂算法的基础组件。比如:
- DFS深度优先搜索,底层就是栈的思想,递归写法用的是系统栈,迭代写法就是手动栈。
- BFS广度优先搜索,本质就是用队列一层层往外扩展,所以二叉树的层序遍历就是用队列实现的。
- 滑动窗口最大值问题,常用双端队列实现单调队列来求解。
如果你栈和队列的基础不牢,学图论和搜索算法时会非常吃力。反过来,栈和队列掌握得扎实,你在理解BFS/DFS、拓扑排序、状态机、以及很多框架的异步处理模型时都会轻松很多。
7.3 项目实践中如何“顺便”巩固栈和队列
在工作中,刻意去写一个栈结构的机会可能不多,但理解这些思想的机会到处都是。我在做接口设计时,有时需要实现请求的“先入先出”排队,这时自然会想到队列;在解析嵌套JSON或实现一个简单的配置模板引擎时,又会用到栈的思想来处理嵌套层级。关键是你要有意识地把问题往数据结构上“套”,而不是闷头写业务代码。
比如你在排查一个消息队列重复消费问题时,如果不知道队列消费的本质是“从Broker取出一条消息并删除或提交offset”,你可能很难想到幂等方案应该如何设计。这个问题的根源依然在队列机制上——你已经取走了消息,但还没提交确认,程序挂了,这条消息会再次被拉取。如果懂队列的FIFO语义和ack机制,排查方向就会清晰很多。
8. 经验总结与避坑心得
做技术这么多年,踩过不少数据结构相关的坑,这里把最值得说的几条整理出来:
第一条:别在容器选型上偷懒。 有些同学写LeetCode习惯了用Python列表一把梭,到工作中用Java也喜欢用ArrayList模拟一切。短期看是省事了,但一旦遇到并发场景,ArrayList不是线程安全的,用起来就会出问题。正确的做法是明确场景是“栈”“队列”还是“列表”,然后把对应的容器选对。
第二条:判空永远比判满更重要。 很多bug都是从空结构里取元素导致的。我的习惯是在栈和队列的pop、dequeue方法里,第一行就先做判空检查。如果你在写框架或底层模块,不要相信调用方“肯定会先判空”的承诺,你自己的方法就该具备防御能力。
第三条:画图是理解栈和队列最好的工具。 无论你写的是循环队列还是递归转迭代,多画几遍“元素进出的过程图”,比盯着代码发呆有效得多。栈和队列是逻辑规则非常强的结构,人脑对图的理解远比对抽象文字的理解来得快。
第四条:重视语言底层库提供的现成结构。 不要重复造轮子。Java已经提供了ArrayDeque,Python有collections.deque,C++有std::stack和std::queue。你真正需要做的是搞清楚它们的应用场景、时间复杂度和线程安全性,而不是自己再实现一遍。但教材里的手写实现还是要写,那是为了理解原理,两者并不矛盾。
第五条:所谓“基础”,不是背概念,而是能解释现象。 比如栈溢出为什么通常出现在递归里?为什么消息队列能实现削峰填谷?为什么线程池要配置有界队列?这些问题如果你能脱口而出,说明你的数据结构不是背来的,而是真的理解了。
栈和队列是数据结构里最基础也最容易被轻视的两个结构。很多人觉得它们太简单,转头去研究红黑树、跳表、B+树这些“听起来很高级”的结构,结果连线程池为啥要用阻塞队列都答不清楚。我个人始终认为,把最简单的东西吃透,比囫囵吞枣地学一堆复杂概念,对日常工作更有帮助。希望这篇文章能帮你在“会用”和“理解”之间架起一座桥。
