
讲真“Array、ArrayList、LinkedList 长度到底能不能变”这个问题我在面试里被问过在 code review 里被怼过也亲手在线上代码里踩过坑。很多新手以为 ArrayList 能 add 就是可变长度数组用不了就固定长度LinkedList 也是可变——但对了一半。真正要搞明白的是“长度可变”背后的实现机制以及这三个容器在“变长”这件事上的本质差异。这篇文章我直接从实操出发掰开揉碎讲清楚。1. 先亮结论谁可长谁不可长1.1 三个“容器”的可变长度结论表先把最核心的结论摆出来后面再展开讲原理。记住这张表基本够应付大多数场景。容器类型长度是否可变可变方式底层结构Array数组不可变没有原生方式连续内存长度在创建时固定ArrayList可变逻辑长度自动扩容内部数组替换基于 Object[] 的动态数组LinkedList可变天然如此增删节点无容量上限双向链表节点离散存储这里有一个非常关键的概念区分ArrayList 和 LinkedList 的“可变”不是一回事。ArrayList 是“假可变”它内部还是数组长度不够了就 new 一个更大的数组再把老数据考进去然后让引用指向新数组——底层数组的物理长度其实没变只是换了个更大的数组。LinkedList 是“真可变”它天生就没有长度上限加一个节点就多一个节点根本不存在“扩容”这个概念。1.2 为什么数组偏偏不可变数组创建的方式就决定了它的长度锁死。比如int[] arr new int[10]这一句写下去JVM 就在堆上分配了一块连续内存大小是 10 个 int也就是 40 字节。数组对象的长度信息会存在对象头里你在代码里用arr.length拿到的就是这个固定值。有人说“那我把 arr 重新 new 一个更大的数组不就能变了吗”对但你看看你干的事——你这是重新创建了一个新数组然后把引用指向它并没有让原来的数组变长。原来的数组还躺在堆内存里等着被垃圾回收。这就是很多人绕不清的点变量引用可以换指向但数组对象本身的长度从出生到销毁都是固定的。从 JVM 层面看数组对象的大小在分配时就要确定因为连续内存的分配必须知道要申请多少空间。不像链表可以用一个节点一个节点的离散对象拼接数组必须一次性把空间全划出来所以长度不可变是刻在它基因里的。1.3 “可变”到底变的是什么拿生活类比一下数组就像停在固定车位的卡车车斗长度是固定的装满了就装不下了你要么换一辆更大的卡车新建数组要么就分批运逻辑处理。ArrayList 像一节火车车厢但车厢本身长度固定装满了就“再加一节车厢”并把旧车厢的货搬过去。LinkedList 更像一条项链你想加一颗珠子就穿一颗想减一颗就摘一颗不存在的“容量上限”。这里要特别提醒ArrayList 扩容时那个“搬货”的动作是有成本的。下一节我专门讲数组怎么处理“变长需求”再下一节深挖 ArrayList 的扩容机制。2. 数组长度不可变但你能怎么绕数组不可变是硬约束但我们业务代码里经常需要“假装让数组变长”。最常见的手段就是Arrays.copyOf和System.arraycopy。顺便提一句网上一搜“使用 array 类对数组排序”说的就是用java.util.Arrays这个工具类对数组操作排序、拷贝、填充都是它。2.1 Arrays 类排序的三种典型写法Arrays.sort是数组排序的标配JDK 里实现得很讲究。基本类型数组用双轴快速排序Dual-Pivot Quicksort对象数组用 TimSort一种结合了归并和插入的稳定排序。看代码int[] nums {5, 3, 8, 1, 9}; Arrays.sort(nums); System.out.println(Arrays.toString(nums)); // [1, 3, 5, 8, 9]对象数组排序要传 ComparatorString[] names {Tom, alice, Bob}; Arrays.sort(names); // 默认按字典序注意大写在前 Arrays.sort(names, String.CASE_INSENSITIVE_ORDER); // 忽略大小写还有一种只排一部分区间的写法特别适合只关心前几个最大值的场景int[] arr {10, 2, 33, 4, 15, 6}; Arrays.sort(arr, 1, 5); // 只排序索引1到4之间的元素 System.out.println(Arrays.toString(arr)); // [10, 2, 4, 15, 33, 6]注意事项Arrays.sort对基本类型是原地排序不返回新数组所以别写成arr Arrays.sort(arr)这不能通过编译。对象数组排序要求元素实现Comparable或者你提供Comparator否则运行时会抛ClassCastException。2.2 数组扩容的两条路手动 copyOf 与 System.arraycopy先说Arrays.copyOf它是“变长需求”最简单的实现。内部实现就是先创建一个新数组目标长度然后调用System.arraycopy把老数据复制过去最后返回新数组。int[] oldArr {1, 2, 3}; int[] newArr Arrays.copyOf(oldArr, 10); // newArr 的 length 是 10前三个元素是 1,2,3后面全是0System.arraycopy是更底层的 native 方法复制效率高但参数多、易出错。它的参数顺序是源数组、源起始位置、目标数组、目标起始位置、复制长度很多人记错我建议直接记成“五连参数src, srcPos, dest, destPos, length”。int[] src {1, 2, 3, 4, 5}; int[] dest new int[8]; System.arraycopy(src, 0, dest, 2, 3); // dest 现在是 [0, 0, 1, 2, 3, 0, 0, 0]如果你要经常手动扩容我强烈建议封装一个小工具方法public static int[] ensureCapacity(int[] arr, int minLength) { return arr.length minLength ? arr : Arrays.copyOf(arr, minLength); }这样至少不会在每个业务方法里都写一堆复制逻辑。2.3 数组工具类的隐藏细节Arrays类里还有很多容易忽略的好东西。Arrays.equals比较两个数组内容是否相等而不是比较引用这点经常有人用去比数组然后一脸懵。Arrays.fill可以快速把整个数组或某一段填成同一个值写测试数据特别好用。Arrays.asList可以把数组转成 List但注意这个 List 不能 add、不能 remove因为它的底层还是原数组长度锁定想增删会抛UnsupportedOperationException。还有一个冷门但实用的Arrays.hashCode对数组内容计算哈希码如果你自定义对象里有数组字段要重写 hashCode 可以直接调它。说实话日常开发中大部分“数组需要变长”的诉求最终都会直接用 ArrayList 解决。除非你是做底层的数据结构封装、网络缓冲解析等性能敏感的场景才需要手动管理数组和扩容。我自己在做某些协议解析工具时就坚持用byte[]手动扩容因为 ArrayList 的装箱开销在一些极端流量下很难接受。3. ArrayList 的扩容机制拆解ArrayList 的扩容机制是面试高频题也是线上性能问题的高发区。搞懂它不仅面试能聊透写代码时也能知道什么时候该预设初始容量。3.1 构造方法里的初始容量陷阱先看三个构造方法的区别。new ArrayList()用的是默认空数组第一次 add 时才懒加载容量为 10。new ArrayList(100)直接初始化容量为 100 的数组空间。new ArrayList(Collection? extends E c)会用集合内容填充容量等于集合大小。这里最大的坑是不要以为new ArrayList()一开始就给你 10 个容量。JDK 8 里它初始是DEFAULTCAPACITY_EMPTY_ELEMENTDATA一个空的 Object[]。第一次 add 时才会触发 grow直接扩容到 10。如果你确定要放很多元素一定用构造器指定初始容量。举个实际例子一个接口要返回 10 万条数据你new ArrayList()然后循环 add期间会发生多少次扩容按 1.5 倍的增长曲线从 10 开始10 → 15 → 22 → 33 → 49 → 73 → 109... 要扩到能装下 10 万大约要扩 20 多次每次扩容都是一次 O(n) 的数组复制加起来就是 O(n²) 的搬运成本。正确的写法是提前预估容量ListString list new ArrayList(100000);3.2 add 方法触发扩容的判断逻辑看源码add(E e)的两步第一步调用ensureCapacityInternal(size 1)第二步在数组的 size 位置写入元素。ensureCapacityInternal里再做ensureExplicitCapacity判断“如果需要的容量超过了当前数组的长度”就调用grow。public boolean add(E e) { ensureCapacityInternal(size 1); elementData[size] e; return true; }从这个逻辑你能看出一个细节size和elementData.length是两个不同的概念。size是逻辑长度也就是里面装了几个元素elementData.length是物理容量。这也解释了为什么new ArrayList(100)刚创建时list.size()返回的是 0而不是 100。很多人跑过来问“为什么我初始容量设了 100size 还是 0”——因为容量是物理空间size 才是元素个数两者不能混淆。3.3 grow 方法是怎么算新容量的核心代码是这样的不同 JDK 版本略有差异但逻辑一致private Object[] grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); // 1.5倍 if (newCapacity - minCapacity 0) newCapacity minCapacity; if (newCapacity - MAX_ARRAY_SIZE 0) newCapacity hugeCapacity(minCapacity); return elementData Arrays.copyOf(elementData, newCapacity); }oldCapacity (oldCapacity 1)就是老容量加上老容量的一半等价于乘 1.5。右移一位比除以 2 更快这是 JDK 里的常见优化。1.5 倍有没有道理有。如果每次只加一个容量那么加 n 个元素要复制 n 次复杂度 O(n²)太慢。如果每次翻倍空间浪费又偏大。1.5 倍是一个时间空间折中的经验值因为每次扩容后的可用空间足够支撑后续多次 add又不至于一下子申请巨大内存。你以为这是拍脑袋不是这是一个经典的扩容策略权衡。MAX_ARRAY_SIZE的限制也要知道当容量快要超过Integer.MAX_VALUE - 8时会走hugeCapacity最多到Integer.MAX_VALUE。因为某些 JVM 在数组里保留对象头需要一定空间所以留了 8 的余量防止OutOfMemoryError时抛得不明不白。3.4 扩容代码实战与性能建议配一段完整的测试代码看看扩容到底发生了什么import java.util.ArrayList; import java.util.List; public class GrowthDemo { public static void main(String[] args) { ListInteger list new ArrayList(); for (int i 0; i 50; i) { list.add(i); } // 用反射或debug时可以看到 elementData.length 的变化 // 我从16开始循环 add跟踪到的容量变化是 // 10 - 15 - 22 - 33 - 49 - 73 System.out.println(size list.size()); } }注意上面这个容量序列是我按 JDK8 的初始 10 推断的。实际用调试模式去看elementData.length你会直观看到它跳变式增长。从我实测过的场景来说建议就是一句话能预估就预估预估不了就选 LinkedList 或干脆用数组处理。如果确定了容量直接传构造参数能省掉大量 Arrays.copyOf 的时间。对于需要反复增删的操作ArrayList 的扩容机制频繁触发时性能下降是非常明显的。4. LinkedList 长度天然灵活但别高兴太早4.1 节点结构与长度维护LinkedList 的底层是双向链表每个节点Node持有三个引用prev、item、next。它内部通过first和last两个指针维护链表头尾通过size字段记录元素个数。增加元素时不需要搬动任何已有数据只需要创建新节点调整前后节点的 next/prev 指向然后把 size 加一。private static class NodeE { E item; NodeE next; NodeE prev; Node(NodeE prev, E element, NodeE next) { this.item element; this.next next; this.prev prev; } }从这个结构看LinkedList 根本没有 length 的限制它只受限于堆内存大小。这个“天然可变”和 ArrayList 的“扩容动态可变”有本质区别。4.2 为什么说它是“可变”的链表结构的变化只是指针操作。往链表中间插入一个节点时间复杂度是 O(1)——前提是你已经拿到了那个位置的节点引用。这句话要划重点很多人以为 LinkedList 随便 insert 都很快其实前提是你在“已知节点位置”的情况下插入这很反直觉。如果是按索引插入你还得先从头或尾部遍历找到那个索引这步就是 O(n)。给一段典型的中间插入操作LinkedListString link new LinkedList(); link.add(A); link.add(C); link.add(D); // 在 A 和 C 之间插入 B直接用 ListIterator 定位 var it link.listIterator(1); it.add(B); System.out.println(link); // [A, B, C, D]listIterator(1)返回的是指向索引 1 位置的迭代器add直接插入不需要遍历两次。4.3 和 ArrayList 的取舍很多人用 LinkedList 是因为听说“它增删快”但实际用起来反而更慢。原因很简单ArrayList 的增删虽然在中间位置要搬移元素但按索引访问是 O(1)LinkedList 按索引访问是 O(n)拿到节点后插入才是 O(1)。拿数据说话我自己做过一个百万级元素测试在列表尾部循环 addArrayList 和 LinkedList 差别不大ArrayList 因为缓存局部性反而更快在中间频繁插入删除LinkedList 只有在定位到节点的情况下才占优如果每次都按索引找性能依然惨不忍睹。还有一个经常被忽略的点LinkedList 的每个节点都是一个对象占用额外内存。存 100 万个 IntegerArrayList 是一个大数组加 100 万个 Integer 对象LinkedList 是 100 万个 Node 对象加 100 万个 Integer 对象Node 还有两个引用字段。内存开销上 LinkedList 明显更大。所以我的选型结论是需要随机访问下标、尾部追加、存储大列表用 ArrayList需要频繁在头部删除/插入或者维护一个队列但是不想用 ArrayDeque 的时候才考虑 LinkedList。现在 Java 的队列场景我基本都用 ArrayDeque它综合性能比 LinkedList 更好。5. 常见问题与排查技巧实录5.1 数组越界ArrayIndexOutOfBoundsException这个异常的本质是访问索引超出了数组的 length 范围。最常见的起因是用变量控制数组下标时没检查边界或者循环条件用了 length结果索引从 1 开始导致最后一个元素访问不到反过来如果用了就越界。举个真实的例子String[] rows {a, b, c}; for (int i 0; i rows.length; i) { System.out.println(rows[i]); // i3 时越界 }排查技巧异常信息会直接告诉你 ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3。看到 index 和 length 两条信息第一时间去看数组声明处和取下标处的边界条件不要瞎猜。5.2 ArrayList 的 IndexOutOfBoundsExceptionArrayList 取元素越界抛的也是IndexOutOfBoundsException但常常比数组越界更隐蔽因为 size 是动态变化的。典型场景是先判断list.size() 0再list.get(0)看起来没问题但是多线程并发下另一个线程把 list 清空了再 get(0) 就会炸。这也是这段代码没有做线程安全控制的典型案例。排查这类问题要结合日志上下文看 size 到底是多少、操作发生在哪一行。如果并发场景需要安全删除读取建议用迭代器或者加锁而不是先查再取。5.3 LinkedList 的 get 性能误用LinkedList 的get(index)会从头部或尾部开始遍历直到找到目标节点。如果你在循环里不断get(i)来遍历 100 万个元素复杂度是 O(n²)慢到怀疑人生。这个坑我见不少同学踩过。正确的打开方式是使用迭代器for (String s : linkedList) { // 遍历时内部使用迭代器不会每次从头找 }或者如果你明确要随机访问就别用 LinkedList。5.4 高频踩坑速查表问题现象原因解决数组长度固定不够用ArrayIndexOutOfBoundsException数组创建长度过小用 ArrayList 或 copyOf 扩容ArrayList 扩容频繁耗时高GC 压力大初始容量太小构造时指定容量Arrays.asList 后 addUnsupportedOperationException转换出的 List 是固定长度视图用 new ArrayList(Arrays.asList())LinkedList 大量 get性能极差get 是 O(n) 遍历用迭代器或换 ArrayList数组排序结果不对排序顺序错误对象没实现 Comparable提供 ComparatorArrayList 多线程并发数据混乱/越界非线程安全使用 CopyOnWriteArrayList 或加锁6. 一些实用经验与建议6.1 到底该怎么选写代码前先问自己几个问题你会按索引随机访问吗你会从头部删除吗你能预估数据量吗会频繁插入中间吗这些问题的答案基本能决定你用什么。从我维护过的项目来看90% 以上场景 ArrayList 都是最优解LinkedList 的使用场景其实很窄。数组则主要用于定长数据、原始类型存储、性能敏感的底层模块。6.2 性能测试的真实数据我简单跑过一个对比测试环境是 JDK 17往列表尾部插入 50 万个元素并读取全部元素操作ArrayList 耗时LinkedList 耗时尾部 add 50万约 35ms约 90ms随机 get 50万约 8ms约 17000ms头部 remove 5万约 150ms约 3ms数据说明一切没有所谓“哪个绝对好”场景不同差异巨大。头部大量删除的队列场景LinkedList 确实赢随机访问场景ArrayList 秒杀它。6.3 最后的实操心得根据我个人经验数组的可变长度需求优先用 ArrayList 替代但如果你要做网络协议解析这类高吞吐的底层模块建议还是用byte[]搭配Arrays.copyOf或System.arraycopy手动管理避免集合框架的装箱和迭代器开销。还有一个老生常谈但很重要的点集合容器用完之后如果有复用的情况记得先clear()或者重新 new不要复用之前装过大量数据的对象否则 size 和 capacity 的差异会让你代码逻辑变得难懂。总之长度可变不可变不是记结论就完了看源码、测数据、理解底层存储结构你在选型时才能做到真正有底气。