尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Java ArrayDeque源码解析:循环数组玩转高效双端队列
我最早认真读ArrayDeque的源码是被一个很普通的问题问住的“Java里用数组也能实现双端队列”当时我脑子里只有链表实现双端队列的印象觉得链表节点两头操作天然方便数组怎么做到从头部插入还是O(1)啃完源码我才发现ArrayDeque底层靠的是一个循环数组加两个指针原理本身不复杂但“循环”这两个字特别容易把人绕晕——head和tail在数组里绕来绕去代码里那一堆位运算第一眼看过去全是噪音。后来我换了个思路把底层数组展开成格子把head和tail标出来每执行一次操作就把内部状态画一遍所有疑问瞬间就通了。这篇文章就按这个“可视化”的视角把ArrayDeque双端队列的底层原理从头拆到尾循环数组怎么布局、入队出队时指针怎么走、容量为什么必须是2的幂、扩容时元素怎么重排以及我实际使用中踩过的一些坑。适合正在学Java集合源码的读者也适合那些用过ArrayDeque但没深究过底层的开发者——读完你不仅能看懂源码还能自己手写一个教学版的双端队列。1. 双端队列这个“看似简单”的命题1.1 双端队列到底解决了什么问题先明确一下概念双端队列英文DequeDouble Ended Queue指的是可以在队头、队尾两端都进行插入和删除操作的线性结构。普通队列只能一头进、另一头出栈只能一端进出而双端队列把这两种能力合并到了一起。这个“两头都能操作”的能力在工程里的应用比想象中广泛得多。经典的滑动窗口最大值问题核心数据结构就是双端队列窗口右移时从尾部加入新元素、从头部淘汰过期元素任务调度里高优先级任务插到队头、普通任务排到队尾再看编辑器的撤销重做栈、浏览器前进后退记录本质上也都是两个栈拼成的双端队列模型。我在工作中还用过它做请求的“限流窗口缓冲”最近10秒的请求ID放在头部超过时间的从尾部淘汰出队入队平均O(1)比用ArrayList做头删要优雅得多。所以在Java里Deque是一个接口它同时提供了addFirst、addLast、pollFirst、pollLast、peekFirst、peekLast这一整套两端操作的能力。而ArrayDeque正是这个接口最常用的实现类——注意不是LinkedList。这里有个很多初学者会忽略的点LinkedList也实现了Deque接口但底层是双向链表ArrayDeque底层是动态数组两者在JDK里的定位完全不同。1.2 数组实现和链表实现选谁更合理先说结论如果你需要一个通用的双端队列ArrayDeque几乎总是比LinkedList更合适。原因有三点。第一内存连续性。数组是一块连续的内存地址CPU加载时能把相邻数据一次性带进缓存遍历和顺序访问时缓存命中率极高。链表节点散落在堆内存各处每个节点还要额外存两个指针prev和next光指针开销就是16字节元素本身越大这个比例越高。第二空间占用。LinkedList每个节点是一个Node对象除了数据本身还要维护前后引用和对象头。ArrayDeque虽然也有“可能扩容到2倍容量”的浪费但总体内存密度远高于链表。实测存100万个整数ArrayDeque的占用大约是LinkedList的一半。第三随机访问能力。ArrayDeque虽然不像ArrayList那样主打随机访问但它毕竟基于数组通过index直接定位元素很简单。LinkedList要跳到第n个节点只能从头一个个nextO(n)级别。那链表是不是一无是处也不是。如果你需要在队列中间频繁插入删除——比如LRU缓存那种“删除中间某节点再移到头部”的场景LinkedList配合HashMap能做到O(1)数组结构就做不到。但这是“在中间操作”的需求和双端队列“只在两端操作”的定位不一样。双端队列只会触碰头尾数组的“头部插入要搬移元素”问题通过循环数组设计恰好被完美绕开了。1.3 别再拿Stack当栈用了顺带说一个非常常见的错误用法Java的java.util.Stack。这个类继承自Vector所有方法都加了synchronized锁在单线程下性能白白受损。而且它继承了Vector的接口允许按下标随机访问语义上根本不是一个纯粹的栈。Stack的官方文档自己都写着推荐用ArrayDeque来代替。ArrayDeque当栈用的时候入栈执行addFirst或push方法出栈执行pollFirst或pop它就是一根干净的、单向操作的数组栈性能比Stack好一个量级。还有一点容易踩坑的地方LinkedList也可以当栈但它那些remove中间节点、按下标访问的方法也会暴露出来用着用着就容易用错API。ArrayDeque的接口高度聚焦不容易被带偏。所以无论当队列、栈还是双端队列ArrayDeque都是默认首选。理解了“为什么选它”接下来就可以深入到“它底层凭什么能做到两头操作都高效”。2. 底层布局循环数组和两个指针2.1 ArrayDeque内部到底有什么字段打开ArrayDeque的源码核心字段一共就三个transient Object[] elements; transient int head; transient int tail;elements是一个Object数组这是真正的存储容器head指向队头元素的位置tail指向下一个可以插入的位置。这里有两个语义要搞清楚很多人读源码就是栽在这上面head不是“数组的起始下标”而是“当前队头元素所在下标”tail不是“最后一个元素的下标”而是“下一个空位下标”。举个例子空队列时head和tail都是0没有任何元素插入一个元素之后如果是从尾部入队元素放进elements[0]tail变成1head还是0。再看个关键细节tail指向的是空位而head指向的是已有元素。这个不对称的设计决定了后面所有操作的位运算写法。当你从头部插入元素时需要先把head往前挪一位再赋值——因为head当前指向的元素是队头新元素要插到它前面当你从尾部插入元素时直接用tail位置赋值再把tail往后挪一位——因为tail本来就指向下一个空位。2.2 容量为什么必须是2的幂这是ArrayDeque最核心的设计决策底层数组的容量一定是2的幂。默认无参构造时容量是16有参构造时也会把传入的“预期元素数量”调整成不小于它的2的幂。为什么非要2的幂因为这样可以用一个极其廉价的位运算替代取模运算(x) (capacity - 1)。数组下标回绕是循环数组的核心操作比如tail已经到了数组末尾的15再往后走一格应该回绕到0。如果容量是16那么(15 1) 15的结果是0一步到位。如果容量是10你只能写(9 1) % 10虽然结果也是0但取模运算在CPU指令层面是个除法操作比位运算慢一个数量级。ArrayDeque的入队、出队、遍历每一处都要做这种“越界回绕”用位运算能省下大量计算。代码里会看到大量(head - 1) (elements.length - 1)、(tail 1) (elements.length - 1)的写法本质就是“绕着环形跑道走走完一圈回到起点”。选2的幂还有另一个好处扩容时直接左移一位n 1新容量严格翻倍依然是2的幂这个性质永远成立。2.3 空和满的判断一个指针重合引发的歧义循环数组最经典的问题来了当head和tail重合时数组到底是空的还是满的空队列时head等于tail二者相等满队列时如果一直插入到最后一个槽位再用完tail也会追回头上还是相等。同一个条件两种含义必须想办法区分。ArrayDeque的解决方案是约定tail和head重合时立即触发扩容不让“满”状态真正出现在稳定运行中。也就是说绝大多数时候数组里至少保留一个空槽位作为“缓冲区分带”。你往容量为8的ArrayDeque里连续addLast插到第7个元素时数组里还有一个空位head和tail没有重合继续插第8个tail追上了head此时立即扩容到16。所以从使用者的角度看ArrayDeque在扩容前的“稳态容量”其实是capacity - 1一个容量8的队列最多稳定装7个元素第8个会触发扩容。这一点理解之后构造器的“坑”也就清楚了后面会专门讲。3. 核心操作的可视化拆解3.1 addLast入队先赋值再让tail绕圈addLast是最直观的操作源码逻辑public void addLast(E e) { if (e null) throw new NullPointerException(); elements[tail] e; tail (tail 1) (elements.length - 1); if (tail head) grow(); }第一步把元素放到tail指向的位置第二步tail后移一格如果移过了数组末尾通过位运算回绕到0第三步判断tail是否追上了head如果追上说明数组被填满了触发扩容。这里有个容易看晕的点为什么先赋值后判断因为扩容的触发条件是“插入之后tail追上head”也就是说这个动作把最后一个空槽用掉之后才扩容。如果先判断再加就永远等不到tailhead的那一刻。我用一个容量8的数组逐步演示下划线表示空槽初始状态 下标: 0 1 2 3 4 5 6 7 值: _ _ _ _ _ _ _ _ head: 0 tail: 0 addLast(A): 下标: 0 1 2 3 4 5 6 7 值: A _ _ _ _ _ _ _ head: 0 tail: 1 addLast(B): 下标: 0 1 2 3 4 5 6 7 值: A B _ _ _ _ _ _ head: 0 tail: 2再执行addLast(C)tail继续后移到3。一切正常的情况下tail一路往后走走到末尾才需要回绕。3.2 addFirst入队head先绕圈再赋值addFirst的源码更考验位运算直觉public void addFirst(E e) { if (e null) throw new NullPointerException(); elements[head (head - 1) (elements.length - 1)] e; if (head tail) grow(); }它是先把head往前挪一格然后把元素放到新的head位置。注意这个表达式里的赋值过程先计算(head - 1) (elements.length - 1)把结果赋给head再用这个新head作为下标写入元素。从队头插入时元素要站在当前队头的前面所以head必须从当前位置“倒退”。如果head已经是0往前退一格就是-1此时-1 7等于7直接回绕到数组末尾。这就是循环数组“原来绕到头了”的精髓从下标0往前走到下标7继续往前走到6、5……数据在逻辑上是连续的环形但在物理存储上头在尾部。继续用那张容量8的表来演示addFirst。当前数组里已经有A、B两个元素head0tail2。addFirst(X) 下标: 0 1 2 3 4 5 6 7 值: A B _ _ _ _ _ X head: 7 tail: 2head从0跳到7X落在下标7。再addFirst(Y)addFirst(Y) 下标: 0 1 2 3 4 5 6 7 值: A B _ _ _ _ Y X head: 6 tail: 2head从7跳到6Y落在下标6。注意如果此时从队头开始按顺序读元素会得到Y、X、A、B。用下标表示就是6、7、0、1——先走向数组末尾再绕回开头。这就是循环数组的遍历规律也是后面迭代器设计的根本。理解了addFirst和addLast你就能回答开头那个问题了数组为什么能O(1)从头部插入因为它插入的位置不是下标0而是head倒退后的“环形位置”不需要移动任何已存在的元素。3.3 pollFirst和pollLast出队取出、置空、指针移动pollFirst是取队头元素同时把元素从数组中“拿掉”public E pollFirst() { final Object[] es elements; final int h head; SuppressWarnings(unchecked) E e (E) es[h]; if (e ! null) { es[h] null; head (h 1) (es.length - 1); } return e; }代码干了三件事取出head位置的元素把那个槽位置为null帮助GC清除引用head后移一格。如果取出来是null说明队列是空的直接返回null。pollLast则是对称的写法public E pollLast() { final Object[] es elements; final int t tail - 1 (es.length - 1); SuppressWarnings(unchecked) E e (E) es[t]; if (e ! null) { es[t] null; tail t; } return e; }关键区别是tail指向的是下一个空位所以取队尾元素时要先把tail回退一格回退下标(tail - 1) (len - 1)取出元素然后让tail指向这个刚空出来的槽位。继续用前面的场景演示。当前数组是下标: 0 1 2 3 4 5 6 7 值: A B _ _ _ _ Y X head: 6 tail: 2执行pollFirst()取出下标6的Y槽位置nullhead变成7。下标: 0 1 2 3 4 5 6 7 值: A B _ _ _ _ _ X head: 7 tail: 2再执行pollLast()tail - 1等于1取出下标1的Btail变成1。下标: 0 1 2 3 4 5 6 7 值: A _ _ _ _ _ _ X head: 7 tail: 1到这里可以总结一个“可视化口诀”addFirst让head往前绕addLast让tail往后绕pollFirst让head往后绕pollLast让tail往前绕。四个操作两对相反动作组合起来就是双端队列的全部基本操作。3.4 完整状态变化表一次看透整个循环过程为了让你对“循环”有更整体的认识我把容量为8的队列从空开始连续做一串操作每一步都记录head、tail和数组内容操作数组内容下标0~7headtail队列内容从head开始初始化_ _ _ _ _ _ _ _00空addLast(A)A _ _ _ _ _ _ _01AaddLast(B)A B _ _ _ _ _ _02A, BaddFirst(X)A B _ _ _ _ _ X72X, A, BaddFirst(Y)A B _ _ _ _ Y X62Y, X, A, BaddLast(C)A B C _ _ _ Y X63Y, X, A, B, CpollFirst()A B C _ _ _ _ X73X, A, B, CpollLast()A B _ _ _ _ _ X72X, A, B这个表里能看到几件事第一head可以在数组头部也可以在数组尾部附近完全取决于历史操作。第二tail可以是在head“后面”也可以是在head“前面”逻辑顺序不等于物理顺序。第三从head开始顺着数组绕一圈读取才能还原队列的真实顺序。遇到“某个元素到底在哪”的问题拿着这张表对着源码看比你空想十遍都管用。这也是我给所有打算啃集合源码的读者的第一个建议动态展示内部状态永远比静态读代码直观。4. 扩容机制满员时的数据重排4.1 什么时候真正触发扩容前面铺垫过ArrayDeque的扩容触发点是tail head。这个条件在addFirst和addLast里都有判断。但要注意它不是“数组还剩0个空位”才扩容而是“插入动作完成之后tail追上head”的那一瞬间才扩容。举个具体的数字容量8的队列初始head0tail0。连续addLast第1个元素tail1第2个tail2……第7个tail7此时数组里有7个元素还剩一个空位下标7不实际上最后一个元素在下标6下标7是空的我修正一下如果addLast了7个元素它们占0~6tail7下标7空。head0tail7还有一个空槽。第8个addLast元素放到下标7tail回绕到0tailhead成立扩容触发。所以数组在扩容前的那一刻实际上是存满了8个元素的。扩容动作非常迅速在你还没反应过来的时候容量已经变成16了。从使用者的视角ArrayDeque的容量是“自动伸缩”的你不需要关心存量只需要知道扩容的成本是O(n)偶尔一次均摊下来复杂度依然是O(1)。4.2 扩容时元素怎么搬从环形恢复成直线扩容的核心难点不是“把数组变长”而是“元素顺序不能乱”。循环数组里元素是绕圈排列的扩容之后必须按队列的真实顺序从下标0开始重新摆放。源码里这一步做得很巧妙。JDK 8的写法是private void doubleCapacity() { assert head tail; int p head; int n elements.length; int r n - p; int newCapacity n 1; if (newCapacity 0) throw new IllegalStateException(Sorry, deque too big); Object[] a new Object[newCapacity]; System.arraycopy(elements, p, a, 0, r); System.arraycopy(elements, 0, a, r, p); elements a; head 0; tail n; }关键在两行System.arraycopy。p是headr n - p是“从head到数组末尾还剩多少元素”。第一次拷贝把elements从p开始、长度r的那段搬到新数组的头部也就是下标0到r-1第二次拷贝把elements从0开始、长度p的那段搬到新数组的r位置起。两次合起来就把环形的顺序完整拉成了一条直线。我画一个具体的扩容过程。假设容量8的数组当前状态如下扩容前capacity8, headtail6: 下标: 0 1 2 3 4 5 6 7 值: A B C E F G Z X ^head/tail队列的真实顺序从head开始读Z、X、A、B、C、E、F、G。为什么是这么个顺序因为head6元素是Z接着下标7是X绕回下标0是A再往后1、2、3、4、5依次是B、C、E、F、G。执行扩容后扩容后capacity16, head0, tail8: 下标: 0 1 2 3 4 5 6 7 8 ... 15 值: Z X A B C E F G _ ... _ ^head ^tail第一次arraycopy复制的是下标6、7两个元素Z和X放到新数组0、1第二次arraycopy复制的是下标0到5的A、B、C、E、F、G放到新数组2到7。最终队列从头到尾的顺序和扩容前逻辑顺序完全一致但物理存储从“环形”变成了“笔直一条线”。看完这个搬移过程你也就理解了为什么扩容后head固定重置为0、tail固定等于旧容量n因为搬完之后队列的第一个元素一定在新数组下标0最后一个元素的下标一定是n-1tail指向的空位正好是n。4.3 扩容的边界与版本演进JDK 9之后扩容方法从doubleCapacity()改成了grow()内部逻辑基本一致只是用局部变量缓存了elements引用并且把两段拷贝的逻辑调整得更加清晰。核心思想没有任何变化容量翻倍、保持2的幂、从head开始按顺序搬移、head清零。还有个极端的边界当数组长度非常大左移一位导致整数溢出变成负数时源码会抛出IllegalStateException提示“Sorry, deque too big”。正常情况下你永远不会碰到这个但知道这个异常的含义真遇到了不至于懵。扩容时还有一个容易忽略的性能细节两段System.arraycopy都是native方法底层内存拷贝速度极快所以扩容虽然理论上是O(n)但常数极小实际体验中不会有明显卡顿。这也是ArrayDeque敢频繁自动扩容的底气——如果每次扩容都要手动循环赋值性能早就崩了。5. 迭代器、克隆与批量操作里的细节5.1 迭代器怎么沿着“环形跑道”走遍历ArrayDeque时迭代器不是简单地从数组下标0走到length-1而是从head出发模拟“环形跑道”的走法一直走到tail为止。核心代码是public E next() { E e (E) elements[cursor]; if (e null) throw new ConcurrentModificationException(); lastRet cursor; cursor (cursor 1) (elements.length - 1); return e; }cursor从head开始每次next就把下标加1并对容量取模。因为ArrayDeque的迭代器是“happy path”的快速失败迭代器如果在遍历过程中检测到元素被修改为null就会抛出ConcurrentModificationException。有个很有意思的细节迭代器的终止条件是cursor ! tail。由于tail本身可能在“环形跑道”的任意位置迭代器必须沿着环形的同一方向走直到和tail重合。这也意味着迭代器的遍历顺序一定是从队头到队尾稳定可靠。反向迭代器DescendingIterator恰好相反它从tail - 1开始每次cursor (cursor - 1) (len - 1)一路往前走到cursor head为止。两个迭代器对称存在分别对应双端队列“从两头遍历”的需求。5.2 clone和toArray怎么还原顺序ArrayDeque的toArray方法也踩过循环数组的坑它不能直接Arrays.copyOf(elements, size)因为物理顺序不是逻辑顺序。正确做法是分两段拷贝先拷贝从head到数组末尾的部分再拷贝从下标0到tail的部分。逻辑和扩容时的搬移如出一辙只不过目标数组的长度是当前元素数量不翻倍。clone方法也做了类似处理它会复制整个elements数组重置head和tail保证克隆出来的队列和原队列逻辑上完全一样但物理数组互相独立。这给我们的启示是任何需要“按队列顺序导出”的操作都必须先搞清楚head在哪、tail在哪再决定从哪个位置开始拷贝。直接按数组下标从头拷到尾拷出来的一定是错乱顺序。5.3 批量操作与元素查找contains、removeFirstOccurrence、removeLastOccurrence这些方法内部都是用一个从head到tail的循环去遍历元素。它们同样要走i (i 1) (len - 1)的回绕逻辑不能简单写i。有一个隐藏点这些遍历方法判断“是否到了队列末尾”用的是i ! tail而不是“下一个位置是否为空”。因为ArrayDeque禁止存null队列的元素槽位要么有值、要么是null而null槽位可能是poll之后留下的空洞也可能是尚未到达tail的间隙。如果遍历逻辑依赖“元素不为null”来判断就会在poll过元素之后提前终止或漏掉元素。所以遍历的唯一可靠基准是head和tail两个指针这一点在用for (Object e : deque)之外写自定义遍历时要特别小心。6. 避坑指南我替你们踩过的那些坑6.1 为什么ArrayDeque死都不让你存null这是最多人疑惑的问题“别的集合都能存null为什么ArrayDeque一存就抛NullPointerException”答案在ArrayDeque的内部约定里。它用null来表示“槽位为空”的占位符比如pollFirst取出的元素如果为null就说明队列是空的扩容前后的数组里那些空槽位也全是null。一旦允许用户存null迭代器和查找方法就无法区分“这是一个真正的null元素”和“这是一个空槽位”了。从语义上讲pollFirst在队列为空时返回null在队列非空时返回队头元素。如果允许队列里存null当你执行pollFirst拿到null时你根本不知道是“队列空了”还是“队头就是一个null”API直接变得无法使用。所以ArrayDeque果断选择禁止null换来了清晰可靠的返回值语义。6.2 ArrayDeque不是线程安全的别拿它硬扛并发ArrayDeque的所有方法都没有加锁多线程同时读写会出现数据竞争轻则丢元素重则数组越界、死循环。它内部没有任何原子变量或volatile保证可见性纯粹是为单线程场景设计的。并发场景下要选ConcurrentLinkedDeque无锁实现适合高并发读多写多的队列场景或LinkedBlockingDeque阻塞队列适合生产者消费者模式。如果只是需要线程安全的双端队列做简单同步也可以用Collections.synchronizedDeque包装一层但要记得所有迭代操作都要手动加锁。迭代器的fail-fast机制也值得留意如果迭代过程中检测到结构被并发修改迭代器会抛出ConcurrentModificationException而不是继续遍历可能已经损坏的数据。这个机制在单线程里是“帮你发现bug”在并发场景里只是“告诉你出事了”并不能保证数据安全。6.3 构造器传参你传的是“预期元素数”不是容量ArrayDeque的构造器public ArrayDeque(int numElements) { allocateElements(numElements); }你传的参数名为numElements是“预期会存放多少个元素”不是“初始容量”。内部会把它调整成一个“放得下这些元素且不触发扩容”的2的幂容量。这里有个很反直觉的规律当传参本身就是2的幂时实际容量会是它的两倍。我实验过的一组数据传入numElements实际elements容量扩容前最多可稳定存放18778781615151615163231100128127为什么8会被抬到16因为前面说过ArrayDeque在扩容前的“稳态容量”是capacity - 1。如果容量是8最多只能稳定放7个元素一旦放第8个就会触发扩容。既然你在构造器里说“我要放8个元素”它就必须给你一个容量16的数组保证你放入前8个元素时全程不扩容。这个细节很容易被忽略如果你在代码里写new ArrayDeque(2)还以为容量是2、最多存1个那就大错特错了——实际容量是8能存7个。6.4 动手写一个可视化调试工具亲手看看内部状态前面讲了那么多原理最扎实的理解方式还是自己写一个“能看到内部状态”的教学版ArrayDeque。我建议初学者复制下面这个简化版把dump方法加到类里然后一步步操作观察每一次变化public class SimpleArrayDeque { Object[] elements; int head, tail; public SimpleArrayDeque(int capacity) { elements new Object[capacity]; head tail 0; } public void addFirst(Object e) { elements[head (head - 1) (elements.length - 1)] e; if (head tail) grow(); } public void addLast(Object e) { elements[tail] e; tail (tail 1) (elements.length - 1); if (head tail) grow(); } public Object pollFirst() { Object e elements[head]; if (e ! null) { elements[head] null; head (head 1) (elements.length - 1); } return e; } public Object pollLast() { int t (tail - 1) (elements.length - 1); Object e elements[t]; if (e ! null) { elements[t] null; tail t; } return e; } private void grow() { int p head, n elements.length, r n - p; Object[] a new Object[n 1]; System.arraycopy(elements, p, a, 0, r); System.arraycopy(elements, 0, a, r, p); elements a; head 0; tail n; } public void dump() { System.out.print(head head tail tail | ); for (int i 0; i elements.length; i) { System.out.print(i : (elements[i] null ? _ : elements[i]) ); } System.out.println(); } public static void main(String[] args) { SimpleArrayDeque q new SimpleArrayDeque(4); q.addLast(A); q.dump(); q.addLast(B); q.dump(); q.addFirst(X); q.dump(); q.addFirst(Y); q.dump(); q.pollFirst(); q.dump(); q.pollLast(); q.dump(); } }跑一下main方法你会看到类似这样的输出head0 tail1 | 0:A _ _ _ head0 tail2 | 0:A 1:B _ _ head3 tail2 | 0:A 1:B _ 3:X head2 tail2 | 0:A 1:B 2:Y 3:X head3 tail2 | 0:A 1:B _ _ head3 tail1 | 0:A _ _ _注意第4行输出head2且tail2但实际上数组已经满了容量4存了4个元素不对你看数组0:A 1:B 2:Y 3:X确实4个都满了headtail2下一行pollFirst就执行了。而我这个教学版的grow逻辑在addFirst里用的是先赋值再判断所以第4行时刻数组满了但还没有扩容这是简化版和真实ArrayDeque行为一致的地方。再仔细看addFirst(Y)之后数组容量还是4但装满了4个元素headtail成立了。紧接着下一次操作pollFirst没有触发扩容。这就是简化版的局限它在满数组时也能继续poll所以head和tail的“空满歧义”暂时没暴露出来。真实ArrayDeque在addFirst/addLast的末尾判断headtail会立刻扩容不会让你看到“满数组且headtail”的稳定状态。但即便有这个教学版的小瑕疵用来理解循环数组的指针移动和扩容搬移效果已经非常直观了。你每执行一步都能看到元素落在哪个下标、head怎么绕圈、tail怎么追这些可视化输出比任何博客里的静态图都更容易进脑子。最后说点实在的看完ArrayDeque的源码我最大的体会是数据结构这种东西光靠看代码和背结论是不够的尤其是“循环、回绕、指针重合”这一类抽象概念大脑很难直接建模。我后来养成一个习惯凡是遇到循环数据结构都先写一个dump函数把内部状态实时打印出来再拿真实场景去验证。ArrayDeque源码里那些看起来高深莫测的位运算一旦放到具体状态图里其实就是“表盘走了一圈回到12点”那么简单。如果你也打算深入读别的集合源码比如HashMap的扩容、ConcurrentHashMap的分段结构我强烈建议用同样的方法先摸清楚核心字段的语义再用手动模拟的方式跑一遍关键路径最后再看源码对照。这样学下来的东西才是真正能写进代码里的理解而不是看完就忘的“面试八股”。
RELATED

