☰
从C循环队列到Java阻塞队列,一文搞懂队列原理与生产级应用
2026/10/5 4:14:19 网站建设 项目流程

早上八点的食堂,排队打饭的人从窗口一路排到门口,先来的先打上,后来的排后面,这就是最朴素的“队列”。计算机世界里的队列,跟这个场景几乎一模一样:先进来的元素先出去,先进先出(FIFO)。但真正把它落到代码里,尤其是用C语言和Java分别实现一遍之后,你会发现这里面的门道比想象中深得多。数组队列的“假溢出”、循环队列的判空判满、Java集合框架里那些并发队列的选择,再到消息队列的重复消费问题,每一步都是面试题和线上事故的高发区。

这篇文章不是教科书式的堆概念,而是把队列从底层实现到生产级应用完整串一遍。我会先用C语言手写一个能跑的循环队列,把边界条件和坑点一个个说清楚;再用Java实现同样功能,并对比集合框架里ArrayDeque、LinkedList、BlockingQueue这些真实选手的取舍;最后聊到线程池的队列选型,以及消息队列重复消费这个在分布式系统里最常见的难题。适合正在学数据结构、准备面试、或者写业务代码时想搞懂队列背后原理的朋友。

1. 队列的本质:先进先出的抽象,以及数组队列为什么容易“假溢出”

1.1 队列的基本操作与逻辑结构

在动手写代码之前,先把队列这个抽象模型理清楚。队列的逻辑结构就是一组按顺序排列的元素,只允许在一端插入(队尾rear),在另一端删除(队头front)。基本操作就四个:入队、出队、判空、判满。听起来简单,但它和栈有一个关键区别:栈是后进先出,只需要一个栈顶指针就能操作;队列涉及两端,至少需要两个指针或者一个计数器来标记位置。

很多人在初学阶段会问:栈和队列到底应用场景差在哪?栈适合解决嵌套和回溯问题,比如函数调用、括号匹配、表达式求值;队列适合解决顺序和缓冲问题,比如任务调度、流量削峰、广度优先搜索。理解了这两类场景,你就知道为什么操作系统、网络协议、线程池、消息中间件里到处都是队列了,因为它本质上是“排队等待”这个动作的抽象。

1.2 顺序队列的数组实现:看起来简单,用起来就崩

先看一段最直接、也最容易出问题的C代码:用数组存队列元素,front指向队头,rear指向队尾的下一个位置。

#include <stdio.h> #include <stdbool.h> #define MAX_SIZE 5 typedef struct { int data[MAX_SIZE]; int front; int rear; } SeqQueue; void initQueue(SeqQueue *q) { q->front = 0; q->rear = 0; } bool enQueue(SeqQueue *q, int value) { if (q->rear == MAX_SIZE) { return false; // 队列已满 } q->data[q->rear] = value; q->rear++; return true; } bool deQueue(SeqQueue *q, int *value) { if (q->front == q->rear) { return false; // 队列为空 } *value = q->data[q->front]; q->front++; return true; }

这段代码能跑,但有个致命缺陷。假设数组容量是5,连续入队ABCDE,队列此时是满的。随后出队AB,front变成2,rear是5。队列里实际还剩CDE三个元素,但此刻如果想再入队F,enQueue会直接返回false,因为rear已经顶到数组末尾了。数组前两格明明空着,却不能用,这就是经典的“假溢出”问题。

假溢出的本质是:rear指针只能往后走,不能回头。即使前面有空间,rear也不会回收利用,因为线性数组的逻辑尾巴到头了。这时候我们会想到两种解决办法:一是不用数组,改用链表存储队列元素,出队就删除头节点,入队就挂到尾部,空间不连续也没关系;第二种更优雅,就是把数组首尾相接,逻辑上变成一个环,rear绕一圈回到数组头部继续用,这就是循环队列。

1.3 为什么要优先学循环队列而不是一上来就写链表

有人会问:链表队列不也能解决问题吗,为什么面试官和教材都爱考循环队列?我个人理解,循环队列的价值在于“用连续内存空间模拟无界延伸的逻辑结构”。数组在内存中是连续存放的,CPU缓存的局部性好,读写性能通常比散落的链表节点更高;而且数组不需要为每个节点额外分配内存和指针,系统开销小。在生产环境里,无锁队列、RingBuffer这些高性能组件,底层几乎都是环形数组的实现。

但循环队列也有自己的难题,最典型的就是“空和满的判定”。因为是环形结构,front和rear相遇可能表示队列为空,也可能表示队列满了。为了区分这两种状态,业界有几种成熟策略,我放到下一章讲C语言实现时细说。这一章先把概念立住:循环队列不是简单的数组下标加减,而是通过取模运算让一组有限空间无限复用起来。理解了这个动机,后面所有代码细节都顺理成章。

2. C语言手写循环队列:完整可运行代码与边界条件排查

2.1 三种判空判满策略的取舍

C语言实现循环队列,核心就一句话:所有下标移动都要对队列容量取模。设数组容量为capacity,插入时rear = (rear + 1) % capacity,删除时front = (front + 1) % capacity,这样当rear或front走到数组末尾时,就自然绕回开头,形成一个环。

但取模解决的是“环形”问题,还没解决“空与满”的判定。当front == rear时,既可能空也可能满。常见方案有三种:

策略判空条件判满条件特点
浪费一个存储位front == rear(rear + 1) % capacity == front简单,但数组中始终有一个位置不能用
设置计数器countcount == 0count == capacity逻辑直观,需要额外的变量维护入队出队次数
设置标志位tagfront == rear且tag为0front == rear且tag为1在front==rear基础上增加一个标志位区分最后操作是出队还是入队

我的建议是:理解三种都用,但动手写的时候优先用计数器count。因为它最直观,也不会绕晕,面试时你还能顺便讲一讲另外两种方案的优劣,显得有深度。下一节就给完整代码。

2.2 用count计数器的循环队列完整实现

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct { int *data; int capacity; int front; // 指向队头元素 int rear; // 指向下一个可插入位置 int count; // 当前队列中的元素个数 } CircularQueue; bool initQueue(CircularQueue *q, int capacity) { q->data = (int *)malloc(sizeof(int) * capacity); if (q->data == NULL) { return false; } q->capacity = capacity; q->front = 0; q->rear = 0; q->count = 0; return true; } bool isFull(CircularQueue *q) { return q->count == q->capacity; } bool isEmpty(CircularQueue *q) { return q->count == 0; } bool enQueue(CircularQueue *q, int value) { if (isFull(q)) { printf("队列已满,无法入队 %d\n", value); return false; } q->data[q->rear] = value; q->rear = (q->rear + 1) % q->capacity; q->count++; return true; } bool deQueue(CircularQueue *q, int *value) { if (isEmpty(q)) { printf("队列为空,无法出队\n"); return false; } *value = q->data[q->front]; q->front = (q->front + 1) % q->capacity; q->count--; return true; } int queueLength(CircularQueue *q) { return q->count; } int main() { CircularQueue q; initQueue(&q, 5); enQueue(&q, 10); enQueue(&q, 20); enQueue(&q, 30); int value; deQueue(&q, &value); printf("出队: %d\n", value); // 10 enQueue(&q, 40); enQueue(&q, 50); enQueue(&q, 60); enQueue(&q, 70); // 满了,返回false while (!isEmpty(&q)) { deQueue(&q, &value); printf("出队并输出: %d\n", value); } free(q.data); return 0; }

这段代码的要点有三个。第一,data是动态分配的,初始化时传入容量,这样队列大小可以由调用方决定;第二,rear指向的是“下一个可插入位置”,所以入队时先赋值再移动rear,出队时先取值再移动front;第三,count在入队出队时同步更新,成为判断空满的唯一依据。

我在测试这个代码时特意让容量为5,但只入队了5个元素,第六次入队就失败,这符合预期。但要注意:因为浪费了count策略不需要牺牲空间,容量为5时实际能存满5个元素,这与“浪费一个位置”的写法不同,后者容量为5时实际只能用4个坑位。这个区别在面试里经常被追问,你自己写的时候也不要搞混。

2.3 热搜题“rear和length指示环形队列”的front推导

在考研和面试题里,经常出现这样一个描述:假设以数组q[m]存放循环队列中的元素,同时以rear和length分别指示环形队列中的队尾位置和当前队列长度。这种写法不给你front,而是让你用rear和length算出队头在哪里。

计算公式是:front = (rear - length + m) % m。为什么是这个公式?因为rear指向队尾元素的下一个位置(这种约定下),队列中一共有length个元素,它们在环形数组里排在rear之前。依次往前数length个位置,就是队头的下标。但直接减length可能变成负数,C语言的%运算对负数结果是负的,所以要先加上m再取模,保证结果落在[0, m-1]范围内。

// 假设数组q[M],元素个数为len,rear指向队尾下一个位置 #define M 8 int getFrontValue(int q[], int rear, int len) { if (len == 0) { return -1; // 队列为空 } int front = (rear - len + M) % M; return q[front]; }

这里有个容易踩坑的细节:当len等于M时,front的计算结果等于rear,代表队列满了;当len为0时,front也等于rear,代表空。区别全靠len这个长度值,这也是为什么“rear+length”表达法的考频这么高,因为它天然规避了“牺牲一个存储位”的尴尬。做题时先判断len是0还是M,再套公式,基本不会错。

2.4 写C队列最容易踩的四个坑

第一,取模溢出的负数问题。计算front = (rear - length + m) % m时,如果忘了加m,遇到rear小于length的情况就会得到负数下标,访问数组时直接越界,C语言不会帮你报错,只会在运行时产生未定义行为。写完代码强烈建议编译时开-Wall,再用asan检查内存访问。

第二,队列容量和元素个数混为一谈。初始化时动态分配的capacity是总格子数,但用“浪费一个位置”方案时,实际最多只能放capacity - 1个元素。有的同学在判断满的时候写rear + 1 == front,这个判断在环形结构下是错的,必须写成(rear + 1) % capacity == front。

第三,头尾指针的语义不一致。有的人代码里front指向队头元素,rear也指向队尾元素;有的人front指向队头元素,rear指向队尾的下一个位置。两种写法都能工作,但判空判满、入队出队的顺序完全不同。建议固定使用书中常见约定:front指向队头元素,rear指向下一个插入位置。

第四,忘记释放动态内存。我见过不少初学者写队列,malloc之后就在主函数里跑完不管了,内存泄露肉眼看不见,但程序长时间运行就会出问题。C语言实现数据结构,务必在main函数结束前free掉所有malloc出来的内存,再用Valgrind检查一遍。

3. Java实现队列的三种形态:手写代码、集合框架与并发场景

3.1 用Java手写一个泛型环形队列

Java手写队列和C语言很像,但有几个天生优势:有数组越界检查、有GC回收不需要手动释放内存、可以用泛型让队列支持任意类型。我用泛型写一个简单版本,关键逻辑和C语言几乎一一对应。

public class ArrayQueue<T> { private final Object[] items; private final int capacity; private int head; private int tail; private int size; public ArrayQueue(int capacity) { this.capacity = capacity; this.items = new Object[capacity]; } public boolean offer(T value) { if (size == capacity) { return false; } items[tail] = value; tail = (tail + 1) % capacity; size++; return true; } @SuppressWarnings("unchecked") public T poll() { if (size == 0) { return null; } T result = (T) items[head]; items[head] = null; // 帮助GC回收 head = (head + 1) % capacity; size--; return result; } public boolean isEmpty() { return size == 0; } public int size() { return size; } }

Java版本用的是Object数组加泛型强转,这是Java泛型擦除机制下的常规做法。值得注意的一个细节:poll方法取出元素后,我显式把items[head]设为null。这一点在Java里尤其重要,因为如果不置空,数组会一直持有已出队对象的引用,当队列容量很大、生命周期很长时,可能造成内存无法被GC回收,也就是所谓的“对象滞留”。

3.2 集合框架里的队列:LinkedList、ArrayDeque怎么选

在实际生产代码里,没人会像上面那样手写队列。Java集合框架已经提供了成熟的Queue和Deque实现,最常用的是LinkedList和ArrayDeque。两者都实现了Queue接口,但底层结构完全不同。

对比项ArrayDequeLinkedList
底层结构循环数组,动态扩容双向链表
访问头尾元素O(1),性能极好O(1),但需要创建节点对象
中间插入删除不支持,只支持两端操作O(n)
null值支持不允许允许
内存占用连续空间,更紧凑每个元素有额外指针开销
FIFO场景推荐度强烈推荐一般,除非你需要链表特性

我个人在实际项目中,凡是只当作队列用的场景,一律优先ArrayDeque。原因很简单:它底层是数组,内存连续,遍历和头尾操作的性能都好;LinkedList每个节点都是new出来的对象,GC压力大,而且null值支持通常是坏味道,说明代码里没处理好空值边界。

3.3 一个典型的队列实践:广度优先搜索按层遍历

队列在算法里的黄金搭档是BFS。二叉树按层遍历、图的最近路径、迷宫最短步数,都可以用队列实现。以二叉树按层遍历为例,核心代码不需要三行就能写完:

public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) { return result; } Queue<TreeNode> queue = new ArrayDeque<>(); queue.offer(root); while (!queue.isEmpty()) { int levelSize = queue.size(); List<Integer> level = new ArrayList<>(); for (int i = 0; i < levelSize; i++) { TreeNode node = queue.poll(); level.add(node.val); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } } result.add(level); } return result; }

这里有个经验之谈:在while循环里先记录int levelSize = queue.size(),再在for循环里处理这一层,这样才能做到按层分组。如果不记录,每一轮poll的长度会动态变化,层边界就丢了。这也是面试官最爱让你手写的一段代码,建议背到肌肉记忆程度。

3.4 Java队列家族里容易忽略的两个成员

除了LinkedList和ArrayDeque,Java还有两个有特色的队列:PriorityQueue和ConcurrentLinkedQueue。

PriorityQueue是基于堆结构实现的优先队列,出队顺序由元素的自然顺序或Comparator决定,不是严格FIFO。它在任务调度里很有用,比如每次取出优先级最高的任务。一个经典坑点是:PriorityQueue允许无界增长,默认容量11,满了会扩容,如果用在线程池任务队列里且任务量大,会有内存压力。

ConcurrentLinkedQueue是线程安全的无界队列,基于CAS无锁算法,并发性能很高。但它不适合做“有界阻塞队列”,因为它没有阻塞能力——队列为空时poll直接返回null,而不是让线程等待。很多同学面试时把它和BlockingQueue搞混,这个区别要明确:ConcurrentLinkedQueue是并发安全的非阻塞队列,BlockingQueue是并发场景下的阻塞队列,两者定位不同。

4. 阻塞队列与线程池选型:有界无界、拒绝策略与线上OOM

4.1 BlockingQueue的四种等待语义

BlockingQueue是Java并发编程里最重要的队列接口,它给Queue增加了阻塞能力。核心操作可以分成四组:

  • put(E e):往队列尾部插入元素,如果队列满就一直等待,直到有空间。
  • take():从队列头部取出并移除元素,如果队列空就一直等待,直到有元素。
  • offer(E e, long timeout, TimeUnit unit):带超时时间的插入,超时就返回false,不会无限阻塞。
  • poll(long timeout, TimeUnit unit):带超时时间的取出,超时返回null,不会无限阻塞。

这四组操作分别应对“需要等待直到成功”和“最多等多久”两类需求。用餐厅等位的比喻来说:put是“没位子就在门口死等”,offer(timeout)是“我最多等到8点,等不到我就走了”,take是“菜没好就一直等”,poll(timeout)是“最多等5分钟,没有就先撤”。

BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(100); // 生产者:队列满时阻塞 new Thread(() -> { int id = 0; while (true) { try { queue.put(id++); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start(); // 消费者:队列空时阻塞 new Thread(() -> { while (true) { try { int value = queue.take(); System.out.println("消费: " + value); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start();

这段代码是生产者和消费者模式的经典骨架。注意捕获InterruptedException后要重置中断标志位,也就是Thread.currentThread().interrupt(),这是Java并发编程中容易被忽略的礼貌习惯。如果不重置,外部线程想通过interrupt通知这个线程停止时,中断状态会被吞掉,线程可能不会如期退出。

4.2 常见阻塞队列的适用场景对比

实现类特性典型场景
ArrayBlockingQueue有界,数组结构,容量固定资源受限的生产消费,比如数据库连接池
LinkedBlockingQueue可指定有界,也可无界,链表结构线程池默认队列,任务缓冲
SynchronousQueue容量为0,put直接交接给take无缓冲直传,CachedThreadPool的默认队列
PriorityBlockingQueue无界,按优先级出队需要优先处理高优先级任务
DelayQueue无界,元素到延迟时间才能取出定时任务、缓存过期、订单超时关闭

SynchronousQueue是最反直觉的一个。它内部不存任何元素,生产者插入数据时必须等一个消费者来拿,否则就阻塞。它适合“一个任务来,一个线程立刻处理”的场景,不希望有任何中间缓冲。线程池newCachedThreadPool用SynchronousQueue就是基于这个考虑,任务来了立刻让线程处理,没有就创建新线程,空闲线程超过60秒回收。

4.3 线程池的队列怎么选,我踩过的坑

写过Java业务系统的人多少都踩过线程池的坑。最典型的是用Executors.newFixedThreadPool,它默认使用无界的LinkedBlockingQueue。问题在于:无界队列意味着任务可以无限堆积,当生产者速度快于消费者,队列会一直膨胀,最终导致内存OOM。

我自己遇到过真实的线上事故:有一个报表生成接口,没有限制并发,调用方批量提交任务到固定线程池,结果高峰期任务在LinkedBlockingQueue里堆积了几十万个,直接把服务内存撑爆,应用反复重启。后来改成了有界阻塞队列,比如new ArrayBlockingQueue<>(200),配合ThreadPoolExecutor的拒绝策略,任务多的时候直接抛出RejectedExecutionException,由上游重试或者降级,服务稳定了很多。

所以我的选型建议是这样:团队没有深入调优经验时,不要用Executors的快捷方法创建线上线程池,而是new ThreadPoolExecutor手动指定参数。核心线程数、最大线程数、非核心线程存活时间、有界队列容量、拒绝策略五项参数都写清楚。有界队列的容量可以参考峰值QPS与服务单任务最大耗时估算:容量 = 峰值每秒任务数 × 单个任务预计最长等待时间。

4.4 队列满了之后:四种拒绝策略的原理与取舍

线程池的拒绝策略,是线程池处理不了新任务时的最后防线。内置四种策略:

  • AbortPolicy:默认策略,直接抛RejectedExecutionException。
  • CallerRunsPolicy:让提交任务的线程自己跑这个任务,比如线程A向线程池提交任务,线程池满了,线程A就自己去执行任务。
  • DiscardPolicy:静默丢弃,不提示,很容易埋坑。
  • DiscardOldestPolicy:丢弃队列头部的任务,再把新任务加入,通常配合有界队列,让新任务有机会处理。

我个人的实际经验是:核心业务用AbortPolicy,配合catch异常做告警和补偿;非核心、可降级的任务用CallerRunsPolicy,既能降低线程池压力,又不会丢任务。DiscardPolicy和DiscardOldestPolicy用得较少,因为静默丢弃在线上排查问题时非常痛苦,你不知道哪个任务被丢了。

5. 从队列到消息队列:重复消费问题为什么是系统设计的必考题

5.1 消息队列本质还是队列,但多了系统级承诺

讲完数据结构里的队列,再讲分布式消息队列,你会觉得一切都顺了。消息队列产品(比如Kafka、RocketMQ等)依然是先进先出的语义,但它解决的是另一个层级的问题:跨进程、跨机器、跨网络传输消息时的缓冲、解耦与削峰。

举个例子,用户下单后,系统需要发短信、扣库存、更新积分。如果这些步骤全在下单接口里同步完成,任何一个下游慢都会拖垮主流程。用消息队列后,下单成功只需要往MQ里发一条消息就立刻返回,短信和积分系统各自订阅消息异步处理。这是消息队列最经典的应用价值。

但消息队列和内存队列有个显著区别:它涉及网络传输、持久化、故障恢复,因此必须引入“投递确认”“消费位点”这些概念。一旦有了这些概念,就会催生一个特别经典的问题:重复消费。

