ARTICLE DETAIL

资讯详情

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

Android队列与Deque核心解析:消息机制、阻塞队列与LRU缓存实战

Android队列与Deque核心解析:消息机制、阻塞队列与LRU缓存实战 先说一个我自己的感受在Android开发里很多人提到“队列”第一反应就是LinkedList再问深一点就开始背“先进先出”的概念然后到真正写代码时要么对着接口方法犯迷糊要么在并发场景下被Null和阻塞搞得焦头烂额。Queue和Deque这两个接口表面上是集合框架的“小弟”实际上却是Android消息机制、线程池、任务调度、缓存淘汰这些核心模块的地基。如果你只会用ArrayList和HashMap那很多系统级的代码你是看不懂的更别提自己去实现一套任务队列或者LRU缓存。这篇文章我不打算照着文档念一遍API而是从Android开发的实际角度出发把这几个问题讲透Queue接口到底在Java集合里扮演什么角色Deque的双端设计解决了什么真实问题ArrayDeque凭什么成为官方推荐的“栈”实现以及BlockingQueue在生产者消费者场景里是怎么用的。顺便会把面试里喜欢问的“用两个栈实现队列”“LRU缓存的链表思想”也一并串进来。适合准备进阶的初中级Android开发也适合那些面试前想快速把数据结构这块补起来的同学。1. 为什么Android开发要重新认识Queue/Deque1.1 Android系统里的队列身影你早就见过先不急着看源码来回忆几个Android开发中几乎每天都在接触的东西。Handler机制里的MessageQueue名字里就带着Queue。虽然它内部用的是Message链表加同步屏障的实现方式但对外暴露的语义就是一个典型的“消息队列”往队列里放消息按时间排序取出主线程死循环里不断地“出队”处理。你要是没有队列的概念理解Looper.loop()里那个死循环就会很别扭因为你不知道消息是怎么被一条条“拔”出来的。线程池里的阻塞队列也就是ThreadPoolExecutor构造函数里的那个BlockingQueue这个是标准的Queue家族成员。核心线程满了以后新任务其实是进队列等待而不是立刻创建新线程。你选择什么样的队列实现直接决定了任务的排队策略LinkedBlockingQueue无界排队、SynchronousQueue不排队直接转交、ArrayBlockingQueue有界排队加拒绝策略。这一块如果只停留在背结论的层面一碰到线上任务堆积延迟增大的问题就会无从下手。还有Android里的Choreographer它负责每帧的输入、动画、绘制三大事件的编排内部有一个回调队列数组去分发每一帧的回调。包括我们在开发中经常写的“延迟任务队列”“日志批量上报队列”“请求重试队列”本质上都是在做同一件事用合适的队列结构把任务的入队、排序、出队节奏管起来。所以你会发现Queue和Deque不是面试八股文里孤立的考点而是Android系统真正的“毛细血管”。理解它们的语义和实现差异能让你在排查问题时有更清晰的思路而不是只会谷歌“Handler为什么卡顿”。1.2 “队列”不等于LinkedList先建立接口思维面试里最常见的一个误区是问Queue的实现类上来就说LinkedList。这个答案不算错但容易暴露你对集合框架的理解停留在“用过的类”层面而不是“接口设计”层面。Java集合框架的套路是接口定义能力抽象类提供骨架实现具体类负责落地。Queue接口定义的是“排队”这种能力它不关心底层是数组、链表还是堆Deque接口定义的是“双端操作”这种能力ArrayDeque用环形数组实现LinkedList用双向链表实现两者也能做栈、做队列。用接口去写代码的好处是你可以随时替换底层实现而不影响业务逻辑。比如你原来用LinkedList做一个任务队列后来发现内存碎片严重想换成ArrayDeque如果你写的是LinkedList queue new LinkedList()那改起来就是全局搜索替换但如果你写的是Queue queue new LinkedList()那只需要改一行new的部分。这就是面向接口编程最简单直白的收益。在学习这块时建议你把接口方法按“操作失败的表现”分个组。以Queue为例抛异常的版本有add、remove、element返回特殊值的版本有offer、poll、peek。前者在队列满或空时会throw异常后者会返回false或null。实际开发中用offer/poll/peek的组合更安全因为你不想因为一个队列满了就直接击穿整个业务。这一点在后面的生产消费者案例里会体现得很明显。从这一节开始我们后面所有讨论都建立在“接口思维”之上先搞清楚要什么能力再选具体的实现类而不是反过来。2. Queue接口两个高频实现背后的选择逻辑2.1 LinkedList当队列用为什么大多数场景不推荐先说结论LinkedList确实实现了Queue接口在数据结构课程里老师也常用它举例子但它在Android开发中作为队列的性能表现并不理想原因有三个。第一它每个节点都是一个Node对象除了存储数据本身还要存prev和next两个引用。这意味着你用LinkedList存100万个元素内存消耗会比数组实现多出一大截。在早期Android设备内存紧张的时代这种浪费是致命的即便到现在大型App里动辄几十万条数据入队的场景也不少。第二链表的内存不连续遍历时CPU缓存的命中率很低。数组结构比如ArrayDeque在遍历时相邻元素在内存里也相邻预取机制能高效加载而LinkedList的节点散落在堆内存各处每次访问几乎都是一次缓存未命中。第三LinkedList的批量操作和随机访问性能差。虽然队列只需要从一端进、另一端出但JDK的LinkedList不是纯队列它还实现了List接口有get(int index)这种操作。为了支持随机访问它的所有操作都要维护链表结构的完整性额外的开销无法避免。那LinkedList就一无是处吗也不是。它的头尾插入删除都是O(1)而且在需要从中间删除、需要存null元素的场景下它是比ArrayDeque更灵活的选择Java的ArrayDeque是不允许存null的这点后面会细讲。只是说如果你的需求就是“排个队”“先进先出”那有更合适的工具。2.2 PriorityQueue给任务排优先级的时候它是主力PriorityQueue和LinkedList是完全不同的思路。它不保证先进先出而是保证每次出队的元素是“当前队列中优先级最高的那个”。底层的实现是一个二叉小顶堆也就是用数组模拟的一棵完全二叉树堆顶永远是最小的元素。Android开发里最常见的用法是任务调度。比如你有一个图片上传队列用户滑动过程中触发的上传任务和用户点击“立即上传”触发的任务后者应该插队优先执行。你可以给每个任务定义一个优先级然后塞进PriorityQueue线程池每次从队列里拿任务时拿到的都是当前优先级最高的。使用PriorityQueue时要注意两点一是元素必须可比较要么实现Comparable要么在构造时传入Comparator二是它同样是线程不安全的多线程环境下需要自己加锁或者用PriorityBlockingQueue。这在Android里很关键因为很多开发者习惯在子线程往队列里塞数据然后在主线程消费不加同步必然出事。举一个比较器写的例子PriorityQueueTask taskQueue new PriorityQueue((t1, t2) - Integer.compare(t2.priority, t1.priority));注意这里用的是t2.priority减去t1.priority这样就实现了高优先级的先出队。如果你写反了变成t1在前那就是低优先级先执行线上事故分分钟。2.3 复杂度与边界PriorityQueue的隐藏规则很多人在面试时能把“PriorityQueue底层是小顶堆”背得滚瓜烂熟但一被追问“扩容机制是什么”就卡住了。这里说两个关键点。PriorityQueue的默认容量是11当容量不够时它有一个grow(int minCapacity)方法如果旧容量小于64就翻倍再加2如果大于等于64容量增长50%。这个设计和ArrayList不太一样原因是小容量时翻倍能减少扩容次数大容量时按比例增长能避免内存浪费。另一个隐藏规则是PriorityQueue不支持null元素。因为它是基于比较器工作的如果某个元素是null调用compareTo或者compare方法时会直接抛出NullPointerException。这个在业务代码里非常容易踩坑你从一个接口里拿到一批任务里面可能混着null塞进PriorityQueue里不报错但poll的时候就会崩。所以入队前一定要做判空。如果你要立刻把PriorityQueue的能力用在Android业务里可以顺手把它和Handler的消息延时队列做个对比。MessageQueue虽然名字里带Queue但它内部维护的是一条按时间排序的Message链表更贴近“优先队列”的语义——所有消息按when字段的先后排序最早到时的消息在链表头部而不是先进先出。理清这个区别以后你再去看Looper源码思路会顺很多。3. Deque与ArrayDeque双端语义和环形数组的源码启发3.1 双端操作与Java接口设计的一个“尴尬点”Deque是Double Ended Queue的缩写也就是双端队列。它同时支持头部插入移除和尾部插入移除所以它既能当队列用又能当栈用。Deque接口定义了12种方法每种操作都有两套版本一套抛异常一套返回特殊值。比如addFirst和offerFirst、removeFirst和pollFirst、getFirst和peekFirst。这种设计固然严谨但也带来了一个实际问题——方法数量太多初学者容易记混。我对这块的建议是在实际业务中你只需要记住你最常用的几个其余当字典查就行。这里说一个接口设计上的“尴尬点”Deque接口里还有一个removeFirstOccurrence和removeLastOccurrence方法用来删除队列中第一次/最后一次出现的指定元素。这两个方法暴露了一个事实——Deque承接了部分List的能力。但正因为如此JDK文档里明确建议如果你只是用线性结构存储数据优先选ArrayList而不是LinkedList但如果你确实需要一个双端操作的线性结构用ArrayDeque而不是LinkedList。这个建议后面被很多人忽略导致ArrayDeque在JDK里一直是“官方推荐但存在感很低”的存在。但在面试里如果你能说出“为什么ArrayDeque比LinkedList更适合当栈用”这绝对是个加分项。3.2 ArrayDeque的环形数组是怎么做到“两头插都很快”的ArrayDeque的底层是一个Object数组加上head和tail两个索引。它把数组当成一个环来用head指向队首元素tail指向队尾元素的下一个位置。当tail到达数组末尾时它会“绕回”到0的位置前提是0那里没有被占用。每次从头部插入addFirsthead就往前移一位也就是(head - 1) (elements.length - 1)每次从尾部插入addTailtail就往后移一位也就是(tail 1) (elements.length - 1)。这里用位运算取代取模运算是因为数组长度始终是2的幂减一之后做与运算效率更高。这就是为什么ArrayDeque扩容时会强制把长度调整为2的幂。初始化时ArrayDeque会根据传入的numElements计算一个大于等于它的最小2的幂作为初始容量最小是8。你传入7实际容量是8你传入9实际容量是16。这样做的好处是环形索引的位运算永远正确坏处是如果你数据量刚好是奇数会有少量内存浪费但这点浪费在堆内存里根本不值一提。ArrayDeque的扩容发生在tail和head重合的时候也就是队列真正满了。扩容时它会创建一块容量翻倍的新数组然后分两次拷贝先从head到原数组末尾这段再从0到tail这段重新排列成连续的数组。这个思路其实就是把“环”重新拉直和HashMap扩容时的rehash思想有异曲同工之处。3.3 为什么说“无界”不等于“随意”ArrayDeque和LinkedList都是“无界”的意思是不像ArrayBlockingQueue那样有容量上限只要堆内存够你可以往死里塞。但在Android里无界结构要格外小心。一个很常见的OOM路径是这样的网络请求回来的数据写入队列某个消费者线程处理不过来队列里的任务只增不减。表面上看队列“无界”实际上它在吞噬堆内存最后直接OutOfMemoryError。所以在客户端开发里我建议即使你用ArrayDeque也要在业务层做“软限流”或者“最大容量保护”。也就是入队前检查一下size超过阈值就丢弃最老的任务或者直接拒绝新任务。这一点在后面的BlockingQueue部分会体现得更明显——有界队列的存在意义在于它强制你把“队列满了怎么办”这个问题拉到台面上来而不是用一个无界结构自欺欺人。另外还要提醒一次ArrayDeque禁止存null。因为它的poll/peek方法用null来标识“队列为空”如果入队允许null你就会分不清peek()返回null到底是“队列空”还是“队列里有一个null元素”。这是Java集合设计里一个非常经典的取舍用“禁止null”换“API语义清晰”。LinkedList没有这个限制也正因为如此你在需要存null的极少数场景下还得用LinkedList。4. BlockingQueue实战日志批量上报队列的完整实现4.1 从消息队列的视角看阻塞队列的价值BlockingQueue是Queue接口的子接口增加了两个关键能力往满队列里放数据时如果队列满了生产者线程会被阻塞直到有空间put方法从空队列里取数据时如果队列空了消费者线程会被阻塞直到有新数据take方法。这套语义天然适合生产者消费者模型。Android里一个非常典型的场景就是日志上报。业务方打日志的频率完全不可控瞬间可能产生上千条日志而网络请求是慢操作不可能每条日志都实时上报。这时你需要的不是“每来一条日志就发一次请求”而是“把日志先堆积到一个队列里由后台线程批量取出来凑够N条或者超过T秒再上报”。这个队列天然就该用有界阻塞队列。你给它设一个上限比如10000条满了以后新的日志可以选择丢弃最老的也可以选择直接丢弃新的由你决定但应用不能崩。和Handler机制里“写一个消息进MessageQueue如果队列满就阻塞或等待”的思路是一个道理。只不过MessageQueue是按时间排序的链表而BlockingQueue是按阻塞规则管理的线程安全队列。理解阻塞队列的阻塞语义对理解AsyncTask的串行执行器、RxJava的背压策略也都有帮助。4.2 一个ArrayBlockingQueue版本的生产者消费者我们实际写一个精简版的日志批量上报队列用ArrayBlockingQueue来实现。这个队列的底层是环形数组并且用一把重入锁加两个条件队列notEmpty、notFull来管理阻塞与唤醒。public class LogReporter { private static final int CAPACITY 10000; private static final int BATCH_SIZE 20; private static final long FLUSH_INTERVAL_MS 2000L; private final ArrayBlockingQueueString logQueue new ArrayBlockingQueue(CAPACITY); private final ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); public void start() { scheduler.scheduleWithFixedDelay(this::flushIfNeeded, FLUSH_INTERVAL_MS, FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS); } public void report(String log) { // 队列满时丢弃当前日志保证主流程不被阻塞 logQueue.offer(log); } private void flushIfNeeded() { if (logQueue.isEmpty()) { return; } ListString batch new ArrayList(BATCH_SIZE); logQueue.drainTo(batch, BATCH_SIZE); // 真正的网络上报逻辑批次大小由BATCH_SIZE控制 uploadBatch(batch); } private void uploadBatch(ListString batch) { // 这里是伪代码接入自己的网络库即可 } }这里有几个设计细节值得展开讲讲。第一report方法用的是offer而非put。原因是一般业务方调用打日志时不希望因为队列满了就卡住当前线程哪怕只是短暂阻塞也不合适。offer在队列满时直接返回false我们可以选择“丢弃”尽快放行业务线程。如果你希望极端情况下也能强制写入那可以再加一个独立线程做put。第二取数据用drainTo方法而不是循环take。take会一条一条取每次都要竞争锁drainTo可以一次性取出最多batchSize条数据减少线程切换和锁竞争成本。这在批量上报场景里效率差异很明显。第三flushIfNeeded是轮询触发的每2秒检查一次如果队列为空就跳过避免无效调用。如果你希望更实时一点也可以把产品形态改成report时若队列长度达到BATCH_SIZE则立刻触发一次上报否则等待定时器兜底。这个可以按业务需求调整。4.3 别把“阻塞”当万能线程安全与边界条件的坑BlockingQueue解决的是队列的线程安全问题但救不了你的业务逻辑。说几个真实会踩的坑。第一个坑如果生产者不是逐条写入而是批量写入且队列剩余容量小于批量大小你就要么分成多次offer要么用put逐条塞。在后一种情况下如果消费者意外挂掉生产者会被永远阻塞在put上表现就是“某个线程卡住不动”。正确的做法是给put加超时boolean success logQueue.offer(log, 1, TimeUnit.SECONDS)超过1秒还没空间就直接放弃避免无上限的等待。第二个坑多消费者并行拉取时的顺序问题。ArrayBlockingQueue内部是线程安全的多个消费者take时每个元素只会被一个消费者拿到不会重复。但如果你在消费者取出日志后又做了二次处理处理阶段没有加锁就可能出现两条日志的处理顺序和入队顺序不一致。日志上报这种场景对顺序容忍度还行但如果你是做订单状态流转的任务队列顺序错了就是事故。解决办法是要么单消费者要么按订单号hash取模路由到不同队列保证同一个订单的日志永远到同一消费者。第三个坑异常吞噬。消费者线程从take()拿到任务后如果执行过程中抛了RuntimeException而这个异常没有被捕获那么线程可能直接退出队列里的剩余任务就永远没人处理了。这种情况在日志上报里表现得不明显但在任务队列里非常致命。所以消费者循环里一定要try-catch-Finally并且记录失败次数做降级。5. 面试和工程都爱考的队列变形题5.1 LRU缓存里的“队列思想”LRULeast Recently Used缓存是面试高频题它的核心数据结构选择很多最常见的是LinkedHashMap。但你有没有想过它为什么和队列有关系LinkedHashMap继承HashMap额外维护了一条双向链表这条链表的顺序就是访问顺序或者插入顺序。当accessOrder设为true时每次get或put都会把对应节点移动到链表尾部这样链表头部的节点就是最久未访问的节点。这本质上就是一个“按访问时间排列的队列”只不过它是双向链表实现的删除任意节点是O(1)。这个场景和Deque/Queue的关联在于面试官经常会追加一个问题“如果让你自己设计LRU你会选择什么数据结构”标准答案是“哈希表加双向链表”——哈希表负责O(1)查找双向链表负责O(1)调整顺序和淘汰头部。很多读者可能会想“这不就是LinkedHashMap吗”对LinkedHashMap就是JDK对这个思路的标准实现。关键要理解的是为什么这里用的是“双向链表”而不是“环形数组”因为LRU淘汰时你需要删除链表中间的某个节点删完还要保持原有相对顺序双向链表能做到O(1)地摘除节点而数组在中间删除是O(n)。队列在这里提供的是一种“顺序淘汰”的语义核心不是先进先出而是“最旧的最先被淘汰”。这和PriorityQueue的“优先级淘汰”是并列的两种淘汰策略。5.2 用两个栈实现队列工程上到底有什么用“用两个栈实现队列”是算法题里的经典题。它的解法是入队时往stackIn压入出队时如果stackOut非空就直接弹为空则把stackIn的所有元素逐个弹出并压入stackOut再弹。这样做的理论依据是栈的“后进先出”倒两手之后就变成了“先进先出”。有人可能会问这种题在Android开发里有什么用我见过一个还挺真实的场景某个日志模块需要先缓存最近的N条日志当发生崩溃时把日志写入磁盘。这里恰恰需要一个“倒序输出”的能力——最新的日志优先落盘方便定位崩溃前最后发生了什么。用双栈结构处理就非常自然所有日志先入stackIn崩溃时把stackIn的元素全部倒入stackOut再从stackOut依次弹出拿到的就是最新的日志优先。另外双栈队列的工程思想就是“分批反转”。它不要求所有元素一次性都倒过去而是按需倒入分摊了时间复杂度。这种“摊还分析”的思想在很多缓冲设计中都有体现。理解了双栈队列的摊还O(1)你再看到Android里的某些缓存写入缓冲队列就更容易理解为什么它们能保持高效了。5.3 一个实用的优先级队列优化案例图片上传任务最后给一个综合案例图片上传队列需要同时满足“按用户操作触发的优先级”和“先进先出的基础顺序”。一个可行的方案是不用PriorityQueue而是“多级队列”。比如建三个ArrayDeque分别对应高优先级、普通优先级、低优先级。生产者按照优先级把任务放入对应的队列消费者每次取任务时先看高优先级队列是否为空不为空就取为空再看普通再是低优先级。这等同于三个队列的轮询调度实现简单、无锁如果每个队列配合独立的锁或就单线程消费者而且比一个PriorityQueue更好控制“同优先级内先进先出”。为什么不用PriorityQueue因为PriorityQueue只保证“每次取出最小的那个”但不保证“相同优先级时先进先出”。如果你要求同优先级内严格按入队顺序执行PriorityQueue就不合适了当然你可以给每个任务加一个序号参与比较但复杂度上来了。多级队列方案则天然满足这个约束而且理解起来非常直白。这就是开发中的典型思路数据结构的选型不是找最“高级”的工具而是找最匹配业务约束的那个。简单方案如果能满足所有约束就不要为了炫技引入更复杂的结构。6. 几个排查队列问题的经验队列相关的线上问题如果能在开发阶段就养成好习惯能省去很多排查时间。分享几个我自己的实操经验。第一入队操作一定要记日志至少是debug级别。尤其在生产者和消费者在不同线程的场景下你不记录入队和出队的数量出了任务积压问题后根本没法定位是生产慢还是消费慢。第二在有界队列的场景里队列的“满”不能只是一行代码。要监控队列长度、丢弃条数、阻塞时长这三个指标。如果队列长时间处于将近满的状态说明消费者能力不够需要考虑扩容消费者或优化消费逻辑而不是等到OOM才去处理。第三把Deque当栈用时要注意removeFirst和pollFirst的区别。removeFirst在队列为空时会抛NoSuchElementExceptionpollFirst返回null。Android主线程崩溃一旦发生直接影响的是用户体验所以如果这个队列是被多个入口调用的建议统一用pollFirst配合判空逻辑避免意外的异常导致崩溃。第四如果你在子线程里往一个无界队列塞数据一定不要忘了加容量保护。哪怕你觉得“数据量不可能那么大”客户端用户规模上来后什么不可能都会变成可能。一个简单的“超过最大值丢弃最老数据”的判断能在关键时刻保住应用不崩溃。这些经验说起来都不复杂但它们才是队列在工作实践中真正“见真章”的地方。数据结构不是背完定义就结束的它的价值是在每个边界条件下的稳健表现。
返回列表