有个刚转后端不到一年的同学问我:队列到底有什么好讲的,不就是排个队吗?我当时没有正面回答,反手给他看了一个线上故障——有一个消费端把消息批量写入下游的接口,高峰期消息一多,整个系统的吞吐量反而掉到平时的三分之一。查到最后,问题出在一处队列实现上:大家为了省事,用了一个会频繁搬移元素的“假队列”,生产者一多,搬迁开销把 CPU 吃掉了大半。他听完沉默了一会儿,又问我:那是不是选对队列实现,比背概念更重要?我说对,也不全对。队列看起来是数据结构里最简单的几种之一,但往下挖,它能串起循环数组、链表哨兵、阻塞与唤醒、线程池、Redis Stream、消息幂等一整条知识链。这篇博文我就按自己实际排障和写代码的思路,把这些内容从头到尾捋了一遍,适合正在学数据结构的学生,也适合做后端开发时遇到队列选型、重复消费这类问题的人。
1. “先到先得”背后,头尾指针和容量问题才是第一道坎
1.1 用打印任务来讲队列的抽象特征
我上课或者带新人时,很喜欢拿打印任务举例子。大家都有过这样的经历:办公室里一台共享打印机,A先点了打印,B后点,正常情况下,A的文档就应该先被打印出来。打印机不会因为B的文件页数少就插队,也不会因为A的文件很重要就为它单独开一条通道。这个朴素规则在计算机里被抽象成了一种数据结构:队列。
队列最重要的两个操作是入队(enqueue)和出队(dequeue)。入队就是把新元素放到队尾,出队就是从头取出元素。这个模型天然带有“先进先出”(First In First Out,FIFO)的特征。
但也正因为模型太贴近生活,很多人会形成一个错觉:队列很简单,随便拿个数组就能实现。真正写代码时才发现,用数组实现队列并不难,难的是让它高效地复用空间。这个点恰恰是《数据结构》课程里“循环队列”这一节的考点来源,也是很多实验报告里让老师一眼看出有没有真懂的地方。
1.2 队列接口要有哪些能力,才算完整
我在实际项目里,很少直接对一个业务场景说“这是个队列”,而会说“它需要支持哪些操作”。一个最基本的队列接口,通常会包含六个能力:
- 入队:往队尾加一个元素。
- 出队:从队头取一个元素并删除。
- 查看队头:只看下一个将被处理的对象,但不弹出去。
- 判空:队列里还有没有元素,决定消费者是否需要等待。
- 判满:队列还能不能继续接收新元素,决定生产者是否需要退避或丢弃。
- 获取当前长度:用于监控队列积压情况。
很多同学学完队列,认为只要实现“入队、出队、判空”就是完成任务。在考试里能得分,但在工程里远远不够。比如查看队头这个能力,在实现“拉取任务但不立即确认”这类逻辑时非常关键;再比如判满,对于有界队列来说,如果忽略了它,生产者侧就会无限制地往内存或消息中间件里写,最终把系统拖垮。
1.3 为什么不建议在业务代码里直接操作原始数组
刚工作的两三年里,我也喜欢自己手写各种数据结构,图的是“可控”。后来才逐渐意识到,在业务代码里直接操作原始数组实现队列,问题往往不是功能实现不出来,而是边界条件太多。入队时 rear 是否已经越界、出队时 front 是否停在数组中间、数组前面空出来的位置怎么复用,这些一旦处理不严谨,就会在并发场景下产生偶发性的 bug,特别难排查。
所以现在我的习惯是:如果是写业务系统,优先用开发语言标准库或者成熟的队列组件;只有在校验自己对数据结构的理解、或者做一些性能敏感的中间件时,才会自己从底层实现。标准库的好处是经过大量用户测试,边界情况覆盖得很好;自己实现的好处是你能完全掌控内存布局和锁的粒度。两者没有绝对优劣,关键看使用场景,这一点尤其在学了循环队列后会有更深的体会。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从数组假溢出到环状复用,循环队列到底解决了什么
2.1 一个朴素思路的致命伤:元素搬移
用数组表示队列时,最直白的做法是维护两个下标:head 指向队头,tail 指向下一个可写入的位置。入队时往 tail 位置写入,然后 tail 加一;出队时读取 head 位置,然后 head 减一。看起来好像没问题。
可一旦你连续执行几次出队,比如队列容量为 5,入队了 5 个元素,然后出队 3 个,这时候 head 变成了 3,tail 还是 5。表面上看数组还有位置,但新元素如果继续写入,tail 会越界。你可能会说:把剩余元素往前搬一下不就好了?可以,但每次出队或入队都可能触发一次 O(n) 的搬移。
如果这个队列的服务对象是某个写磁盘的任务队列,每次平均几千个元素,搬移造成的影响不会太明显;但要是这个队列每秒处理几万甚至几十万个请求,搬移带来的 CPU 消耗和延迟抖动就是灾难。我处理过的那个线上问题,本质上就是类似原因:队列一直在做整体搬移,导致消费线程拿到的任务有严重的延迟毛刺。
2.2 环形数组的写法与关键边界
要解决空间复用,最简单的办法是把数组从逻辑上“掰弯”,让尾部绕回头部。这就是循环队列。实现时依然用一个数组,但 tail 和 head 在到达数组末尾后,通过取模运算回到 0。
下面是一个用 C 语言实现的循环队列核心代码,大学实验和面试手写里经常用到:
c复制#include <stdio.h>
#define MAX_SIZE 5
typedef struct {
int data[MAX_SIZE];
int front; // 队头下标
int rear; // 队尾下标,始终指向下一个入队位置
} CircularQueue;
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 - 1 个元素。为什么?因为要区分“空”和“满”这两种状态:如果数组可以全部填满,那么当 front == rear 时,既可能是队列空,也可能是队列满,程序会陷入二义性。留一个空位之后,判别条件就很清晰了。
2.3 判空判满的另外两种姿势,以及它们各自的使用边界
上面牺牲一个存储单元来区分空满,代码简单,也是教科书里最常见的方法。但学完它之后,我在实际项目里反而很少直接这么用,更常用的是下面这两种方式。
第一种是加一个 size 字段,记录当前队列中的元素个数。入队成功 size++,出队成功 size--。这时判空就是 size == 0,判满就是 size == capacity。它的好处是不浪费任何一个数组元素,而且语义直观,对阅读代码的同事非常友好。代价是多维护一个字段,在并发场景下,这个字段要保证原子性或者和锁整合在一起,否则会出现计数值和真实数量不一致的问题。
第二种是用一个 boolean 标志位记录最近一次操作是入队还是出队。队满时 front == rear 且最近一次操作是入队,队空时 front == rear 且最近一次操作是出队。这种写法能省一个数组单元,也能省一个 size 字段的更新开销,但逻辑更绕。如果不是在极其追求内存利用率的嵌入式场景,我不推荐为了省一个字段把代码的可维护性丢掉。
2.4 数组实现与链表实现的取舍
数组实现和链表实现是队列最常见的两条路线,谁更优秀还是要看业务场景。我自己在选型时一般有三个判断维度。
- 内存布局:数组是连续内存,遍历和 CPU 缓存命中率更高;链表每个节点单独分配,节点间内存地址离散,加上 next 指针本身要多占额外空间。
- 动态扩缩容:数组满了之后需要扩容,通常是重新分配一块更大的数组并拷贝;链表天然可以动态增删节点,不需要一次性预留大块空间。
- 入队出队常数:两者在入队出队操作上都接近 O(1),但如果面临频繁的随机访问,链表的缓存不友好会让实际性能下降明显。
所以单机场景下的大多数任务队列,我会优先考虑数组实现或基于数组变形的结构。而 Java 的 LinkedBlockingQueue 等链表队列之所以还被大量使用,更多是得益于它们在某些并发设计上的优势,这一点我们后面讲阻塞队列时再说。
3. 链式队列里那个容易被忽略的哨兵节点,是怎么减少并发竞争的
3.1 单向链表实现队列的经典结构
链式队列的实现思路不复杂:front 指向队头节点,rear 指向队尾节点。入队时在 rear 后面追加新节点,然后移动 rear;出队时从 front 位置取下节点,然后移动 front。
但这里有一个值得玩味的细节:如果队列为空,front 和 rear 都指向 NULL,入队操作和出队操作都会遇到空指针分支,需要额外判断。这还不能算大问题,真正麻烦的是在并发场景下,两个线程一个从队头取节点,一个往队尾加节点,为了保证正确性,通常需要一把大锁把整个入队出队过程保护起来。如果头尾在同一把锁的粒度下操作,并发度过高时锁竞争就会拖慢所有线程。
后来我在看 JDK 源码时发现,LinkedBlockingQueue 用了一个巧妙的做法:内部维护一个不存储真实数据的哨兵节点,初始化时让 head 和 tail 都指向这个空节点。因为头节点永远是那个哨兵,队头出队时从 head.next 取数据;队尾入队时把新节点插到 last.next,再移动 last。这样即使队列空,链也不会断,头操作和尾操作在很多情况下可以各用各的锁。
3.2 哨兵节点为什么能帮助并发扩容效率
很多人第一次看到哨兵节点时,会误以为这不过是为了少写几个 if 判断。我在读过源码后越来越觉得,它的价值更多体现在并发设计的简洁性上。
想象一下,如果带头尾都指向 NULL,空队列条件下并发入队和出队,必须去统一维护 front 和 rear 两个指针的可见性,这需要更复杂的同步机制。而有了哨兵节点后,我们可以约定:出队操作只关心 head.next,入队操作只关心 last.next。空队列时的锁竞争压力被分离了,引擎只需要很小的区域做原子操作,就能支持更多生产者和消费者并行执行。
当然,这不意味着链表队列在所有并发场景下都比数组队列好。JDK 里的 ArrayBlockingQueue 也有它的优势:它是一个循环数组,锁粒度更集中,不会因为频繁创建节点带来 GC 压力。两种实现最终怎么选,往往取决于你的业务是“内存里任务排队”更像数组场景,还是“跨网络/跨进程的消息传递”更像链表场景。
3.3 动手写一个基于链表的队列要注意什么
如果你平时写代码需要自己封装一个链表队列,或者你正在做数据结构实验报告,我建议你至少要考虑清楚这几个点:
- 明确头结点是“哨兵”还是“真实数据节点”。这一决定了出队时是否需要处理空队列的特例。
- 出队时要保存待返回节点的值,然后更新 front,最后释放旧节点。顺序别写反,否则容易造成野指针或丢数据。
- 如果队列里只有一个元素,出队后 rear 也要同步处理,否则下一次入队时会丢链。
- 不要在单线程模型里假设所有操作都天然安全。一旦引入多线程,哪怕只是简单的 size 读取,也可能读到过期的旧值。
这些边界问题,我当年写实验报告时几乎每一个都踩过一遍。踩多了就会发现,链表队列的“复杂”不是复杂在增删节点,而是复杂在你如何维护链的完整性和标记状态。数据结构这门课真正在训练的不是背代码,而是你在各种边角条件下保持状态一致的能力。
4. 阻塞队列、线程池和生产者消费者模型,为什么工程里绕不开
4.1 从自旋等待到条件通知,阻塞队列解决的痛感是什么
假设我们不用任何阻塞队列,只用普通队列加锁,让一个消费者线程不断轮询队列里有没有新任务,会怎样?
最粗暴的写法是 while(true) 里不断检查队列是否为空。如果队列一直空,CPU 就被白白烧掉了。稍微优化一点的写法是每次轮询之间 sleep 一下,比如休眠 100 毫秒,可这样任务到达后,消费者最多会延迟 100 毫秒才能感知到,响应性很差。如果 sleep 时间很短,又回到了 CPU 空转的老问题。
阻塞队列的本质,就是为生产者和消费者提供一种“精确通知”的机制。消费者调用 take() 时,如果队列空,当前线程会进入等待状态,而不是继续空转;生产者调用 put() 时,如果队列满,生产者也会被阻塞,等消费者取走一个元素后它才恢复。Java 里 ArrayBlockingQueue 和 LinkedBlockingQueue 都实现了这套能力,底层是用锁和条件变量(Condition)来管理等待唤醒。
我自己写消费者程序时,现在很少手动去写 synchronized 搭配 wait/notify 的代码了。用 BlockingQueue 最大的好处是代码意图非常清楚:你要的是“队列空时消费者睡觉,有数据时被叫醒”,直接调用 blocked 方法就行,不用自己维护一堆等待标志位和循环条件,出 bug 的概率低很多。
4.2 线程池里的任务队列到底该怎么选
线程池和阻塞队列是最经典的搭配之一。核心线程先干活,线程数不够时,新任务会进入任务队列排队,队列也满之后,线程池才会尝试创建更多非核心线程,直到达到最大线程数,再触发拒绝策略。
选择哪一种阻塞队列,直接影响系统在突发流量下的表现。我见过很多同学用 Executors.newFixedThreadPool,却不清楚它默认塞的是一个无界的 LinkedBlockingQueue。一旦任务提交速度超过消费速度,任务会不断堆积,内存占用持续走高,线上表现为 Full GC 越来越频繁,最后整个服务不可用。
下面这张表是我平时做技术方案时习惯参考的:
| 队列实现 | 是否有界 | 行为特点 | 适合场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界 | 基于循环数组,容量固定 | 希望内存占用量有上限,需要快速拒绝或走拒绝策略 |
| LinkedBlockingQueue | 无界/可指定有界 | 链表实现,吞吐不错 | 需要无界排队时可临时用,但要警惕内存堆积 |
| SynchronousQueue | 无界但不在队列中存储 | 消费者必须实时接走任务 | 任务处理很快,不想排队,希望任务直接交给空闲线程 |
| PriorityBlockingQueue | 无界 | 按优先级出队 | 需要紧急任务插队,但要注意无界堆积风险 |
| DelayQueue | 无界 | 延迟时间到后才能取出 | 做延时任务、定时任务重试 |
在真实业务里,我最常用的组合是“有界队列 + 自定义拒绝策略”。这样即使下游处理不过来,也不会让内存无限膨胀,而是触发降级、熔断、或者把任务丢弃到对账系统。毕竟对于大部分业务系统来说,宁可短暂拒绝新请求,也不能让整个进程因内存溢出崩溃。
4.3 别把 PriorityBlockingQueue 理解成“可以先来先得还能插队的神器”
有一个高频面试问题是:线程池能不能用 PriorityBlockingQueue?答案是可以,但需要理解它会改变原本先进先出的行为。
我有一次帮同事 review 代码,他要在线程池里让“用户主动发起的任务”优先于“后台定时触发的任务”执行,于是用了 PriorityBlockingQueue,但 Comparator 没有考虑到两个任务优先级相同时应该按入队先后排序。结果是:同样优先级的任务在队列里顺序错乱,某些任务被延迟了很久。原因很简单,PriorityBlockingQueue 底层是一个堆,它只保证堆顶是最高优先级的元素,并不保证整体顺序按插入时间排列。
所以我的建议是:如果确实需要优先级,写入 Comparator 时尽量把“优先级降序 + 入队序号升序”作为组合排序键。这样既能让高优先级任务先执行,又能保证同优先级下按先来后到出队。如果没有优先级需求,老老实实用 FIFO 队列更符合直觉,也更容易排查问题。
5. 当“消息队列”变成消息中间件,队列的玩法被放大到分布式
5.1 单机队列能力有限时,我们开始在进程之间排队
讲完 JDK 里的队列,很多人可能会有一个疑惑:那我们在后端经常讨论的消息队列,比如 RocketMQ、Kafka、Redis Stream,它们到底跟数据结构里的队列是一回事吗?
在我看来,它们是一件事在不同尺度上的投影。数据结构里的队列,解决的是同一个进程内不同线程之间怎么有序共享任务;消息队列中间件,解决的是不同进程、不同机器之间怎么可靠地把消息从生产者传递给消费者。所以,单机队列关注的是“锁、阻塞、容量、CAS”;消息中间件还要额外关注“网络、持久化、消费进度、故障恢复、重复投递”。
5.2 Redis Stream 是怎么实现“拉取队列消息”的
Redis Stream 是 Redis 5.0 引入的一种支持消费者组的消息模型,它相比 Redis 里传统的 List + BRPOP 方案要完整不少。最核心的一点是它支持消费者组和手动确认(Acknowledgment),而不是消息被读走就立刻丢失。
如果你在 Spring Boot 里用 Redis Stream 拉取消息,典型的流程大概是这样的:
bash复制# 创建消费组,从 0 开始读历史消息
XGROUP CREATE orderStream orderGroup 0
# 生产者往流里追加消息,返回一个消息 ID
XADD orderStream * userId 1001 orderId 9001
# 消费者阻塞拉取,> 表示只读新消息
XREADGROUP GROUP orderGroup consumer-1 COUNT 1 BLOCK 3000 STREAMS orderStream >
# 业务处理完成后确认消息,防止它继续出现在 Pending 列表里
XACK orderStream orderGroup 1700000000000-0
这里很多初学会犯一个错:只执行 XREADGROUP,不执行 XACK。其实拉取成功只代表消息被“交付”给了消费者,并不代表消息已经被处理完。如果不确认,消息会一直留在消费者组的 Pending Entries List 中,当消费者异常重启后,这些未确认的消息会被重新投递给其他消费者。
Spring Boot 层面处理时,通常用一个单独的线程池去执行 XREADGROUP,拿到消息后进入业务处理,处理成功再调 XACK 回调;处理失败可以根据重试次数决定是继续尝试,还是放到另一个专用队列等待人工处理。
5.3 重复消费问题的本质,不靠“只拉一次”解决
提到消息队列,一定会聊到重复消费。很多人第一反应是:能不能让中间件保证一条消息只投递一次?理论上可以做一些机制,但在分布式环境下,要做到恰好一次(exactly once)会带来巨大的性能开销,主流中间件基于性能考虑,通常提供的是至少一次(at-least-once)。
什么意思?就是消息可能会重复,但不会丢。重复出现在什么情况下?通常是消费者已经处理完消息,但在确认消息时网络抖动,导致还没来得及 XACK,消费者就和 Redis 断开连接。消息会进入待转移队列,被另一个消费者再次取走,于是同一笔业务被处理了两次。
如果这个业务是“给用户扣款”或者“发放优惠券”,处理两次就会出大问题。所以消费端必须保证业务处理的幂等性。我现在做设计时,通常会为每条消息设置一个唯一的幂等键,业务处理前先查一张幂等表或者在数据库里用唯一索引约束。如果发现这个幂等键已经处理过了,直接返回成功,不再执行真正的扣款、发券或下单逻辑。
还有一个容易被忽略的点:不要把“确认机制”和“幂等机制”混淆。确认机制解决的是消息不丢失,幂等机制解决的是重复不产生负面影响。哪怕你把中间件配置得再精确,只要存在网络不可靠,消费端幂等就是一道必须做的安全兜底。
6. 队列的变种家族,双端队列、优先队列、延迟队列和单调队列
6.1 双端队列:把头和尾都变成可以操作的入口
生活中排队通常是不允许从队尾插队的,但在计算机世界里,我们常常需要“两端都能操作”的队列,这就是双端队列(Deque)。它支持从队头入队、队头出队、队尾入队、队尾出队,四个方向的组合让它可以扮演普通队列、栈甚至更灵活的滑动窗口容器。
双端队列应用场景非常多。比如编辑器的撤销重做功能,用户每次操作可以看作一个节点,撤销时从尾部弹出,重做时再把弹出的节点塞回去,这种天然的栈行为用 Deque 实现非常顺手。另一个让我印象深刻的场景是浏览器历史记录的前进后退,本质上也是双向栈。再比如滑动窗口内求最大最小值时,如果没有 deque,就必须维护一个有序集合,而用双端队列可以高效地淘汰过期元素。
6.2 优先队列和延迟队列:有人要插队、有人要“踩点出发”
优先队列打破了 FIFO 的纪律,它不再按入队顺序出队,而是按优先级出队。底层的常见实现是二叉堆,每次出队都能在 O(log n) 时间内拿到当前集合里优先级最高的元素。这个结构在 Dijkstra 最短路径、任务调度、TopK 问题里是绝对主力。
与优先队列有关系但经常被单独提起的是延迟队列。Java 里的 DelayQueue 本质上就是用一个优先队列存储元素,并用元素的延迟时间作为排序键。当消费者执行 take() 时,只有队首元素的延迟时间已经到期,它才会被取出,否则消费者会继续等待。这非常适合做“超时关单”“延迟重试”之类的场景。比如一个订单超过 30 分钟未支付,不需要用一个定时任务每分钟扫全表,而是把订单放进延迟队列,等到 30 分钟后触发检查即可。
我自己的经验是:延迟队列解决的是“有时间先后顺序的大量任务”,它比轮询要精准得多,但要防止每个元素都保留过长引用,导致内存里积压太多“还没到点”的对象。
6.3 单调队列:滑动窗口最大值的优化利器
最后提一个看起来很“竞赛向”,实际工程里也会用到的变种——单调队列。它的核心思想是:维护队列内元素从队头到队尾严格单调递减或递增,凡是入队时破坏单调性的元素,直接弹出。
比如求一个数组中窗口大小为 k 的滑动窗口最大值,最容易想到的方法是每滑动一次窗口,就在 k 个元素里遍历找最大值,复杂度是 O(nk)。如果用单调队列,每个元素最多入队一次、出队一次,总复杂度可以降到 O(n)。
python复制from collections import deque
def maxSlidingWindow(nums, k):
dq = deque()
result = []
for i, num in enumerate(nums):
# 把当前元素加入前,先清掉所有比它小的队尾元素
while dq and nums[dq[-1]] <= num:
dq.pop()
dq.append(i)
# 队头如果已经滑出窗口,淘汰
if dq[0] <= i - k:
dq.popleft()
# 窗口完整时记录最大值
if i >= k - 1:
result.append(nums[dq[0]])
return result
我用类似代码处理过实时监控面板上“最近一分钟 CPU 最大值”的统计:上游每 5 秒上报一次指标,消费端实时维护一个固定窗口,每次只需要 O(1) 时间就能得到窗口内的最大值,而不必把窗口里的每个点都读出来重新算。很多初学数据结构的同学觉得单调队列只是面试题里的花活,但当你做时序数据的窗口统计时,它真的能救你于水深火热之中。
7. 队列选型时我会问自己的几个问题,以及一些来自实践的小提醒
7.1 五连问,快速定位该用哪种队列
这几年在处理各种队列相关问题时,我慢慢形成了一个习惯:不急着抄别人方案,先问自己几个问题。
- 第一问:这是在单个进程内部,还是要跨进程?单机直接用语言标准库的阻塞队列,跨进程就要上消息中间件或 Redis Stream。
- 第二问:队列允许无限堆积吗?如果不允许,必须选有界队列,并设计好拒绝之后的兜底策略。
- 第三问:消费者失败后,消息能不能丢?不能丢就要有持久化和确认机制。
- 第四问:消息处理的先后顺序重要吗?重要就尽量保持在同一个队列/分区内消费,避免多消费者并发打乱顺序。
- 第五问:消息是否可以重复处理?如果可以接受重复,那不用做额外处理;如果不行,必须准备幂等表或唯一键约束。
这套问题基本能覆盖从数据结构基础到分布式消息中间件的大部分场景。不管是在一张白纸上做设计,还是面试时被问到“你怎么为这个业务选队列”,回答清楚这五个问题,逻辑都是连贯且有说服力的。
7.2 关于队列的几个容易踩的坑
队列相关的坑,我在不同阶段踩过很多。简单总结几条,也算是我认为常规文档里不会写得太明白的经验。
第一,不要让队列里堆放大体积对象。一个订单对象可能很小,但如果你把整个大 JSON、大文件内容都塞进队列,内存压力会成倍增加。生产者最好做一层裁剪或对象转换,让队列里只保留业务必要字段。
第二,监控队列积压非常关键。我通常会给核心队列加两个监控指标:当前队列滞留数量和平均等待时间。这个比看线程数更直观。一旦发现滞留数量增长趋势明显,就要马上检查下游消费能力,而不是等线上响应时间报警了才去排查。
第三,在线定位问题的时候,优先看线程堆栈。如果发现很多线程长期 WAITING 在 LinkedBlockingQueue.take() 方法上,代表队列在有意识地让消费者休息,不一定是故障。如果生产者在 put() 方法上大量阻塞,那才说明队列已经满了,要么消费不过来,要么队列设置得太小。
第四,用 ConcurrentLinkedQueue 时要小心,它虽然线程安全,但 size() 方法是一个相对耗时的操作,需要遍历节点,频繁调用会拖垮性能。如果你真的需要“当前积压了多少任务”这种信息,最好自己维护一个计数器,或者干脆选用提供了 O(1) 大小获取能力的阻塞队列。
数据结构里,队列是入门级的内容,但它从数组到链表、从阻塞队列到消息中间件,贯穿了并发编程和分布式系统的很多核心思路。我记得自己刚工作那会儿也觉得自己什么队列都懂了,直到真出了线上问题,才发现对实现细节和边界条件的理解还不够。后来每次再遇到和队列相关的选择,我都会退回去想一想:它是单机线程模型还是跨进程模型?要不要阻塞?确认机制怎么设计?重复该怎么办?把这些想透了,代码写起来才会稳。希望这篇文章能帮你少走一些我走过的弯路。