相关推荐

AI审阅答辩材料:PDF解析与多Agent证据链检查

AI审阅答辩材料:PDF解析与多Agent证据链检查

上周我把刚改完的答辩 PPT 转成 PDF,丢进 Workbuddy 里,本意是让 AI 当个免费评委帮我看看还有什么硬伤。结果它通读一遍之后,第一句话让我有点没绷住:"整体结构还行,但第三章的每一个结论,我都没找到…

📅 2026/10/8 20:55:22
网上书城毕设源码实战:JSP/Servlet/MySQL三层架构全拆解

网上书城毕设源码实战:JSP/Servlet/MySQL三层架构全拆解

简介:这套JavaWeb网上书城毕业设计资料包,定位于计算机专业学生毕业设计、课程设计及JavaWeb开发者进阶学习。资源提供完整的网上书城项目源码,并配套数据库初始化脚本、设计文档、论文文档和系统页面截图,压缩包约28.42MB&#x…

📅 2026/10/8 20:55:22
SpringAI ReactAgent实战:从工具调用到智能审核的Agent编排

SpringAI ReactAgent实战:从工具调用到智能审核的Agent编排

1. 开篇:从“会调用工具”到“会自己判断”的这一步先扯点题外话。如果你关注过SpringAI,大概率已经经历过前面那几“掌”——从最简单的ChatClient对话,到PromptTemplate模板管理,再到OutputConverter结构化输出、Function Calli…

📅 2026/10/8 20:50:19
MORE NEWS

更多资讯

📰

Windows 下 VSCode 配置 OpenCode:把 settings 改到 TaoToken 的完整步骤

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

五个没人会主动教你的 Claude Code 实用命令:从 401 报错到 Base URL 改到 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

2026年AI论文网站全攻略:用TaoToken统一Key打通学术写作工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

全民“养虾”还是全员“裸奔”?OpenClaw AI Agent 权限边界与 TaoToken 统一 Key 实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

text-to-cad 实战:从自然语言到 STEP/STL/GLB 的几何建模管线

1. 从一段文字到三维实体:text-to-cad 到底在解决什么问题第一次听到 "text-to-cad" 这个词,很多人会下意识觉得它是个噱头——输入一句话就能生成 CAD 模型?这听起来像是把设计师十几年的经验压缩成一次回车键。但真正在机械设计、…

📰

一看就是AI做的网页?ClaudeCode+5句提示词5分钟告别「蓝紫色」AI味儿|TaoToken统一Key实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