从顺序表到ArrayList:扩容机制、源码剖析与面试避坑指南

📅 发布时间:2026/10/8 14:40:22
从顺序表到ArrayList:扩容机制、源码剖析与面试避坑指南
前阵子我面了一个自称“Java基础扎实”的候选人聊到集合框架时随手问了句ArrayList底层扩容具体是怎么做的对方沉默了几秒挤出几个字“好像是数组满了就复制到一个更大的数组里。”再问新数组容量多大、为什么这样设计就彻底答不上来了。这个场景我想很多搞Java的人都见过。你让他写业务代码ArrayList用得滚瓜烂熟你让他讲讲ArrayList的本质——一个动态数组实现的顺序表很多人反而支支吾吾。《java数据结构基础-顺序表》这个标题看起来很“课本”但真正能把顺序表说透的人Java基础不会差到哪里去。这篇文章不讲虚的我们从存储模型、手写实现、扩容计算、实战选型一路拆到底顺带把面试里最爱挖的几个坑也一并排掉。1. 一次面试追问暴露了多少人对顺序表的理解仅停留在“数组”两个字1.1 顺序表到底是个什么东西顺序表是线性表的一种存储方式。线性表强调“元素之间有先后关系”而顺序表则要求这些元素在内存里一块连续的区域依次存放。你可以把它理解为“数组套了一层动态扩容的外壳”逻辑上a0, a1, a2, …, a(n-1) 一个个排好队物理上它们在内存里也是紧挨着放的前一个元素后面紧跟下一个元素结构上只需要知道首地址就能通过“基址 偏移”算出任意元素的位置。用生活场景类比顺序表就是电影院的一排座位。座位号连在一起中间不空位你只要知道1号座在哪就能顺着数到任意号。链表则像游乐园里散落的摊位每个摊位都挂一块牌子写着“下一个摊往东走50米”你得一家一家跳过去。Java里天天用的ArrayList就是顺序表最出名的实现。理解它等于同时掌握了“数组的底层原理 线性表的设计思想 Java集合框架的一条主线”这也是为什么几乎所有数据结构教材都把顺序表放在线性表这一章最前面。1.2 为什么学Java数据结构应先从顺序表开始很多初学者喜欢一上来就学HashMap、红黑树觉得高级。但问题是HashMap的扩容、链表转红黑树、扰动函数……这些概念全都建立在数组和链表的组合之上。顺序表是这个知识体系里最底层的一块砖栈可以用顺序表实现Stack、ArrayDeque本质上就是动态数组队列也可以用顺序表实现循环队列改一改就是跳表、哈希表、堆排序里的“堆”底层依然是数组甚至理解ArrayList源码之后再去看Vector、CopyOnWriteArrayList会轻松很多。顺序表不是说“这个我也会写array[i]”就完了。真正值钱的部分是你会不会处理扩容边界会不会设计插入删除的元素搬移能不能解释均摊复杂度这些能力直接影响你读任何集合框架源码的效率。有趣的是顺序表这个名词更多出现在教材里而Java面试里99%的问题都不直接叫它“顺序表”而是问“ArrayList的扩容机制”“ArrayList和LinkedList怎么选”“为什么数组下标从0开始”。本质上全是顺序表。本文后面所有内容都会刻意把这些考题和顺序表的知识点串起来讲不只是背答案。2. 顺序表的存储模型连续内存、下标基址与容量/长度的分野2.1 连续内存与下标访问的底层逻辑数组能支持O(1)随机访问靠的是一句非常简单的公式第i个元素地址 首地址 i × 单个元素大小这就是为什么数组下标从0开始而不是从1开始。下标i在这里不是“第几个”的意思而是“偏移了几个单位”。第一个元素的偏移量是0所以下标是0如果从1开始每次访问都要做一次index - 1的换算白白浪费一次CPU操作。你可以做个小实验用System.arraycopy去拷贝一个100万元素的数组和用循环逐个arr[i]赋值性能差距极其明显。原因就是arraycopy在JVM层面可以用向量化指令、连续内存块批量复制而普通循环本质上还是逐元素搬移。顺序表插入、删除时大量依赖这种连续内存复制操作这也是它和链表在底层行为上的关键差异。不过一个易忽略的细节是Java数组里存放的其实是对象的引用而不是对象本体。顺序表中元素的地址连续不代表那些对象在堆内存里也连续。真正连续的是引用数组对象本身可能散落在堆里。所以严格来说ArrayList的“连续性”更多体现在引用遍历时对CPU缓存友好这已经比链表那种“一个节点跳一个地址”好太多了。2.2 容量与长度两个完全不同的概念顺序表里有几个概念一旦混了后面看源码必懵概念含义ArrayList对应字段容量(capacity)当前数组最多能装多少元素elementData.length长度(size)当前实际存了多少元素size预分配为防止频繁扩容提前多申请空间DEFAULT_CAPACITY10打个比方容量是你家车库总共有几个车位长度是现在停了几辆车。车库可以空很多车位但一辆车占了超出总车位的位置就会出大问题数组越界。顺序表设计上有一个经典取舍如果每次add都精确扩容一个位置那插入n个元素就得扩容n次每次都要全量复制时间复杂度直接飙到O(n²)。所以工业级实现一定会预留额外空间用“容量冗余”换“操作次数降低”。这也是动态数组和普通数组最本质的区别——普通数组一旦创建容量固定顺序表则把“管理容量”这件事内建到了自己身上。ArrayList的空构造器和传初始容量的构造器并不一样。JDK 8之后默认构造器不直接new长度为10的数组而是先指向一个空的DEFAULTCAPACITY_EMPTY_ELEMENTDATA等第一次add时才真正扩容到10。这个懒加载优化就是为了避免那些“创建了ArrayList但从不往里存数据”的对象白白占内存。这种细节面试时能主动说一句会很加分。2.3 泛型数组创建的经典难题如果你尝试自己手写一个泛型顺序表第一行就会卡住E[] data new E[10]; // 编译不通过Cannot create a generic array of EJava的泛型在运行时会被擦除E在运行期根本没有具体类型所以JVM无法确定你要创建的数组到底是什么类型。实际开发中通用的写法是private Object[] elementData; ... SuppressWarnings(unchecked) E e (E) elementData[index];先创建一个Object[]需要取元素时再强转成E。ArrayList源码里就是这么干的你可以在ArrayList.java里看到大量的SuppressWarnings(unchecked)。这个设计不是偷懒而是Java泛型机制下的无奈之举。真正理解这一点你才算过了“手写泛型容器”这关。另一个相关考点是toArray()为什么老报ClassCastException这也是泛型擦除的连锁反应。后面第5章我会专门展开讲。3. 手写一个能用的顺序表核心方法源码逐段拆解光说不练假把式。我建议每个学Java的都亲手写一个迷你版SeqList不用实现List接口的全部方法能把增删改查和一个扩容函数写明白就够了。写一遍再看ArrayList源码你会觉得那些代码就是你平时会写的东西。3.1 整体设计与字段定义public class SeqListE { private static final int DEFAULT_CAPACITY 10; private static final int MAX_ARRAY_SIZE Integer.MAX_VALUE - 8; private Object[] elementData; private int size; public SeqList() { this.elementData new Object[DEFAULT_CAPACITY]; } public SeqList(int initialCapacity) { if (initialCapacity 0) { throw new IllegalArgumentException(initialCapacity不能为负数: initialCapacity); } this.elementData new Object[initialCapacity]; } public int size() { return size; } public boolean isEmpty() { return size 0; } }字段就两个Object[] elementData和int size。所有操作都是围绕这两个字段展开。DEFAULT_CAPACITY设为10是经验值太小会导致频繁扩容太大会白占内存10是经过多年实践验证出来的一个折中数字。3.2 添加元素尾部add与中间add尾部添加是最常见的操作public boolean add(E e) { // 确保容量足够 ensureCapacityInternal(size 1); elementData[size] e; return true; } private void ensureCapacityInternal(int minCapacity) { if (elementData.length minCapacity) { grow(minCapacity); } }关键逻辑先看当前数组长度够不够不够就扩容然后把元素放进去size自增。不要小看这个ensureCapacityInternal它保证了elementData[size] e这一步永远不会越界。中间位置插入则复杂一些public void add(int index, E element) { rangeCheckForAdd(index); // index必须在[0, size]之间 ensureCapacityInternal(size 1); System.arraycopy(elementData, index, elementData, index 1, size - index); elementData[index] element; size; } private void rangeCheckForAdd(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(Index: index , Size: size); } }核心就一句System.arraycopy把从index开始的所有元素统一往后搬一格再把新元素放到空出来的位置上。这里有两个注意点arraycopy的源和目标可以是同一个数组JVM保证这种情况下复制结果正确不会出现覆盖错乱插入位置越靠前需要搬移的元素越多最坏情况是头插O(n)。3.3 删除元素按下标删与按值删按下标删public E remove(int index) { rangeCheck(index); E oldValue (E) elementData[index]; int numMoved size - index - 1; if (numMoved 0) { System.arraycopy(elementData, index 1, elementData, index, numMoved); } elementData[--size] null; // 让GC能回收被删对象 return oldValue; }删除同样靠arraycopy把后面的元素整体往前挪。最后一句elementData[--size] null很容易被忽略但它是必要的如果不把最后一个位置的引用置空那些已经被删除的、但还留在数组末尾的引用会一直存活内存无法被回收。业务代码里如果频繁增删大对象又不注意这点会莫名出现内存占用偏高。按值删则要处理equals和nullpublic boolean remove(Object o) { for (int i 0; i size; i) { if (o null ? elementData[i] null : o.equals(elementData[i])) { remove(i); return true; } } return false; }这里有个非常经典的面试陷阱list.remove(2)到底删的是下标2还是元素2答案是下标2。因为int会优先匹配remove(int index)。如果你想让编译器走remove(Object)必须写成list.remove(Integer.valueOf(2))。这个坑我在下面的第5章还会用代码具体演示。3.4 查询与修改get/set/indexOf这三个方法相对简单但恰好能帮我们重新理解“为什么顺序表随机访问是O(1)”public E get(int index) { rangeCheck(index); return (E) elementData[index]; } public E set(int index, E element) { rangeCheck(index); E oldValue (E) elementData[index]; elementData[index] element; return oldValue; } public int indexOf(Object o) { for (int i 0; i size; i) { if (o null ? elementData[i] null : o.equals(elementData[i])) { return i; } } return -1; }rangeCheck只校验index是否在[0, size)范围内。get的耗时与集合大小无关因为底层就是一次数组下标访问。indexOf则要逐个比较复杂度O(n)这说明哪怕同一个容器查询方式不同代价也不同。动手写一遍之后你会自然理解一个问题为什么ArrayList的get比LinkedList快但add到中间可能反而慢一切都写在这几个方法里了。4. 扩容机制背后的数学账1.5倍扩容率与均摊复杂度4.1 1.5倍扩容的数学逻辑扩容是动态数组的核心。我的简化版grow方法可以这样写private void grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); // 相当于乘以1.5 if (newCapacity minCapacity) { newCapacity minCapacity; } if (newCapacity MAX_ARRAY_SIZE) { newCapacity hugeCapacity(minCapacity); } elementData Arrays.copyOf(elementData, newCapacity); } private int hugeCapacity(int minCapacity) { if (minCapacity 0) { throw new OutOfMemoryError(数组容量超过整数上限); } return (minCapacity MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }关键代码是oldCapacity (oldCapacity 1)。右移一位相当于除以2所以总容量变成了原来的1.5倍。为什么是1.5倍而不是2倍、3倍扩容倍数过小比如1.1倍扩容频繁每次扩容都要复制总复制开销大扩容倍数过大比如3倍剩余空间浪费严重明明只存了10个元素扩容一次就占30个位置1.5倍是时间与空间的折中均摊下来每次新增元素的操作成本仍只有O(1)同时内存浪费控制在可接受范围。网上还有种说法是1.5倍接近黄金分割比例1.618能兼顾“过去释放的空间可以被未来重新利用”这种说法有一部分道理但官方并没有给出严格数学证明。我个人倾向于把它理解为“大量工程实践后大家都觉得这个比例舒服”。真正面试时你只要能讲清楚“倍数太小复制频率高、倍数太大浪费内存、1.5倍是折中”就已经超过90%的候选人了。4.2 扩容上限MAX_ARRAY_SIZE与OutOfMemoryErrorMAX_ARRAY_SIZE Integer.MAX_VALUE - 8。为什么不是Integer.MAX_VALUE因为数组在JVM里有个对象头有些JVM实现里需要预留几个字节存数组长度如果直接用Integer.MAX_VALUE做数组大小可能连对象头都放不下创建一个极限长度的数组反而会OutOfMemoryError。日常开发里数组能撑到接近2的31次方个元素几乎不可能但你知道这个上限就够了。面试如果被问到“ArrayList最大能存多少元素”你能说出“受Integer.MAX_VALUE-8限制而且实际JVM堆内存才是决定性瓶颈”这个答案就是满分。顺带一提扩容不是无脑按1.5倍算。当size1超过1.5倍计算结果时比如你手动设置了一个很大的初始容量然后用ensureCapacity代码会用minCapacity兜底。这里体现的设计原则是容量计算以“满足当前需求”为优先“预留空间”只是优化手段。4.3 均摊复杂度O(1)的简单推导很多人背过“ArrayList的add均摊复杂度是O(1)”但不知道这个结论怎么来的。我用人话推导一遍假设数组初始容量为1每次放满就翻倍扩容。那么第1次add直接放0次复制第2次add触发扩容复制1个元素第3、4次add其中一次触发扩容复制2个元素第5~8次add其中一次触发扩容复制4个元素以此类推。往n个元素里插入总复制次数是1 2 4 8 … n/2 ≈ n。也就是说插入n个元素额外复制操作总数大约是n次平均到每次add成本还是常数级别所以均摊复杂度是O(1)。这就是“摊还分析”最朴素的思想扩容虽然单次很贵但贵得次数很少把贵的成本摊到每一次普通操作上平均下来就便宜了。同理1.5倍扩容下的均摊仍然是O(1)只是常数因子变大了一点点。5. 顺序表实战对比性能账本、经典接口陷阱与误用场景5.1 顺序表与链表的真实性能账本先放一张对比表再聊环境变量。操作顺序表(ArrayList)链表(LinkedList)尾部插入O(1)均摊O(1)头部插入O(n)全体右移O(1)中间插入O(n)O(n)但要先找到节点按下标读取O(1)O(n)必须遍历按下标删除O(n)O(n)且要处理前后指针内存占用预分配空间可能浪费每个节点多存两个引用CPU缓存友好度高引用连续低节点散布很多人在“ArrayList vs LinkedList”的选择题上只知道一个“查多用ArrayList、增删多用LinkedList”。但真实工程里顺序表的表现往往更好。原因有三个第一随机访问这个优势是碾压级的ArrayList的get是真正的O(1)LinkedList找第n个元素再快也得从头跳 第二顺序表连续存储对CPU缓存友好遍历时JVM可以预取连续内存而链表各节点在堆里乱窜缓存命中率低 第三LinkedList每个节点都有prev和next两个引用存一个Integer也要额外几十字节内存占用比ArrayList高得多。所以实际项目里除非你确定要频繁在头部插入删除否则优先选ArrayList基本不会错。我自己写过不少队列、任务调度类组件最后都从LinkedList换成了ArrayDeque或ArrayList性能反而更稳。5.2 从Arrays.asList到subListJava里那些“假顺序表”的坑顺序表用起来简单但Java的集合框架里藏着几个非常容易误用的变体。Arrays.asList返回的List看起来像ArrayList实际上是一个定长的Arrays$ArrayList它直接复用传入的数组ListInteger list Arrays.asList(1, 2, 3); list.add(4); // 抛UnsupportedOperationException Integer[] arr {1, 2, 3}; ListInteger list2 Arrays.asList(arr); arr[0] 99; System.out.println(list2.get(0)); // 输出99两个坑不能add/remove且修改原数组会直接影响List。原因是这个内部类没有重写add/remove且底层直接引用原数组。如果你需要可变的独立列表记得new ArrayList(Arrays.asList(...))。subList是另一个经典巨坑。它返回的是原List的一个视图不是副本ListInteger list new ArrayList(); list.add(1); list.add(2); list.add(3); ListInteger sub list.subList(1, 3); list.add(4); // 这里对原list做了结构性修改 sub.get(0); // 抛ConcurrentModificationException原因很简单subList会记录父List的modCount一旦父List发生变化子视图的修改计数就对不上了。所以如果你拿到subList之后还要操作原列表要么立刻把subList转成独立副本new ArrayList(list.subList(...))要么就让subList只在局部使用用完就扔。5.3 toArray的类型丢失问题前面提到泛型擦除这里它的影响就显现了ListString list new ArrayList(); list.add(hello); String[] arr (String[]) list.toArray(); // 运行期ClassCastExceptionList.toArray()无参版本的返回类型是Object[]虽然是String值但数组本身的运行时类型是Object[]强转成String[]会直接炸。正确姿势是String[] arr list.toArray(new String[0]); // 或者传入与list等长的空数组例如list.toArray(new String[list.size()])JDK里对toArray(new String[0])其实有专门优化会根据集合大小重新分配数组传0长度的空数组既简洁又不会浪费。这类细节平时不踩一次坑真的很难记住。6. 面试高频追问与我踩过的那些坑给初学者的实操建议6.1 三个值得背下的底数10、1.5和MAX_VALUE-8面试里关于ArrayList的高频问题翻来覆去就绕不开这三个数字默认初始容量10扩容倍数1.5倍最大数组容量Integer.MAX_VALUE - 8。我把这三个数字理解为“Java动态数组的身份证”。背下来只能算及格能解释来历才算过关初始容量10是大量实践验证的折中值太小频繁扩容太大浪费内存1.5倍扩容是复制频率与内存浪费的平衡点MAX_VALUE-8是给JVM对象头留的空间防止连头都放不下。另外面试官还爱追问“批量添加100万条数据怎么做最高效”。我处理这种场景一般先估算数据量然后手动new ArrayList(expectedSize)甚至调用ensureCapacity预分配避免中途扩容。这样可以少复制很多次尤其在大批量数据导入时性能差距肉眼可见。6.2 我在项目中选型与使用的三条经验第一读多写少或按下标取数据的场景无脑选ArrayList。比如商品价格查询、系统配置读取数据量不大但访问频繁顺序表就是最合适的容器。第二需要频繁删除时不仅仅要考虑复杂度还要注意删除方向。比如一个列表里要删掉符合条件的多个元素用for循环正着删会导致下标错乱。我会改成倒序遍历或者直接使用removeIflist.removeIf(item - item.getStatus() 1);removeIf底层是遍历一次、统一搬移比循环里逐个remove高效得多这也是JDK对顺序表删除场景专门做的优化。第三在for-each或Iterator遍历过程中不要直接调用add/remove。顺序表的迭代器采用fail-fast机制任何结构性修改都会立刻抛ConcurrentModificationException。我职业生涯里最早期写代码就因为在for-each里删元素被这个异常教育过一次。现在推荐的做法就是上面说的removeIf或者收集满足条件的元素循环结束后统一remove。最后说一点个人体会。顺序表在数据结构里属于最简单的模型越是简单的东西越容易被人忽视。我带过的几个新人一个个都能熟练写业务但让他们从零实现一个扩容数组很多人想不起来要用System.arraycopy更说不清为什么容量和长度是两个字段。实际上把顺序表亲手实现一遍之后再去看ArrayList.java你会觉得那无非就是“自己写过的代码加上更多边界判断和优化”。所以我的建议很直接无论你是准备Java面试还是单纯想把数据结构基础夯扎实都别跳过顺序表。动手写一个SeqList然后打开JDK源码对照一遍再顺手把Arrays.asList、subList、toArray这几个坑都测一遍。这一套下来你对Java集合框架的理解就完全不是“会用API”的层面了。顺序表是起点但它是那种必须好好走的起点后面学栈、队列、哈希表都会因此顺畅很多。