5.2 重复消费是怎么产生的

重复消费的根源在于消息系统普遍采用at-least-once“至少一次”的投递语义:为了保证不丢消息,要么生产者重试,要么消费者重试。我整理一下最常见的三个产生场景:

第一,消费者处理完消息、但还没来得及提交位点时就崩溃了。消息队列会认为这条消息还没消费完,等消费者恢复后重新投给它,于是同一条消息处理了两次。

第二,网络抖动导致消费者侧成功提交了位点,但提交确认的响应没有送回broker。broker判断超时,再次投递消息。

第三,生产者发送消息时因为网络超时而重试,结果同一条业务数据被发送了多次,消息队列存储了多个副本。

这里要明确一点:重复消费不是bug,而是分布式系统中为了“不丢消息”付出的代价。想消除它,正常做法不是靠消息队列去重,而是消费端自己保证幂等。

5.3 幂等性:应对重复消息的唯一正确答案

所谓幂等,就是同一个业务操作执行一次和执行多次的结果完全一样。设计上常见三种思路。

第一种,利用数据库唯一键约束。比如处理订单消息时,业务表里有一个带唯一索引的业务单号,重复插入时数据库报错,捕获后视为已处理。这是最省事、最可靠的做法。

第二种,维护一个“已处理消息表”。消费时先查询这张表,如果消息ID已存在就跳过,不存在则处理业务并同步记录消息ID。写入消息表和执行业务逻辑必须在同一个事务中,否则先写入消息表再说业务失败,会漏消息。

第三种,业务本身带状态机。比如“订单已支付”这个操作,如果订单状态已经是“已支付”,再次收到支付成功消息时直接忽略。这种方式适合状态可判定的场景,比如物流状态、工单状态。

我推荐的实际组合是:数据库唯一键兜底 + 消息表记录,双保险。曾经有一次我们只用了消息表去重,结果消息表写入成功、业务执行成功后事务提交失败,消息被重复投递,业务里又没有唯一键约束,差点造成两条重复的财务流水。从那以后,凡涉及金额、库存、积分这种敏感数据的消息处理,我必做唯一约束。

5.4 三者的边界:Queue、BlockingQueue、Message Queue别混为一谈

回答一个被问烂的面试问题:Java中的Queue,和BlockingQueue,和消息队列MQ,有什么区别?

一句话总结就是层级不同。Queue是最基础的数据结构抽象,定义先进先出的访问规则;BlockingQueue在Queue基础上增加了线程安全与阻塞等待能力,解决多线程环境下的生产者消费者问题,作用范围是单个JVM进程;Message Queue是跨进程的架构组件,包含网络通信、持久化、集群容错、重试与幂等等一系列分布式问题。理解了这三个层次,你在设计系统时就知道该用哪个。

我还想补充一点:真正面试深了,面试官可能会问“如何实现一个简单的消息队列”。这时你可以从BlockingQueue入手搭建一个内存版MQ:生产者和消费者通过LinkedBlockingQueue通信,消息里带上id和业务数据,再加一个延迟队列做重试。这种从小到大的实现顺序,能系统展示你对队列的掌握程度。

结语之外,再分享一点我的实际体会

我个人最初学队列的时候,觉得这就是个先进先出的容器,代码抄抄就会,没什么好研究的。后来在真正的业务系统里,因为线程池队列选型不当导致OOM、因为消息重复消费导致数据异常,我才意识到:队列这种最基础的数据结构,恰恰是性能调优和分布式一致性的主战场。每一次线上问题,蹬根到底,往往都能追溯到某个“队列边界条件”想当然了。

如果你也想把队列吃透,我的建议很简单:先别急着背源码,把C语言循环队列的代码自己写三遍,第一遍抄也好,第二遍遮住答案默写,第三遍不看任何参考改bug跑通。能做到这一层,你再看Java的ArrayDeque源码、BlockingQueue的put/take逻辑,会发现全是似曾相识的东西。从这个基础再往消息队列、无锁队列、双端队列扩展,你的队列功底会比大多数人扎实得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询