ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从C到Java:队列实现、阻塞队列与消息队列实战解析

从C到Java:队列实现、阻塞队列与消息队列实战解析 队列大概是我见过的最“表里不一”的数据结构。表面上看就四个字——先进先出真让你用C语言徒手写一个循环队列或者用Java实现一个线程安全的阻塞队列很多人当场卡壳。这篇文章不是来背概念的而是把队列的操作和实现从C到Java完整走一遍再延伸到生产环境里那堆真正的队列线程池用的阻塞队列、消息队列Kafka/RabbitMQ/RocketMQ的选型与避坑。适合三类人看准备面试的、刚学完C语言想过度到Java的、写着业务代码但一直没搞懂队列底层的人。1. 队列是什么从生活场景到代码世界1.1 FIFO规则和四个核心操作队列叫Queue是一种操作受限的线性结构。线性结构大家熟悉数组、链表都是关键在“操作受限”这四个字——你只能在队尾追加元素只能从队头取出元素中间的元素既不能插队也不能抽走。这个规则就叫FIFOFirst In First Out先进先出。生活里排队买奶茶就是典型队列先来的人先拿单后来的站后头。程序里最常见的例子是打印机任务队列你点了打印文档不会瞬间全部输出而是按提交顺序排队一个一个来。再比如操作系统的进程调度、网络数据包缓冲、BFS广度优先搜索底层全是队列。队列的四个核心操作必须烂熟push/offer/enqueue入队往队尾加元素pop/poll/dequeue出队从队头取元素并移除front/peek只看队头元素不移除empty/isEmpty判断队列是否为空很多C语言教材里习惯叫enqueue和dequeueJava里则是offer和poll称呼不同说的都是同一件事一端进一端出严格按顺序来。理解这四点后续手写代码就不会跑偏。提示队列和栈最大的区别就在“出口位置”。栈是同一端进出先进后出FILO队列是一端进另一端出先进先出FIFO。面试问到区别一句话答清楚就够了。1.2 谁需要手写队列面试、底层原理、生产认知总有人问JDK里明明有LinkedList、ArrayDeque、LinkedBlockingQueue为什么还要自己写一遍我的答案是分三种情况看。第一种是面试场景。C语言岗位笔试题经常让手写一个队列考察指针操作、内存管理、边界条件Java岗位则喜欢问线程池的阻塞队列怎么选、add和offer有什么区别。这些题考的不是你会不会调API而是你有没有真正理解队列的实现机制。你连front和rear指针的移动逻辑都说不清人家怎么相信你能写出可靠的生产代码。第二种是底层原理需求。学操作系统要理解消息缓冲队列学网络要理解数据包排队学Java并发要理解AQS里的等待队列。这些场景里队列不只是一个“容器”它本身就是并发控制的核心机制。你只有自己实现过队列看到Condition的await和signal时才不会懵因为那本质就是“线程版的入队和出队”。第三种是生产认知需求。每天写业务代码的人可能只用过new ArrayList和Redis列表但一旦遇到削峰、解耦、异步处理就得面对真正的消息队列。Kafka、RabbitMQ、RocketMQ再复杂核心思想还是那个FIFO队列只是加上了分布式、持久化、高可用这些工业级能力。知道基础队列长什么样才能理解那些“增强版”到底增强了什么。反正我的观点是手写队列不是为了重复造轮子是为了让你的知识有落点。代码写一遍比看十遍理论都管用。2. C语言实现数组和链表两种思路的完整代码2.1 循环队列一块固定内存如何反复利用先用最简单的方式理解数组实现队列。开一个数组data[capacity]head指向队头下标tail指向队尾的下一个空位。入队时往data[tail]写数据然后tail出队时从data[head]取数据然后head。问题很快就来了如果一直让两个下标往右走前面的位置空出来也浪费数组用一会儿就“假溢出”了。解决办法是让tail和head在到达数组末尾时回绕到0这就是循环队列。回绕的写法是取模运算tail (tail 1) % capacity;比如capacity 5tail走到4之后下一个位置是(4 1) % 5 0又回到开头。整个过程数组空间被反复利用不再需要搬移元素。但循环队列有个经典难题空和满的判断。如果你用front rear判断空那满的时候rear转一圈回到front也是front rear程序分不清到底是空还是满。破解办法常用的有三种其中用size字段记录元素个数最直观我推荐这个#include stdio.h #include stdlib.h #include stdbool.h typedef struct { int *data; int capacity; int head; int tail; int size; } Queue; void queue_init(Queue *q, int capacity) { q-data (int *)malloc(sizeof(int) * capacity); if (!q-data) { exit(EXIT_FAILURE); } q-capacity capacity; q-head 0; q-tail 0; q-size 0; } bool queue_is_empty(Queue *q) { return q-size 0; } bool queue_is_full(Queue *q) { return q-size q-capacity; } bool queue_push(Queue *q, int value) { if (queue_is_full(q)) { return false; } q-data[q-tail] value; q-tail (q-tail 1) % q-capacity; q-size; return true; } bool queue_pop(Queue *q, int *out) { if (queue_is_empty(q)) { return false; } *out q-data[q-head]; q-head (q-head 1) % q-capacity; q-size--; return true; } void queue_destroy(Queue *q) { free(q-data); q-data NULL; q-capacity 0; q-head q-tail q-size 0; }这段代码里tail始终指向下一个元素写入的位置head始终指向当前队头元素。入队时data[tail]写入然后tail后移出队时data[head]读取然后head后移。配合size字段空满判断完全避开“front rear到底是空还是满”的坑。注意用size方案时初始化head和tail都为0没问题但如果采用“牺牲一个存储单元”的经典方案初始化时front rear 0判断满的条件是(rear 1) % capacity front实际能存capacity - 1个元素。两种方案都可以别混着用就行。2.2 链表队列malloc和free的边界管理数组队列有容量限制满了就不能再入队。如果想无限扩容就用链表实现。链表队列的核心结构是两个指针front指向队头节点rear指向队尾节点。入队就是在rear后面挂新节点出队就是摘掉front节点。C语言链表队列最容易出问题的点是内存管理。malloc出来的节点出队时必须free不然就是内存泄漏但free之后如果还有指针指向这块内存就变成悬空指针后续访问直接崩溃或者是未定义行为。最典型的情况是出队最后一个元素后rear还指向那个已经被free的节点等你下一次入队时执行rear-next node就操作了一个无效地址。#include stdio.h #include stdlib.h #include stdbool.h typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *front; Node *rear; int size; } LinkedQueue; void lq_init(LinkedQueue *q) { q-front NULL; q-rear NULL; q-size 0; } bool lq_push(LinkedQueue *q, int value) { Node *node (Node *)malloc(sizeof(Node)); if (!node) { return false; } node-data value; node-next NULL; if (q-rear NULL) { q-front node; q-rear node; } else { q-rear-next node; q-rear node; } q-size; return true; } bool lq_pop(LinkedQueue *q, int *out) { if (q-front NULL) { return false; } Node *tmp q-front; *out tmp-data; q-front q-front-next; if (q-front NULL) { q-rear NULL; } free(tmp); q-size--; return true; } void lq_destroy(LinkedQueue *q) { while (q-front) { Node *tmp q-front; q-front q-front-next; free(tmp); } q-rear NULL; q-size 0; }你可以对比一下数组队列用size判断空满链表队列用front NULL判断空。链表实现没有容量上限只要内存够但每次入队都要malloc一次出队都要free一次频繁操作时开销比数组大。我见过很多人在写链表队列时只记得出队时free当前节点却忘了判断front变成空后要把rear也置空。这个问题不吃几次段错误是记不住的写完之后一定跑一跑边界用例入队一个再出队一个再入队看程序是否正常。2.3 数组还是链表一张表帮你做选择两种实现方式没有绝对好坏看场景。我把关键维度列成一张表对比项循环数组队列链表队列容量固定创建时确定动态可扩展到内存耗尽入队/出队时间复杂度O(1)O(1)内存分配一次性分配每个节点一次malloc内存碎片少多空满判断需要size或牺牲单元判断front是否为NULL适合场景容量可控、性能敏感容量不确定、频繁增删如果面试官问你“实现队列用数组还是链表”别只回答一句“都行”。你应该说如果能够预估容量上限且追求缓存友好和高性能用循环数组如果元素数量波动很大、无法预估上限用链表更灵活。缓存友好指的是数组元素在内存中连续分布CPU缓存命中率高链表每个节点不连续频繁malloc还可能造成内存碎片。Redis的list结构底层和操作系统的一些消息队列都会根据实际负载在两种结构间互相转换就是为了取长补短。3. Java实现从接口规范到并发阻塞队列3.1 JDK队列接口add与offer等方法的区别Java的队列体系比C语言规整得多。它把队列行为抽象成了Queue接口实现类一大堆LinkedList既可以当队列也可以当双端队列ArrayDeque是数组实现的双端队列PriorityQueue是优先级队列LinkedBlockingQueue是并发阻塞队列。很多人背接口方法背得滚瓜烂熟但没搞懂两套方法的语义差异。Queue接口定义了6个核心方法分为两组操作类型抛异常返回特殊值入队add(e)offer(e)出队remove()poll()查看队头element()peek()区别在于队列满或空的时候。add在队列满时抛IllegalStateExceptionoffer返回falseremove在队列空时抛NoSuchElementExceptionpoll返回nullelement和peek同理。你写代码时用哪一组取决于业务。不允许入队失败就选add让异常立刻暴露问题允许失败并需要优雅降级就选offer判断返回值。LinkedList是最容易上手的队列实现因为它同时实现了List和Deque接口既可当队列也可当栈。但注意它的线程安全性如果多个线程同时读写同一个LinkedList必须自己加锁否则会出现数据错乱。3.2 手写一个Java链表队列不依赖JDK现有类自己写一个泛型队列能更好地理解Java对象的引用关系。思路和C语言链表队列一致只是把malloc/free换成了new和GCpublic class MyQueueE { private static class NodeE { E value; NodeE next; Node(E value) { this.value value; } } private NodeE head; private NodeE tail; private int size; public boolean offer(E e) { NodeE node new Node(e); if (tail null) { head node; } else { tail.next node; } tail node; size; return true; } public E poll() { if (head null) { return null; } E value head.value; head head.next; if (head null) { tail null; } size--; return value; } public E peek() { return head null ? null : head.value; } public int size() { return size; } public boolean isEmpty() { return head null; } }这段实现和C语言版本异曲同工offer在队尾挂节点poll从队头摘节点唯一要小心的还是“最后一个元素出队后tail要置空”。Java虽然没有野指针问题但如果你不把tail置为null下次offer时就会在原本已经不在链表里的旧tail节点上追加导致队列数据错乱。泛型的引入让队列可以存放任意类型的对象这是Java相对于C语言的一个明显优势。C语言里想实现泛型队列要么用void*存指针要么用宏定义模板可读性差很多。3.3 仿写一个简化版阻塞队列搞懂生产消费模型阻塞队列是Java并发编程里的大杀器。它和普通队列的区别在于队列满时入队操作会阻塞等待队列空时出队操作会阻塞等待。这种“自动等待唤醒”的机制天然适合生产者-消费者模型。JDK的LinkedBlockingQueue和ArrayBlockingQueue内部实现用到了ReentrantLock和Condition。Condition可以理解成队列里的“等待室”队列满时生产者线程await进等待室消费者取走元素后signal唤醒一个生产者。反之同理。我写一个超简版帮你理解核心机制import java.util.LinkedList; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class SimpleBlockingQueueE { private final LinkedListE items new LinkedList(); private final int capacity; private final ReentrantLock lock new ReentrantLock(); private final Condition notEmpty lock.newCondition(); private final Condition notFull lock.newCondition(); public SimpleBlockingQueue(int capacity) { this.capacity capacity; } public void put(E e) throws InterruptedException { lock.lock(); try { while (items.size() capacity) { notFull.await(); } items.addLast(e); notEmpty.signal(); } finally { lock.unlock(); } } public E take() throws InterruptedException { lock.lock(); try { while (items.isEmpty()) { notEmpty.await(); } E e items.removeFirst(); notFull.signal(); return e; } finally { lock.unlock(); } } }这段代码有两个关键点。第一判断条件必须用while而不是if因为Condition存在“虚假唤醒”的可能线程被唤醒后发现条件又不满足了必须再循环检查一次。第二await会释放锁并让出CPU被signal唤醒后要重新竞争锁所以不能把unlock写在await之前。注意put和take方法声明了throws InterruptedException。阻塞中的线程如果被interrupt打断会抛出该异常。生产环境里捕获中断异常后应当恢复中断标志而不是吞掉异常。3.4 线程池里的队列选择理解了阻塞队列就理解了一半的线程池。ThreadPoolExecutor的工作流程是任务提交后如果核心线程没满就创建新线程执行如果核心线程满了任务就丢进阻塞队列等待如果队列也满了才触发拒绝策略。所以你在网上看到的“线程池的阻塞队列怎么选”这个问题答案全在这条流程里。不同的阻塞队列决定了线程池在“任务堆积”时的不同表现阻塞队列特性使用建议ArrayBlockingQueue有界数组队列队列容量可控适合想要限制积压任务的场景LinkedBlockingQueue默认无界可指定容量未指定容量时最大长度为Integer.MAX_VALUE任务堆积可能导致内存暴涨SynchronousQueue不存任务直接移交适合任务生产速度和消费速度匹配、希望尽快执行任务的场景PriorityBlockingQueue按优先级出队适合任务带优先级的场景DelayQueue延迟时间到期才可取适合延时任务调度实际生产中最常翻车的是无界队列。小流量时没啥感觉一旦某个上游接口变慢大量任务堆积在无界队列里内存占用飙升轻则Full GC频繁重则OOM。我建议明确使用有界队列并设置合理的capacity和拒绝策略宁可让任务被拒绝后走降级逻辑也不要让整个应用被拖垮。4. 队列的工程延伸消息队列选型与避坑4.1 为什么业务系统需要分布式消息队列单机里的队列解决的是“线程间通信”而分布式消息队列解决的是“服务间通信”。当你的系统从单体拆成多个微服务A服务要把订单数据同步给B、C、D服务最简单的做法是A直接调用它们各自的接口。问题是B挂了怎么办C响应慢怎么办大促流量爆掉怎么办消息队列把这些耦合解开了。A只把消息发到队列里不需要知道自己有多少下游B、C、D自己去队列里消费消息。这就是消息队列的三大价值解耦、异步、削峰。解耦是各服务互不依赖异步是发送方不需要等接收方处理完削峰是把瞬时流量平摊到一段时间内处理。我再说直白一点单机队列是“线程的生产者-消费者模型”消息队列是“服务之间的生产者-消费者模型”。你不理解前者的put/take阻塞机制看后者的“消息积压、消费滞后”也会一脸懵因为它们本质上是同一件事。4.2 Kafka、RabbitMQ、RocketMQ怎么选现在Java后端面试必问的一个题是Kafka、RabbitMQ、RocketMQ你选哪个为什么这个问题没有标准答案只有适合场景的答案。对比项KafkaRabbitMQRocketMQ开发语言Java/ScalaErlangJava消息模型分区模型Topic内分Partition队列模型 交换机路由Topic 队列模型吞吐量最高百万级/秒中等万级/秒高十万级/秒消息可靠性高需配置ack高高消息顺序分区内有序单队列有序队列有序社区活跃度高高中高阿里开源适合场景大数据采集、日志收集、流计算企业内部系统集成、复杂路由金融支付、电商订单等对可靠性要求高的场景我做选型时一般按这个思路判断如果场景是大数据链路比如埋点日志进数仓、配合Flink做实时计算首选Kafka它的吞吐和生态太强如果只是几个微服务之间做异步解耦不想引入太重的基础设施RabbitMQ够用它的管理界面好用、路由灵活如果涉及交易、支付这类需要严格可靠和事务支持的场景RocketMQ的参数更贴合业务需求对消息丢失、重复消费的容忍度控制更好。注意选型不是越新越好、越强越好。引入一个中间件就多一份运维成本。只有一两个业务需要异步场景硬上Kafka结果团队没人会调参数反而是灾难。4.3 业务中绕不开的重复消费与顺序问题很多人第一次用消息队列都会遇到两个“老朋友”重复消费和顺序乱了。这两个问题不是中间件缺陷而是分布式环境下必然出现的事实关键在于应用程序怎么应对。重复消费的根源在于“至少一次at least once”的投递语义。消费者处理完消息后还没来得及提交ack进程就挂了消息被重新投递消费者就会再处理一遍。解决办法不是让消息队列“保证不重复”而是让消费者具备幂等性。幂等的意思是同一个请求执行一次和执行一万次结果一样。实现幂等常见方案有三种用业务唯一键做去重比如订单号、流水号消费时先查Redis或数据库已存在就跳过利用数据库唯一索引重复插入直接触发冲突异常然后catch掉记录消费位点处理前检查是否已消费顺序问题更麻烦。Kafka只能保证分区内的顺序如果同一个订单的多个消息被发送到不同分区消费时顺序就会乱。解决办法是按业务维度指定分区键比如用订单ID做key让同一个订单的所有消息都进同一个分区。RocketMQ里则是使用MessageQueueSelector把相同业务ID的消息发到同一个队列。消息积压也是高频故障。典型现场是下游消费能力不足或者消费者挂了没及时拉起队列里积压几百万条消息消费者恢复后拼命拉取数据库被压垮。这时候不能闷头扩容消费者要先看瓶颈在哪。最常见的临时方案是紧急扩容消费者数量并适当调大每次拉取的消息条数如果是某条“毒消息”导致消费者反复崩溃需要跳过或修复这条消息再恢复消费。5. 常见问题与排查经验5.1 面试高频题速查队列相关的面试题看似零散其实都围绕着几个核心考点底层结构、边界条件、并发控制和工程选型。我把常见问题整理成一张速查表。问题核心回答要点队列和栈的区别结构上队列是一端进另一端出FIFO栈是同一端进出LIFO适用场景不同队列适合公平排队栈适合回溯匹配如何用两个栈实现队列一个栈负责入队一个栈负责出队出队时如果出队栈为空就把入队栈全部倒入出队栈如何用两个队列实现栈入栈时把元素放入非空队列出栈时把前面元素依次挪到另一个队列只剩一个元素时弹出循环队列空满判断size法、牺牲一个存储单元法、标志位法推荐size法最直观add和offer的区别add满时抛异常offer返回falseremove与poll、element与peek同理ArrayBlockingQueue和LinkedBlockingQueue的区别一个有界数组固定容量一个默认无界可自定义容量一个锁实现一个双锁实现线程池为什么用阻塞队列队列满时阻塞生产者线程实现任务排队和流量控制直接使用非阻塞队列需要自己处理锁和等待唤醒Kafka如何保证顺序分区内有序通过消息key进行分区路由相同key进相同分区消息重复消费怎么办保证消费者幂等用业务唯一键去重或数据库唯一索引兜底消息积压怎么排查先看消费端是否健康再看消费速度和生产速度差再检查是否存在毒消息必要时扩容消费者这里要特别说一下“用两个栈实现队列”。这个题出现的频率极高很多候选人能把代码默写出来但问一句“入队栈倒入出队栈的时机”就懵了。关键点在于只有出队栈为空时才能执行倒入而且必须一次性倒完。如果你在入队时也倒栈队列顺序就无法保持。5.2 实操中容易踩的坑最后分享一些我在实际写代码和排查问题中踩过的坑每一个都是真金白银换来的教训。第一个坑是C语言循环队列元素类型太单一。如果你只把队列做成了int版本后面需求变成存储结构体指针你就要改一版。所以工程上常见做法是把data定义成void*数组或者直接用泛型宏但这对新手不友好。我的经验是练习时先用int把逻辑跑通学到指针后再封装成void*版本一步到位。第二个坑是C语言链表队列只malloc不free。我见过有人写完队列测试时反复入队出队程序跑着跑着内存持续增长最后被系统杀掉。原因就是出队时只移动了front指针没有free原节点。还有一个关联问题出队最后一个节点后没有把rear置空。这个坑我在2.2节已经用代码标注了一定要跑“入队一个再出队一个再入队”的用例。第三个坑是Java线程池用了无界队列。之前接手过一个线上应用高峰期任务暴增线程池用的是默认的LinkedBlockingQueue无界队列结果内存持续涨频繁Full GC最后OOM重启。后来改成ArrayBlockingQueue并配合CallerRunsPolicy拒绝策略虽然偶有任务被拒绝但应用稳定多了。使用CallerRunsPolicy会由提交任务的线程直接执行任务相当于用调用方线程做最后的兜底适合不想丢任务但对延迟不太敏感的场景。第四个坑是消费者不写幂等。之前一个订单系统接入消息队列后数据库出现了不少重复订单。排查发现消费者在业务处理成功后、提交ack前崩溃消息重投导致重复处理。后来在消费逻辑里先查订单号是否已存在存在就直接跳过这个问题才收敛。请记住几乎所有的消息队列在实际场景中都会有重复消费的可能不在消费者侧做幂等迟早出事。第五个坑是阻塞等待时忘记处理中断。在Java里调用put/take线程被中断会抛出InterruptedException。有人习惯把它catch住然后什么都不做结果线程悄无声息地退出队列停止消费消息积压到爆。正确做法是catch之后重新设置中断标志或者至少打日志告警让问题能被及时感知。第六个坑是数组队列的容量设置。早期写循环队列时我习惯把容量设为2^n然后用(tail 1) (capacity - 1)替代取模运算这能提升一点性能。但如果你用牺牲单元的方案实际容量是capacity - 1很容易错算。后来统一用size字段后这块的心智负担才降下来。提示排查队列问题时先看“空满判断是否可靠”再看“指针/引用移动是否正确”最后看“并发访问是否加锁”。这三个层面抓好80%的队列bug都能快速定位。我个人在实际操作中还有一个习惯写完队列代码后用最小用例把边界条件全部走一遍。比如C语言循环队列我会依次测试“空队列出队”“入队到满”“满队列入队”“出队到空后再次入队”每个操作都打印日志观察head、tail、size的变化。Java阻塞队列则重点测试“队列满时put阻塞”“队列空时take阻塞”“多个生产者消费者同时运行是否有数据错乱”。这些测试看起来基础但真能帮你把实现细节刻进脑子里。学数据结构最怕的就是眼高手低看别人的代码觉得简单自己动手才明白每一行都有讲究。
返回列表