
栈和队列这两样东西说简单也简单说复杂也复杂。我见过不少同学能把数组、链表写得飞起一碰到栈和队列反而在概念上打转。问栈和队列的区别回答“一个后进先出、一个先进先出”就没了。但你要真问他函数调用栈怎么回溯、线程池为什么用阻塞队列、消息队列重复消费怎么处理他就答不上来了。这恰恰说明栈和队列不只是卷面上那两个名词它们是整个软件体系里最底层的骨架。这篇文章就围绕“定义与实现”这件事把栈和队列彻底拆开讲一遍从抽象定义到数组链表两种实现再到工程里的并发队列、消息队列、调用栈回溯最后把常见坑点也一起捋清楚。不管你是刚学数据结构的学生还是写业务写了好几年想回头补基础的后端或者是做嵌入式、音视频、底层中间件的人这篇内容都应该能吃下。1. 先理解栈的本质不只是后进先出1.1 栈的抽象定义与关键操作栈是一种限定仅在表尾进行插入和删除操作的线性表。这个概念很多教材上都写但“表尾”这个词容易把人绕晕。换成生活中的例子就特别直观食堂里一叠餐盘你拿走的是最上面那个别人还回来也放在最上面没人会从中间抽盘子。这个“最上面”就是栈顶插入叫入栈push删除叫出栈pop只看不取叫取栈顶peek。所有操作都发生在栈顶所以栈最重要的特性就是后进先出LIFO。之所以说“限定”是因为它和普通线性表的差异在操作自由度上。数组、链表你可以任意位置插入删除栈不行它把自由度砍掉换来了操作语义的确定性。这种确定性在计算机系统里价值极大函数调用、表达式求值、递归回溯都需要一种“先保存现场后恢复现场”的结构栈天然匹配。从时间复杂度看push、pop、peek都是O(1)这也是栈能成为系统级基础结构的重要原因。不管是数组实现还是链表实现只要定位到栈顶操作就是常数级的。还有一个容易忽略的点栈的容量是有限的。在嵌入式平台和C语言场景下栈溢出是一种非常常见的故障原因就在于操作系统给每个线程的栈空间是固定的递归太深或者局部变量太大都可能压爆它。后面我会详细说栈帧和调用栈这块理解了你才算真正明白栈在计算机里到底是怎么运作的。1.2 栈帧与程序运行时的“幕后栈”很多人学栈只知道做题却不知道写出的每一行代码都在跟栈打交道。程序运行时每一次函数调用系统都会在调用栈上分配一块区域这块区域叫栈帧Stack Frame。栈帧里装的是函数的局部变量、参数、返回地址以及上一层函数的栈底指针。栈帧的形成过程大致这样函数A调用函数B时先把参数压栈然后把返回地址压栈再保存当前栈底指针最后为新函数分配局部变量空间。函数B执行完毕后回收栈帧根据返回地址重新跳回函数A继续执行。这一套流程就是“栈帧形成过程”。调试的时候你看到的调用堆栈Call Stack其实就是当前函数一路往上追溯到main函数的所有栈帧记录。这里我要特别聊一下backtrace栈回溯也就是调用栈回溯。当程序崩溃或者需要定位问题时调用栈是最高效的线索。你在Linux下用gdb输入bt命令就能看到从崩溃点一直到main的完整调用链靠的就是逐帧读取保存的返回地址和栈指针。x86架构和ARM架构的回溯方式略有差异ARM常见的做法是用fp帧指针寄存器回溯但现代编译器经常做优化省略帧指针这时就只能靠调试信息和硬件的栈回溯机制。我做嵌入式调试时踩过坑release版本开了O2优化fp被优化掉了backtrace直接失效。解决办法是编译时保留调试符号或者禁掉omit-frame-pointer选项代价是代码体积变大。所以大家做嵌入式或者底层开发时一定要提前规划调试策略别等到线上出问题才后悔。还有个经典概念容易被搞混就是堆和栈。栈是系统自动分配和释放的速度极快但空间小堆是程序员手动申请释放的C/C空间大但分配速度慢还容易产生内存碎片。你定义了一个局部变量数组它在栈上你malloc出来的内存它在堆上。记住这张表就清晰了对比项栈堆分配方式系统自动分配手动申请malloc/new释放方式函数返回自动释放手动释放free/delete速度快相对慢空间大小通常MB级别可到GB级别容易出的问题栈溢出内存泄漏、内存碎片2. 从数组到链表栈的两种典型实现2.1 顺序栈实现与扩容问题栈的实现最简单粗暴的方式就是用数组这种叫顺序栈。核心就两个字段一块连续内存和一个指向栈顶的游标。我们用一个自顶向下的实现视角看这个问题——所谓自顶向下就是栈顶指针始终指向下一个可写入的位置。用Python写逻辑最清楚class ArrayStack: def __init__(self, capacity10): self.capacity capacity self.data [None] * capacity self.top 0 # top 指向下一个可写入位置 def push(self, value): if self.top self.capacity: raise OverflowError(stack overflow) self.data[self.top] value self.top 1 def pop(self): if self.top 0: raise IndexError(pop from empty stack) self.top - 1 return self.data[self.top] def peek(self): if self.top 0: raise IndexError(peek from empty stack) return self.data[self.top - 1] def is_empty(self): return self.top 0这段代码里有三个细节值得画重点。第一top初始值取0还是-1会直接改变push和pop的写法解题时如果两个方案来回切最容易在边界上出错。我建议选定一种方案写熟top0表示下一个空位先写再移动top-1表示当前栈顶元素先移动再写。二者不要混着用。第二扩容问题。Java里栈推荐用ArrayDeque本质就是一个可以自动扩容的数组。扩容的过程一般按1.5倍或2倍扩展然后整个拷贝数据。这个过程是O(n)的均摊下来push仍然是O(1)但如果你写实时性要求很高的代码就要避免频繁扩容。第三越界检查不能省。C语言里数组栈不检查栈满push越界直接写坏内存这类bug极难排查。我建议在调试版本里一定加断言。2.2 链式栈实现与适用场景链式栈就是用链表模拟栈入栈就是头插法出栈就是删头节点。为什么不用尾插因为单链表找尾节点是O(n)的而头插头删天然O(1)完美契合栈的操作语义。用C语言写一个简易的链式栈typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *top; // 栈顶指针指向第一个节点 } LinkedStack; void push(LinkedStack *s, int x) { Node *node (Node *)malloc(sizeof(Node)); node-data x; node-next s-top; s-top node; } int pop(LinkedStack *s, int *x) { if (s-top NULL) return -1; Node *tmp s-top; *x tmp-data; s-top tmp-next; free(tmp); return 0; }链式栈最大的好处是不用预先分配容量理论上无限扩展代价是每个节点多了一个next指针内存占用高而且malloc和free在频繁入栈出栈时会产生性能损耗。什么时候用链式栈场景通常是“无法预估最大深度”的时候。比如编译器做括号解析、遍历目录树这种递归深度不定的情况动态扩容的链式结构更稳。但如果是嵌入式MCU栈深度明确直接上数组还省了malloc的不确定性开销。说到底选哪种实现取决于你对“栈的最大深度”是否有准确预估。能预估数组不能预估链表要兼顾扩展性和速度就用动态数组自动扩容方案。3. 队列的定义与循环队列实现3.1 队列的抽象定义与变体队列是限定在一端插入、另一端删除的线性表。插入端叫队尾删除端叫队头操作天然满足先进先出FIFO。这个概念大家都懂我就不炒冷饭了。重点说说队列的几种变体因为工程中用到的几乎都是变体。双端队列是队列的“超级版”。它允许头尾两端都能插入和删除你可以把它同时当栈和队列用。Java里的ArrayDeque、C的deque都是这种结构底层实现是分段连续数组既兼顾随机访问性能又支持两端扩展。滑动窗口最大值这题最优解就是双端队列维护一个单调递减的双端队列每次窗口移动时队头出窗口元素队尾淘汰比当前元素小的元素复杂度O(n)。阻塞队列是并发编程里的核心角色。它在线程池、生产者消费者模型里用得最多。阻塞队列的不同之处在于队列满时入队操作会阻塞等待队列空时出队操作会阻塞等待。把线程安全、等待通知机制、容量限制这三件事合在一起就变成了一种非常强大的线程协作工具。优先队列和“先进先出”无关它按优先级出队底层是堆不是严格意义上的队列。但很多框架把它叫队列比如Java的PriorityQueue、DelayQueue。C的priority_queue也是堆。这点容易造成概念混滑你问一个人队列是FIFO还是LIFO他会选FIFO但优先队列却打破了FIFO。所以要记住优先队列是“带了队列壳子的堆”。3.2 顺序队列的“假溢出”与循环队列用数组实现队列最麻烦的是“假溢出”。假设数组长度是5rear指针一路往后移动到了5就算数组前两个位置已经出队空了rear也不能再往后写了判断队满的条件rear capacity就触发了。但数组前面明明是空的放着不用这就叫假溢出。解决假溢出的方案是循环队列也叫环形队列。核心思路把数组首尾相接通过取模运算把rear和front在逻辑上接起来。这里有个关键设计问题——怎么判断队空和队满。常见做法有三种留一个空位、加一个size变量、加一个tag标志位。热搜里专门提到了“以数组q[m]存放循环队列中的元素同时以rear和length分别指示环形队列中的队尾和长度”这就是第二种方案用rear length两个字段。这种方案的好处是判断队空队满特别直观length等于0就是空等于m就是满不需要额外牺牲一个存储位。队头位置怎么算队列的rear指向下一个写入位置length是当前元素个数那么队头front (rear - length m) % m。这个公式很重要如果你在解题或者写代码时发现队头位置错了多半就是这里没处理负数取模。C语言里负数取模的结果是负数所以要加m再取模。用C写一版完整的循环队列#define MAXSIZE 8 typedef struct { int data[MAXSIZE]; int rear; // 队尾指向下一个写入位置 int length; // 当前元素个数 } CircularQueue; int is_full(CircularQueue *q) { return q-length MAXSIZE; } int is_empty(CircularQueue *q) { return q-length 0; } int enqueue(CircularQueue *q, int x) { if (is_full(q)) return -1; q-data[q-rear] x; q-rear (q-rear 1) % MAXSIZE; q-length; return 0; } int dequeue(CircularQueue *q, int *out) { if (is_empty(q)) return -1; int front (q-rear - q-length MAXSIZE) % MAXSIZE; *out q-data[front]; q-length--; return 0; }这套实现的精髓在于通过length判断队满后可继续使用的空间队尾指针写完后一律取模回绕队头动态计算。相比front rear方案它省去了判断“留一个空位”的麻烦也避免浪费一个数组格子。实际项目中很多环形缓冲区就采用类似思路比如音频的ringbuffer、串口的DMA接收缓冲区。3.3 链式队列与环形缓冲区链式队列的实现思路和链式栈类似区别在于要同时维护队头、队尾两个指针。入队操作在尾部插入O(1)出队操作在头部删除O(1)。用C写的话需要加个尾指针typedef struct QNode { int data; struct QNode *next; } QNode; typedef struct { QNode *front; QNode *rear; } LinkedQueue; void enqueue(LinkedQueue *q, int x) { QNode *node (QNode *)malloc(sizeof(QNode)); node-data x; node-next NULL; if (q-rear NULL) { q-front q-rear node; return; } q-rear-next node; q-rear node; } int dequeue(LinkedQueue *q, int *out) { if (q-front NULL) return -1; QNode *tmp q-front; *out tmp-data; q-front tmp-next; if (q-front NULL) q-rear NULL; free(tmp); return 0; }注意空队列出队时front已经变NULL但rear还指着旧节点所以必须把rear也置NULL否则二次出队和isEmpty判断都会出错。这个细节我在面试别人的时候经常当作考察点十个有六个会漏掉。环形缓冲区和循环队列是同一个东西在不同领域的叫法。底层通信、音视频采集、日志缓冲几乎都能看到环形缓冲区。它的价值有两个一是空间复用写满一圈后覆盖最旧的数据天然适合“只需要最近N个数据”的场景二是配合读写指针可以做到单生产者单消费者无锁操作性能极高。我做过一个ESP32的音频采集项目麦克风数据就是通过环形缓冲区从I2S中断搬运到主任务做处理的一边写一边读只要读的速度跟得上写的速度就不会丢数据。这算是一个很典型的“阻塞队列”思想在嵌入式里的轻量版本。4. 并发队列与消息队列工程中的队列实现4.1 阻塞队列与线程池的秘密线程池为什么要用阻塞队列因为线程池需要解耦任务提交和执行两个节奏。生产者线程不断提交任务工作线程不断消费任务。如果队列满生产者应该被阻塞而不是无限堆积任务否则内存会爆掉如果队列空工作线程应该等待而不是空转否则CPU白白浪费。阻塞队列就是为这套逻辑定制的。选哪种阻塞队列直接决定了线程池的行为。Java里常见的几个是队列实现锁策略容量适用场景ArrayBlockingQueue单锁固定有界队列公平性可控LinkedBlockingQueue双锁默认Integer.MAX_VALUE无界或有界SynchronousQueue无锁/CAS0任务不排队直接交接DelayQueue锁堆无界延迟任务、定时任务我在之前的项目里踩过一个很真实的坑默认的Executors.newFixedThreadPool用的是LinkedBlockingQueue无界队列。看起来开发很省心任务提交就完事。结果某天业务方疯狂提交任务队列里积压了几百万条任务内存直接飙到3个GGC频繁服务卡死。从那以后我所有线程池都强制用有界队列并配合拒绝策略。像ArrayBlockingQueue或者自定义容量很小的LinkedBlockingQueue宁可拒绝任务也不能让队列无界膨胀。这些经验常规文档里不会替你考虑“线程池阻塞队列选择”从来不只是选择题而是系统稳定性设计的生死线。阻塞队列的底层实现也很值得玩味。Java的ArrayBlockingQueue用一把ReentrantLock加两个Condition一个队列非空、一个队列非满通过await和signal实现等待唤醒。LinkedBlockingQueue则用takeLock和putLock两把锁让生产者和消费者可以分别并发操作队头和队尾吞吐量更高。这些细节你了解了以后排查问题会快很多如果生产的锁和消费的锁是同一把那么生产者和消费者会被迫串行竞争锁在锁冲突高的场景下性能会明显下降。4.2 无锁队列与原子操作并发队列有锁就有锁竞争带来的阻塞和上下文切换开销。在超高吞吐需求下人们会尝试无锁队列Lock-Free Queue。无锁队列的核心依赖是原子操作最典型的是CASCompare And Swap比较并交换。CAS的意思是检查某个内存位置当前值是否等于预期值如果等于就更新为新值整个操作是原子的不会被打断。C里用std::atomic就可以实现简单的无锁栈或者无锁队列。最简单的无锁栈核心代码长这样struct Node { int value; Node *next; }; std::atomicNode * head; void push(Node *node) { Node *old head.load(std::memory_order_relaxed); do { node-next old; } while (!head.compare_exchange_weak(old, node)); }这段代码的原理是先把当前栈顶保存到old然后尝试把自己的next指向old再用CAS把head更新为node。如果这期间别的线程改了headCAS就会失败old会被刷新成最新值循环重试。这就是无锁编程最基本的“读取-尝试-重试”模式。但无锁队列真正落地极其复杂。ABA问题、内存管理、伪共享、memory_order的选择每一个都能让人头发掉一把。ABA问题尤其经典线程T1读到的值A在CAS重试前另一个线程把值改成B又改回AT1的CAS就误判成功导致链表错位。解决ABA通常用带标记的指针比如stamped head指针或者hazard pointer、epoch回收机制。我的态度向来是生产环境不要轻易手写无锁队列。除非你非常清楚队列的全部生命周期否则老老实实用经过验证的并发库。很多所谓无锁队列测出来性能还不如优秀的加锁队列因为无锁在高竞争场景下CAS重试会剧烈消耗CPU产生活锁。无锁队列真正的优势场景是“短暂的临界区低到中度的竞争”以及ISR中断上下文和线程之间不能加锁的情况比如DPDK、Nginx多进程模型里的共享队列。4.3 消息队列的重复消费问题很多项目用RabbitMQ、Kafka、RocketMQ本质就是分布式版的生产者消费者队列。热搜里“消息队列重复消费问题”是个高频面试题同时也是真实生产环境最容易翻车的地方。为什么消息队列会重复消费根本原因是消息队列的“至少一次at-least-once”语义。以Kafka为例消费者处理完业务后提交offset如果业务处理完成但是offset提交失败比如网络闪断、消费者重启重启后会从旧的offset重新拉取数据消息就重复了。RabbitMQ的ack机制也一样消息没有签收就会重新入队。解决重复消费没有“银弹”业内共识是消费端幂等。也就是同样的消息消费多少次结果都一样。幂等的手段有几种在数据库表里加唯一约束重复插入直接报错或忽略更新用Redis setnx做消息ID去重消费前先尝试写入消息ID写不进说明已经处理过还有一种业务幂等比如支付回调里的状态机流转只有“未支付”状态下才能转为“已支付”重复回调时状态已经变了就不会二次扣款。做消息队列架构时我的建议是把“幂等”当成系统的默认义务而不是上线后才补的补丁。每条消息都天然带着一个全局唯一ID消费侧在入口处做一个去重检查成本很低。千万别以为消息队列本身能保证不重复它只能尽量做到“不丢”重复几乎不可避免。理解了这一点你的后端设计会稳健很多。5. 栈和队列的实战应用盘点5.1 栈的经典场景括号匹配、表达式求值、撤销操作、函数调用栈最有名的算法应用是括号匹配遇到左括号就入栈遇到右括号就出栈匹配最后栈空就是合法。复杂度O(n)的经典解法。表达式求值也是栈的主场。中缀表达式转后缀表达式靠的是运算符栈里优先级比较后缀表达式求值靠的是操作数栈。做题是一回事工程里也有真实应用很多计算引擎、规则引擎的核心计算模块就是这么实现的。撤销/回退操作。编辑器、浏览器的返回功能本质上就是两个栈一个undo栈、一个redo栈。你做一次操作把旧状态压进undo栈顺便清空redo栈按CtrlZ时从undo栈弹出来压进redo栈按CtrlY时反向操作。这个模式我建议每个做GUI或者工具链的人都学会它让状态管理变得极其清晰。递归函数和调用栈原理前面已经讲过不再重复。另外值得提一下函数调用栈是排查问题的重要抓手。你在IDE里打断点或者崩溃后导出的core dump第一步就是看调用栈。配合栈回溯工具能快速定位崩溃位置和调用来源。arm调用栈回溯在嵌入式Linux上尤为重要我处理过一起“死机在莫名偏移处”的疑难杂症最后就是用栈回溯加反汇编一点点倒推出来的。所以做底层开发的朋友建议至少熟练掌握一种栈回溯方式gdb的bt、perf的callgraph都可以。5.2 队列的经典场景BFS、生产者消费者、流量削峰、日志缓冲图的BFS广度优先搜索用队列做遍历顺序的载体这是算法课必讲的根节点入队循环出队、访问邻接点、入队。树的分层遍历也一样。队列在这里的作用是保证“先访问的先扩展”也就是保证了逐层推进。生产者消费者模式是队列在并发领域最经典的落地。它的核心价值是削峰填谷生产者速度忽高忽低消费者速度恒定通过队列缓冲让高低谷削平。这个思路同样可以用到日志系统里应用线程把log丢到队列里立刻返回日志线程批量异步写盘整体性能提升一个量级这也是log4j2 AsyncAppender的基本原理。流量削峰是另一个高频场景。秒杀系统里瞬间10万请求直接打到数据库数据库必挂。通常的做法是请求先进入MQ系统按照数据库能承受的速度从MQ拉取消息慢慢处理。用户的体验是“已经下单等待结果”但实际上订单处理被延后了。这就是典型的“排队叫号”思想窗口再多也有限叫号机加座位缓冲是最成熟的做法。5.3 双端队列的独特价值滑动窗口与LRU Cache双端队列的价值在于它同时具备“队头高效操作”和“队尾高效操作”两种能力能实现一些单端队列很难优雅实现的结构。最典型的就是前面提到的滑动窗口最大值在窗口移动过程中既要快速淘汰离开窗口的元素队头出又要维护窗口内元素的单调性队尾淘汰插入双端队列几乎是为这个题目量身打造的数据结构。还有一种常见场景是LRU缓存。虽然LRU通常用“HashMap双向链表”实现但如果不想引入指针操作可以用双端队列配合“每次访问时把节点先删除再插到队头”配合版本号或计数器来标记节点是否失效。为什么LRU不用单链表做因为单链表删中间节点是O(n)双向链表删任意节点O(1)。这里也顺便模型化了一个道理双端队列就是比普通队列“更灵活但语义仍然明确”的线性结构。做题和工程有一点不同工程里很少有纯靠双手去“用双端队列实现LRU”的需求因为现成的LinkedHashMap已经帮你做好了。但是在算法题、嵌入式代码里自己实现依然常见理解双端队列的两端操作语义对你写代码和解题都有帮助。6. 常见错误与排查技巧实录6.1 栈相关典型错误栈顶指针方向混乱。前面提到top初始值选0还是-1很多人写一半换方案然后push、pop就互相矛盾。我的建议是固定为“top指向下一个可写入位置”这样判断空是top 0、判满top capacity。空栈peek/pop不检查。C语言里栈为空仍然pop顶多返回一个垃圾值不会立刻崩但会在几层调用之后以诡异的方式体现。记得所有操作前先检查。递归太深导致栈溢出。C、Python这类语言对递归深度都有上限Python默认递归限制在1000层左右实际上在深度几百层就可能溢出。如果你想遍历一棵极深的目录树或解析极端嵌套的JSON递归不是稳定的方案要么改用显式的栈迭代要么调整线程栈大小嵌入式做任务栈大小的设计时尤其重要。结构体变量定义时搞混指针和值栈。用Go、C定义栈结构时变量可以定义在栈上也可以定义在堆上malloc或new。如果结构体本身包含一个很大的数组定义在栈上会直接拉高栈空间消耗多个线程各自的栈容易爆。这种情况建议栈结构用堆分配。6.2 队列相关典型错误循环队列的“队满”和“队空”判断混滑。如果采用“front rear”判断队空和队满就必然会冲突所以要么留一个空位要么加length/tag。用rearlength方案时注意front计算公式里负数的处理前面代码已经示例过了。数组越界和取模错误。循环队列的每一项移动都要考虑模运算rear (rear 1) % m。有人图省事直接rear数组越界或者逻辑上绕不回起点后面就会出现各种诡异错乱。所有环形操作一律取模不要留裸奔。“阻塞队列”的线程安全问题。自己用普通数组加wait/notify实现阻塞队列时最容易犯的错误是条件变量判断用if而不是while。Java的Object.wait必须放在while循环里因为线程可能被伪唤醒或者被其他线程抢先拿走任务if只检查一次很容易越界取数据或者取到空。这是教科书里会写但实际很多人依然犯的经典并发bug。队列积压导致数据延迟过高。线上如果消息量很大而消费速度跟不上队列长度会不断增长。我在处理一次项目事故时日志显示发送消息耗时突然从50ms变成5s最后排查发现是消费者进程宿主机负载太高消费能力下降导致队列积压。遇到这类问题先看队列长度趋势再看消费者线程的WAITING状态时间占比定位是锁竞争还是外部依赖慢。6.3 定位顺序建议遇到栈相关的问题我的排查顺序是先查调用栈和栈溢出日志再看代码里的递归路径和局部变量大小确认是逻辑缺陷还是配置限制。遇到队列相关的问题顺序是先看队列长度和消费速率曲线然后看生产者和消费者的耗时分布最后确认是否存在死锁、积压或锁竞争。这套顺序不是死的但按“容量→速率→锁”的路径走通常能很快圈定范围。个人在实际项目中的体会是很多低级错误都源于对数据结构“边界条件”的轻视。栈顶指针初始值、循环队列的取模、阻塞队列的while判断这些细节值不了多少分但在真实系统里就是导致线上故障的元凶。你把这些坑提前规避掉后面调试的时间能省出一大半。