资讯详情

资讯详情

队列与循环队列原理详解:从数组实现到线程池与消息队列实战

队列这东西说简单是真简单说复杂也真复杂。我刚入行那会儿觉得队列不就是先进先出嘛后来在并发编程、消息中间件、算法优化里反复碰到它才发现自己当初的理解太浅了。尤其是循环队列一个“取模回绕”的思路直接决定了数组存储结构的高效性很多生产环境里的消费模型、线程池任务缓冲、消息堆积场景底层都跟它脱不开干系。这篇文章我就围绕“队列以及循环队列原理与实现”展开从基础数据结构讲到循环队列的工程实现再延伸到阻塞队列、线程池选型、单调队列优化DP、消息队列选型对比这些你一定会遇到的实战问题。无论是正在学数据结构的在校生还是工作中需要处理任务调度、异步削峰的后端开发都应该能从里面拿到一点能直接用的东西。1. 队列的基本认知不只是“先进先出”四个字1.1 队列的模型与两个基本操作线性表有两种受限结构一种叫栈后进先出另一种就是队列先进先出。队列的模型特别像生活中排队买奶茶先来的人先拿到后来的人排在队尾。在代码层面队列只需要两个核心操作入队enqueue和出队dequeue。入队操作队尾追加元素出队操作从队头移除元素。除此之外还有两个辅助操作我们几乎每个场景都会用到查看队头元素peek和判断队列是否为空isEmpty。我用一个最简单的生活类比解释一下为什么队列这种结构这么重要。日常开发中经常遇到“生产者”和“消费者”两个角色生产者的生产速度和消费者的处理速度往往不一致。如果让两边直接对接生产者就得等消费者处理完才能继续干整体效率被最慢的一环锁死。中间加一个队列作为缓冲区这个问题就迎刃而解了生产者往队列里丢任务消费者按自己的节奏从队列里取任务两者解耦互不阻塞。这就是消息队列、线程池、任务调度系统最底层的设计思想。1.2 数组实现和链表实现两种队列存储形态的取舍队列的物理存储有两种常见方案数组和链表。数组方案因为内存连续、CPU缓存友好在多数场景下性能更好但固定容量是它的软肋。链表方案可以动态增长删除方便但是每个节点需要额外的指针开销而且在频繁分配释放节点时会产生内存碎片。数组实现队列时有一个特别容易踩坑的问题如果我定义了一个长度是5的数组往里塞了5个元素然后从队头弹出3个这时候数组的前3个位置空了。如果继续入队由于队尾指针已经指向索引5按照常规逻辑数组就“满”了可实际明明空着3个位置。这就是普通数组队列的假溢出问题。要么做数据搬移把后面的元素往前挪但那样时间复杂度是O(n)要么想个办法把数组首尾连接起来让队尾指针从末尾绕回开头。这个办法就是循环队列也是这篇文章我最想展开的部分。2. 循环队列实现数组、链表与扩容细节全拆解2.1 核心机制取模运算与回绕思想循环队列的本质就是用取模运算把逻辑上的环映射到物理上的连续数组。假设数组容量是maxSize队头指针是front队尾指针是rear那么入队和出队时指针的移动不再是简单的rear、front而是rear (rear 1) % maxSize; front (front 1) % maxSize;这个取模操作就是“回绕”。数组的最后一个位置和第一个位置被% maxSize这个运算逻辑上连接起来形成环形结构。我在地铁上看到过很多学数据结构的同学在这里卡住其实没必要把它想得太玄乎。取模跑一圈回到0你就把它当成钟表上12点之后的1点时间是连续走动的只是显示数字绕了一圈。循环队列最大的好处是没有数据搬移。出队之后空出来的位置马上可以被后续入队复用入队和出队的时间复杂度都稳定在O(1)。这在高频生产消费场景中是至关重要的想一想每秒几万条消息的实时处理任何一次的O(n)搬移都会拖垮整个流程。2.2 判空与判满循环队列经典的三套方案环形结构带来一个麻烦queue满和queue空的时候front和rear的关系相同都是front rear。所以必须在设计层面把这两种状态区分开。经典方案一共有三种浪费一个存储位、记录元素个数、增加标志位。方案一是“牺牲一个位置”。约定rear指向队尾元素的下一个位置当(rear 1) % maxSize front时判定为满。队空判断依然是front rear。这个方案实现最简洁但是容量利用率会打折扣明明是大小100的数组只能存99个数据。方案二是“维护size字段”。用一个变量length记录当前队列元素个数入队时length加1出队时length减1。此时判空只需length 0判满只需length maxSize。这个方案多一个整型变量但逻辑最清晰几乎不会出错。方案三是“设置标志位”。当入队导致front rear时把flag置为1说明队列是满的出队导致front rear时把flag置为0说明队列是空的。这个方案也在实际工程中使用不过逻辑分支较多我在生产代码里用得更少。方案判空条件判满条件优点缺点浪费一格front rear(rear 1) % maxSize front实现简单容量利用率低记录长度length 0length maxSize逻辑清晰不易出错多维护一个计数器标志位front rear flag 0front rear flag 1空间利用率满代码分支多易遗漏状态更新2.3 完整代码实现初始化、入队、出队、扩容我用C语言写一个带扩容功能的循环队列代码注释尽量详细。数据结构定义选用维护length的方案这样判空判满最直观还能顺手得到一个常用接口size()。#include stdio.h #include stdlib.h #include stdbool.h typedef struct { int *data; int capacity; int front; int rear; int length; } LoopQueue; // 初始化 void init(LoopQueue *q, int cap) { q-data (int *)malloc(sizeof(int) * cap); q-capacity cap; q-front 0; q-rear 0; q-length 0; } // 判断是否为空 bool isEmpty(LoopQueue *q) { return q-length 0; } // 判断是否为满 bool isFull(LoopQueue *q) { return q-length q-capacity; } // 扩容 void resize(LoopQueue *q) { int newCap q-capacity * 2; int *newData (int *)malloc(sizeof(int) * newCap); for (int i 0; i q-length; i) { newData[i] q-data[(q-front i) % q-capacity]; } free(q-data); q-data newData; q-capacity newCap; q-front 0; q-rear q-length; } // 入队 bool enqueue(LoopQueue *q, int value) { if (isFull(q)) { resize(q); } q-data[q-rear] value; q-rear (q-rear 1) % q-capacity; q-length; return true; } // 出队 bool dequeue(LoopQueue *q, int *out) { if (isEmpty(q)) { return false; } *out q-data[q-front]; q-front (q-front 1) % q-capacity; q-length--; return true; } // 查看队头 int peek(LoopQueue *q) { return q-data[q-front]; } // 当前大小 int size(LoopQueue *q) { return q-length; }扩容函数里有一个容易忽略的细节扩容后必须把front和rear重新映射到新数组的开头位置否则旧数组的环形索引在新数组中会产生错位。我曾经的代码就漏了这个导致扩容后取到的元素顺序完全错乱排查了好一阵子。源码里可以看到复制数据时用(front i) % capacity从旧数组的front开始按逻辑顺序遍历复制完再把front归零、rear指向length。这里的length就是元素个数正好位于新数组最后一个元素的下一个位置。2.4 链表式循环队列的适用场景另一个常见实现是用带头尾指针的链表模拟队列。链表不需要环形回绕因为节点本身是离散分配的入队往尾部追加出队删掉头部节点。这种方式的优势是容量理论上不受限制每个节点动态分配适合任务数量波动很大的场景。缺点是节点内存不连续CPU缓存命中率不如数组而且频繁malloc/free在高并发下会出现性能抖动。结合工程实践来说单线程或低并发场景用链表队列完全没问题比如某些事件驱动框架里的待处理事件列表。但在高吞吐场景JDK里的ConcurrentLinkedQueue虽然也是链表底层却用了大量CAS和无锁技巧不是一个简单的链表就能替代的。如果只想快速实现一个本地缓冲数组版本的循环队列通常是更稳的选择。3. 阻塞队列与并发改造线程池为什么必须选它3.1 从普通循环队列到阻塞队列的进化上面实现的循环队列是线性安全的吗不是。多线程同时入队、出队front/rear/length这些共享变量会出现数据竞争。最简单的解决方法是加锁让入队和出队串行执行。Java的BlockingQueue在接口层面做得更彻底它不仅线程安全还带阻塞能力。阻塞队列的阻塞机制有两个方向。第一个方向是“消费者等待”队列为空时消费者线程调用take()会被挂起直到生产者入队一个元素后才能被唤醒。第二个方向是“生产者等待”队列满时生产者线程调用put()会被挂起直到消费者取走一个元素腾出空间。这正好对应生产消费模型避免了忙轮询消耗CPU。为什么需要用循环队列去理解阻塞队列因为ArrayBlockingQueue的底层就是数组循环队列加一把ReentrantLock和两个Condition。看源码时你发现里面也有putIndex、takeIndex和count其实就是front、rear和length的变体。只要你把循环队列的机制弄透了读这些并发容器源码会顺畅得多。3.2 JDK阻塞队列对比与线程池核心线程数的逻辑JDK里内置了好几种阻塞队列它们的差异直接决定了线程池的行为特征。我做一个对比表格方便你查阅。队列实现底层结构是否有界使用场景ArrayBlockingQueue数组循环队列有界公平性可控的有界任务队列LinkedBlockingQueue链表结构默认无界吞吐量较高但注意堆积风险SynchronousQueue无缓冲无缓冲不存储任务直接交给线程处理PriorityBlockingQueue堆结构无界有优先级要求的任务DelayQueue优先队列无界延迟任务、定时轮询这里面的坑非常多。很多人以为LinkedBlockingQueue默认是有界的其实它的默认容量是Integer.MAX_VALUE。当你用它作为线程池的任务队列时如果生产者远超消费者能力任务会无限堆积最终导致内存溢出。这个问题在生产环境出现后通常都很惨烈线程池明明在跑可用内存却一路飙到极限接着就是一连串的OOM重启。线程池的核心线程数怎么定我的经验公式是分场景的。CPU密集型任务核心线程数设置成CPU核心数 1IO密集型任务可以设置得更高比如2 * CPU核心数因为IO等待期间CPU可以切换去处理别的任务。但这里还有一层容易忽略的逻辑阻塞队列的容量决定了系统的“弹性窗口”。如果队列很小比如SynchronousQueue线程池会倾向于直接新建线程处理任务此时最大线程数要给出足够余量如果队列很大比如有界10000那线程池倾向于让任务排队可以在低峰期消化不太会触发拒绝策略但延迟会变高。所以在设计时要根据业务容忍的排队时延而不是随便填一个数。3.3 C原子操作与无锁队列的方向聊完Java阻塞队列顺带提一下C领域的无锁队列。无锁队列的核心是用原子变量操作替代锁常见的是用CAS实现push和pop。经典的实现模型是队列头指针和尾指针都声明为std::atomic入队时CAS更新尾指针出队时CAS更新头指针。队列为空或满的判断依然需要借助循环队列的容量计数设计。无锁队列的好处是没有线程上下文切换和锁竞争在极端高并发下吞吐量明显优于锁实现。但坏处也非常明显实现难度高ABA问题、内存回收、多生产者多消费者并发时的顺序保证任何一个细节没处理好都会出现诡异的数据错乱。我建议如果不是性能压测已经明确告诉你锁是瓶颈不要轻易自己造无锁队列直接用成熟的库C里可以看看boost::lockfree::queue生产环境经受过大量验证。4. 队列思维的进阶单调队列、双端队列与任务调度4.1 单调队列滑动窗口最大值问题的O(n)解法数据结构一旦学会了很多算法题会突然变得有迹可循。单调队列是我特别喜欢的例子它的本质是“在队列中维护一个单调性”常用于解决滑动窗口类问题。LeetCode的滑动窗口最大值就是经典题暴力解法是O(nk)每次移动窗口重新扫描k个元素。用单调队列可以把时间压到O(n)。思路是这样用一个双端队列维护窗口内可能成为最大值的元素下标保证队头到队尾的元素值单调递减。每次窗口右移先检查队头是否已经滑出窗口滑出了就弹出然后从队尾往前把所有小于新元素的元素全部弹出因为它们在新元素面前永远没机会成为最大值了接着新元素入队此时队头就是当前窗口的最大值。这个“淘汰”的思想和单调栈异曲同工本质是利用局部信息排除永远不会被选中的候选者。我用Python写一个极简版本from collections import deque def max_sliding_window(nums, k): dq deque() res [] for i, v in enumerate(nums): # 超出窗口范围的队头移除 if dq and dq[0] i - k: dq.popleft() # 从队尾移除所有小于当前值的元素下标 while dq and nums[dq[-1]] v: dq.pop() dq.append(i) if i k - 1: res.append(nums[dq[0]]) return res单调队列优化DP就更高级了。有一类DP的状态转移方程是dp[i] max(dp[j] cost(j))其中j的取值范围是一个长度固定的滑动窗口这时候直接套用单调队列可以把O(nk)的DP优化成O(n)。多重背包的二进制优化之外也经常见到用单调队列优化多重背包的方法原理是把余数分组每组内按窗口单调性转移。学这个套路时会发现很多看起来高深的优化根基反而是最基础的数据结构。4.2 双端队列设计为什么Deque更灵活双端队列的支持操作比普通队列多得多可以在队头入队、队头出队也可以在队尾入队、队尾出队等价于栈和队列的合体。循环数组同样可以实现双端队列此时用来判断空满的条件依然离不开front、rear和length。工程上双端队列最常见的两个场景一个是上面提到的单调队列另一个是“任务窃取”框架。线程池里每个线程维护一个双端队列自己从队尾取任务执行空闲线程从其他线程队列的队头“窃取”任务这种Work-Stealing模式可以显著平衡线程间的负载。Java的ForkJoinPool底层就用到了这个设计。4.3 大模型调度平台的任务与队列管理现在大模型应用多了调度平台里也到处是队列的影子。请求进入网关后先放任务队列调度器按优先级、资源余量、租户配额从队列中拉取任务分配到GPU节点执行。这里使用的队列往往比普通队列复杂得多常见的是“优先级队列多级队列”的组合全局有一个优先级队列每个优先级内部又是一个FIFO队列避免高优先级任务无限抢占导致低优先级饿死。这类任务管理系统的队列设计核心是两点队列容量要可控任务等待时间要可观测。生产环境里我们要对每个队列配置最大pending数量一旦超过阈值就拒绝新请求或触发告警防止上游重试风暴打垮整个平台。5. 消息队列选型实战Kafka、RabbitMQ、RocketMQ到底该选谁5.1 三个主流消息队列的定位差异把视野从本地内存队列放大到分布式场景消息队列的选型问题几乎是每个后端团队都会遇到的。Kafka、RabbitMQ、RocketMQ这三者定位差异非常明显。Kafka的核心优势是吞吐量极高和日志型存储。它天生为海量事件流设计分区机制保证了同一个分区内的消息顺序加上消费者组的概念让横向扩展变得非常自然。RocketMQ是阿里巴巴开源的消息中间件很多人在金融和电商场景大量使用它的优势是事务消息、延迟消息能力强大消息堆积能力也不错。RabbitMQ则胜在功能全面、路由灵活支持多种交换机类型和复杂的路由键匹配适合业务系统间可靠的消息传递。维度KafkaRabbitMQRocketMQ吞吐量极高百万级/秒一般十万级/秒高数十万级/秒消息顺序分区内有序单队列有序队列内有序延迟毫秒级微秒级毫秒级典型场景日志收集、事件流、大数据管道业务解耦、RPC异步电商交易、金融级事务运维复杂度较高依赖ZK较低中等拿“秒杀”场景举例系统瞬间涌入大量下单请求数据库扛不住最常用的方案就是请求先打到消息队列消费者按库存量慢慢消化。这类场景要的是“高写入吞吐快速消费”Kafka或者RocketMQ会更适合。但如果是内部系统之间需要可靠的业务通知比如订单状态变更后通知仓储、通知财务RabbitMQ的路由能力反而更灵活能按不同的routing key把消息分发到不同业务队列。5.2 重复消费问题不可完全避免但必须处理好幂等分布式消息队列里因为网络抖动、消费者宕机、提交偏移量失败等原因重复消费是常态而不是异常。我在实际项目中遇到过这么一件事消费者明明已经处理完一条订单消息但因为处理时间超过会话超时消费者被判定为故障消息被重新投递结果订单表里插入了两条一样的记录。这个事故的直接原因就是没做幂等处理。三种通用的解决方案我逐一说明。方案一是让消费操作天然幂等比如数据库的INSERT ... ON DUPLICATE KEY UPDATE同一订单无论来几次结果一致。方案二是使用去重表消费前插入一条带业务唯一标识的记录如果该记录已存在说明消息之前消费过直接跳过。方案三是Redis分布式锁或者状态机在消息处理前设置一个“处理中”状态处理完成改为“已完成”重复消息看到已完成的标识就丢弃。具体的幂等键怎么设计我的建议是优先选择业务自身的唯一键比如订单号、支付流水号而不是消息ID。因为消息ID在生产者重发时可能变化比如网络超时后的重试消息经常会产生新的消息ID但业务流水号是不会变的。5.3 顺序消费与堆积两种打包出现的高频事故消息队列的顺序性常常被忽略等出了事才悔之晚矣。Kafka只有在单个分区内保证顺序如果生产者把同一笔订单的不同事件写到不同分区消费端拿到的顺序就可能是乱的。RocketMQ的MessageQueueSelectorApi可以按照业务ID把消息固定发送到同一个队列这样消费端才能严格顺序处理。RabbitMQ的单队列天然有序但它没有分区的概念吞吐量会受影响。消息堆积是另一个经典问题。堆积的根源要么是生产速率暴涨要么是消费能力下降比如某条坏消息反复消费失败把单线程消费者卡住了。定位堆积问题我用三步法第一步在监控面板上对比生产速率和消费速率确定瓶颈方向第二步查看消费者的日志是不是有大量重试或异常第三步针对坏消息做隔离或者跳过比如把失败N次的消息转入死信队列不要让他们阻塞整个消费链路。真正排查过一次事故后你才会理解消息队列的一个底层现实队列本身不会丢消息关键是客户端怎么提交位移。Kafka里如果消费者先处理消息后提交offset应用崩溃会导致重复消费如果先提交offset后处理应用崩溃会导致消息丢失。想尽可能避免两者就得做“处理提交”的原子化设计或者用事务消息把状态写库和提交offset绑在一起。值得说明的是RocketMQ对事务消息的支持是开箱即用的很多金融转账一致性场景都会重点考虑它。5.4 并发消费调优分区数、消费线程、批量参数的配合消息队列的性能调优本质上就是调整并行度。Kafka的并行度上限是分区数每个分区的消息只能被同一个消费组内的一个线程消费所以想让消费能力强就得把主题的分区数设置得足够大并且消费端的线程数要匹配分区数而不是盲目开线程。RocketMQ消费端虽然支持线程池并发消费多条消息但顺序消费模式下单个队列又必须串行。这里有个实操参数值得关注拉取批量大小。Kafka消费者每次poll()可以拉取多条消息默认最大500条。把批量拉取的数量调大可以减少网络往返次数提升吞吐量但拉取太多每条消息处理时间差异很大时会造成队列尾部消息迟迟得不到处理。RocketMQ也有类似的批量消费设置consumeMessageBatchMaxSize参数默认是1如果你确认消费逻辑很快调到16或者32会有明显提升。6. 常见问题与排查技巧实录6.1 循环队列的队空队满混淆自己在实现循环队列时最容易出bug的就是没想清楚rear到底指向哪里。我推荐一个自查方法把所有状态打印出来对照下表验证。状态frontrearlength初始空队列000入队2个元素后022出队1个元素后121队列填满时0capacity-1capacity扩容后0lengthlength如果你用的是“浪费一格”方案rear指向队尾元素的下一个位置那么入队时直接赋值data[rear]然后移动到下一个位置出队时读取data[front]然后front移动。这个逻辑一定要画图想清楚代码里遗漏任何一个取模操作都会导致索引越界或者数据覆盖。6.2 数组越界与索引错位循环队列的索引范围是0到capacity-1由于取模运算的存在代码里所有访问下标的操作都必须经过% capacity。我在项目中看到有人写data[rear % capacity]这个表达式在C语言里会先计算rear % capacity然后对rear自增结果看起来对但语义很容易绕晕。更稳妥的写法是把取模和赋值分开data[rear] value; rear (rear 1) % capacity;6.3 消费者处理消息抛出异常导致死循环使用消息队列时最典型的一个坑是消费端没有处理异常。如果你在while(true)里调用consume()某条消息一旦抛出未捕获的异常循环就会中断线程池里的线程死掉待消费消息越积越多。我给团队定的底线是消费逻辑必须用try-catch包裹捕获后的处理策略要么重试若干次后记录告警并跳过要么发送到死信队列。6.4 队列监控从无到有建立观测队列不是说上线就能安枕无忧的。本地队列要关注最大积压数量和平均等待时间分布式消息队列要关注生产速率、消费速率、消费延迟、堆积总量。这些指标最好全部进入监控大屏设置对应阈值告警。我的经验是消费延迟比堆积总量更重要。总量大不一定有问题可能只是正常峰值期但消费延迟持续分钟级上升往往意味着消费端出了问题必须马上确认。6.5 一点选型上的个人建议如果让我给新项目提一个相对实用的路线中小业务系统内部异步通知RabbitMQ完全够用日志、埋点、事件流这类海量数据Kafka是最稳的选择金融、电商核心交易链路中需要事务消息和高可靠性的优先考虑RocketMQ。队列的这个话题从循环数组到分布式消息队列跨度其实非常大但核心思想一脉相承用一块缓冲区削峰填谷让生产者和消费者各司其职。我在工作中体会最深的一点是很多看起来高级的框架和中间件底层都藏着这些基础的数据结构思想。把基础彻底弄明白选型、排查、优化都会有清晰的逻辑线。最后再分享一个小技巧每次改动队列相关的核心逻辑先在代码里把入队出队的状态日志打全用肉眼过一遍再谈并发和性能优化这能省下大量调试时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →