ArrayList 底层原理与数组互转、扩容计算全解析
都说 ArrayList 是 Java 集合里最没存在感的一个类——用到就 new 一个add 完遍历好像没有任何秘密。但真有人在面试或代码 review 里被问到底层的 Object 数组、数组互转和扩容计算时才发现自己其实只是停留在“会用”的层面。这篇就是我对 ArrayList 的一次完整复盘重点围绕标题里的三块硬核内容底层原理、数组互转、扩容计算顺带把源码里那些“不读不知道读了就回不去”的细节全部摊开讲。内容适合正在啃集合源码的初中级 Java 工程师也适合准备面试、想在代码评审里说话更有底气的人。1. 先把底层看明白ArrayList 内部其实只有一个数组和两个字段很多人对 ArrayList 的理解是“能自动变长的数组”这句话方向对但太粗糙了。真正读到源码底层你会发现它比想象中简单数据存储全在Object[] elementData里逻辑长度靠int size记录结构变更次数靠int modCount统计。搞懂这三个角色后面所有问题都有了支点。1.1 elementData、size、modCount 各自扮演什么角色打开 JDK 源码核心字段少得可怜public class ArrayListE extends AbstractListE implements ListE, RandomAccess, Cloneable, java.io.Serializable { // 实际存数据的数组即使有泛型参数运行期也只是一个 Object[] Object[] elementData; // 当前已经放入的元素个数不是数组容量 int size; // 继承自 AbstractList记录“结构性修改”的次数 protected transient int modCount; }elementData是真正放数据的容器size表示容器里“有效数据”的数量。举个例子一个new ArrayList(100)的对象底层数组长度一开始就是 100但size是 0。list.get(0)能不能调用判断依据是index size就抛越界异常而不是去数数组长度。所以容量是“能装多少”size 是“已经装了多少”两者是两码事。modCount则是给迭代器用的。每次add、remove、clear这类改变列表结构的操作都会让modCount。迭代器在初始化时记录一个期望值每次next或remove之前都检查当前modCount是否还等于期望值不等就抛ConcurrentModificationException。这就是常说“快速失败”机制目的是尽早暴露并发修改问题而不是保证并发安全。get和set为什么是 O(1)因为底层就是数组随机访问。get的代码本质上只做两件事检查下标越界然后直接返回elementData[index]。这也是ArrayList实现RandomAccess接口的原因标记自己适合随机访问让Collections.binarySearch这类工具敢走indexedBinarySearch分支。1.2 new ArrayList() 的时候底层其实什么都没分配这是源码里特别有意思的一个偷懒点。无参构造并不像很多资料写的那样“初始容量为 10”它只是把一个共享空数组赋给了elementDataprivate static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }注意这个空数组是static final的所有用无参构造创建的ArrayList在初始状态下都指向同一个数组对象。好处很明显很多List创建出来根本不会添加元素没必要真的给每个对象都分配一个长度为 10 的数组白白占用内存。第一次调用add时才会走grow逻辑把容量顶到 10。而带初始容量参数的构造器则直接分配真实数组public ArrayList(int initialCapacity) { if (initialCapacity 0) { this.elementData new Object[initialCapacity]; } else if (initialCapacity 0) { this.elementData EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException(Illegal Capacity: initialCapacity); } }这里也有个细节new ArrayList(0)和new ArrayList()用的空数组不是同一个常量前者是EMPTY_ELEMENTDATA后者是DEFAULTCAPACITY_EMPTY_ELEMENTDATA。区别在于后续扩容的起点不同无参构造首次扩容直接跳到 10而指定 0 容量的首次add只会扩到 1然后再扩到 2。如果你在面试中被问到“指定容量 0 和无参会有什么区别”这个点就能体现你是不是真读过源码。还有第三种构造方式接收一个Collection? extends E。它会先把集合通过toArray()转成数组如果数组长度不是 0再通过Arrays.copyOf重新包装成Object[]。这里有一个值得注意的兼容细节如果传入的集合底层已经是Object[]copyOf本质上就是一次System.arraycopy如果不是则会做一次元素复制。1.3 为什么字段是 Object[]而不是 E[]泛型擦除与类型安全这个点被问到的频率极高既然ArrayListString存的是字符串为什么字段不用String[]原因是运行期根本没有String这个类型参数。Java 的泛型是编译期擦除的ArrayListString在字节码层面就是一个ArrayList内部字段只能是最通用的Object[]。如果硬要写E[] elementData反而需要额外做一次不安全的强转来构造数组不仅不能带来类型安全还会让源码更难维护。那用户为什么在get的时候拿到的是String而不是Object因为编译器在调用点自动插入了checkcast也就是强转校验指令。运行期elementData[0]本身还是Object但它实际指向的对象类型是被之前的add保证过的。这就像一个不带标签的收纳盒放入时已经确认过每件物品取出时只需要在门口核对一下身份。2. 扩容那笔账第 11 个元素是怎么触发 1.5 倍增长的数组的长度在 Java 里是固定的所以ArrayList必须在容量不够时做“换新数组 复制旧数据”的操作。这一步在源码里叫grow。很多人只记得“扩容是 1.5 倍”但具体什么时候触发、怎么计算、容量上限在哪值得一笔一笔算清楚。2.1 grow 方法的最小代码模型JDK 8 到如今的主流版本里核心逻辑几乎没变过简化后就是这个样子private void grow(int minCapacity) { // 1. 记录当前数组长度容量 int oldCapacity elementData.length; // 2. 先尝试扩到 oldCapacity oldCapacity / 2即约 1.5 倍 // oldCapacity 1 等价于 oldCapacity / 2但位运算效率更高 int newCapacity oldCapacity (oldCapacity 1); // 3. 如果 1.5 倍都还不够 minCapacity直接取 minCapacity if (newCapacity - minCapacity 0) { newCapacity minCapacity; } // 4. 如果超过数组上限走 hugeCapacity 做最后裁决 if (newCapacity - MAX_ARRAY_SIZE 0) { newCapacity hugeCapacity(minCapacity); } // 5. 分配新数组并复制旧数据 elementData Arrays.copyOf(elementData, newCapacity); }当add(E element)时传入的minCapacity是size 1。也就是说每次只放一个元素时扩容需求的“最小容量”就是当前元素个数加 1。正常情况下oldCapacity (oldCapacity 1)比size 1大不少所以直接走 1.5 倍分支。但第一次add时是无参构造刚创建的列表此时elementData是DEFAULTCAPACITY_EMPTY_ELEMENTDATA长度为 0计算出来newCapacity 0 0 0显然小于minCapacity 1于是 newCapacity 被强行拉高到 1 吗不对因为源码中calculateCapacity提前做了特殊判断private static int calculateCapacity(Object[] elementData, int minCapacity) { if (elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } return minCapacity; }无参构造的首次addminCapacity会被计算成Math.max(10, 1) 10。所以第一次扩容并不是 0 到 1而是 0 到 10这也是“默认容量 10”的真正含义。我们来算一下最经典的场景无参构造的列表连续 add底层容量会按照什么节奏变化。已 add 次数触发扩容时 size旧容量新容量这个区间能承受 add 的次数第 1 次0 → 101010 个元素时正好放满第 11 次101015第 11 到第 15 个元素第 16 次151522第 16 到第 22 个元素第 23 次222233第 23 到第 33 个元素第 34 次333349第 34 到第 49 个元素第 50 次494973第 50 到第 73 个元素容量序列大致是 10、15、22、33、49、73、109、163、244、366……每次大约是前一次的 1.5 倍奇数容量时向下取整。2.2 1.5 倍这个数字是怎么来的位运算与空间时间权衡oldCapacity (oldCapacity 1)中 1就是把二进制右移一位效果等同于整除 2。右移比除法指令更快这是源码作者在微优化层面的一贯追求。比如oldCapacity 15时15 (15 1) 15 7 22正好是 15 的 1.5 倍向下取整。为什么选 1.5 倍而不是直接 2 倍这是一个空间和时间的权衡问题。假设从容量 C 开始2 倍扩容会把容量变成 2C下一轮扩容发生在元素数达到 2C 时所以每个“扩容周期”里数组使用率从 50% 慢慢爬回 100%平均值是 75%等于平均浪费 25% 的空间。1.5 倍扩容的容量从 C 变成 1.5C下一轮扩容发生在元素数达到 1.5C 时使用率从约 66.7% 爬到 100%平均值约 83.3%平均浪费约 16.7% 的空间。代价是什么1.5 倍增长的扩容次数会比 2 倍更多对应的整表复制次数也多。但每次复制都是一步到位的Arrays.copyOf底层是System.arraycopy这个 native 方法连续内存拷贝非常快所以多点几次复制成本有限。而内存空间则是长期占用、实实在在的成本宁可少浪费。这个“增长因子”的取舍就是 ArrayList 源码工程师给出的工程答案。2.3 容量上限与 hugeCapacity 的最后裁决数组不是无限长的。JVM 对数组对象有额外的对象头开销所以ArrayList内部定义了一个保守上限private static final int MAX_ARRAY_SIZE Integer.MAX_VALUE - 8;减 8 是因为某些 JVM 实现里数组对象头部要占用一定字节超了这个边界可能出现难以理解的OutOfMemoryError。hugeCapacity的规则是如果minCapacity比Integer.MAX_VALUE - 8还大就直接返回Integer.MAX_VALUE否则返回MAX_ARRAY_SIZE。也就是说极端情况下ArrayList容量理论上可以逼近Integer.MAX_VALUE但实际上在达到这个数之前内存早就撑不住了。这个细节平时用不上但理解它的意义在于ArrayList不可能无限制扩容它的一切动态能力都是“有限数组”上的动态并不是真正意义上的无限列表。2.4 扩容的三板斧构造器指定、ensureCapacity、批量 addAll手动优化扩容有三种最常用的手段。第一种是创建时直接指定初始容量。比如明确知道要装 1 万条数据就写new ArrayList(10000)避免从 10 开始一路扩到 8191、12288 之类的中间容量。第二种是已经创建了无参列表后续才知道数据量调用list.ensureCapacity(10000)手动触发一次扩容把容量顶上去。ensureCapacity的实现其实就是调grow但它是一个 public 方法专门留给开发者做容量预判。第三种是批量添加时优先使用addAll而不是循环add。这里藏着一个容易忽略的细节addAll计算的minCapacity是size 传入集合的大小也就是说它经常是“一次到位”的扩容。比如当前容量 10你要一次性addAll200 条数据oldCapacity (oldCapacity 1) 15依然小于minCapacity 210于是newCapacity会直接采用 210。而如果是 200 次循环add中间会触发多次扩容。这就是源码注释里那句“minCapacity is usually close to size, so this is a win”的意思单元素 add 时 1.5 倍通常够用批量 add 时则直接按需扩。我在处理大数据量导入时习惯这样做ListRecord records fetchBatchFromDb(); ListRecord buffer new ArrayList(records.size()); buffer.addAll(records);提前声明容量不仅避免了反复copyOf也能减少扩容期间新建大数组造成的 GC 压力。尤其是几十万条数据的场景这个习惯带来的性能差距是肉眼可见的。3. 数组互转的真实姿势五个高频写法与两个必踩的坑ArrayList和数组之间的转换是日常工作里绕不开的操作但这一块“正确写法”和“看起来很对的错误写法”之间只隔一层窗户纸。我见过不少人在这里栽跟头尤其是toArray()的强转和Arrays.asList()的增删操作。3.1 List 转数组为什么 (String[]) list.toArray() 一定会崩这是 Java 里最经典的错误之一。直接看代码ListString list new ArrayList(); list.add(a); list.add(b); // 错误运行期抛 ClassCastException String[] arr (String[]) list.toArray();原因在toArray()的源码里写得很清楚public Object[] toArray() { return Arrays.copyOf(elementData, size); }它创建的是Object[]运行时类型是Object[]不是String[]。数组是协变的但强制向下转型要求运行期类型匹配把Object[]当成String[]用JVM 直接拒绝。正确做法是传入一个有类型的空数组String[] arr list.toArray(new String[0]);这里有个值得展开的细节。toArray(T[] a)的内部逻辑可以概括为如果传入数组长度小于size走Arrays.copyOf(elementData, size, a.getClass())生成一个与a同类型、长度为size的新数组。如果传入数组长度大于等于size直接System.arraycopy把元素填进去如果传入数组比size长还会把a[size]置为 null作为“结束标记”。所以toArray(new String[0])和toArray(new String[list.size()])到底哪个好早期很多书推荐后者理由是“省一次复制”但后来 JDK 官方也在 Javadoc 中明确建议传 0 长度数组。道理很简单传new String[size]会先在外部创建一个完整尺寸的数组如果内部逻辑判断长度不够这个数组就白白分配了而传 0 长度数组只会创建一个空数组代价可以忽略。现代 JVM 里两者性能差异微乎其微但传 0 的写法更简洁、更不容易出错。3.2 数组转 ListArrays.asList 返回的不是 java.util.ArrayList数组转List最普及的写法是Arrays.asList但它的行为经常让人意外String[] arr {a, b}; ListString list Arrays.asList(arr); list.set(0, z); // 可以底层就是原来的数组 list.add(c); // 抛 UnsupportedOperationException原因在于Arrays.asList返回的是 Arrays 内部定义的一个私有静态类java.util.Arrays$ArrayList。它直接用传入的数组作为底层存储并没有真正复制数据所以set能直接改到原数组。但这个内部类没有重写AbstractList的add和remove而AbstractList默认实现就是直接抛UnsupportedOperationException。如果后续还要增删正确做法是在外层再包一层ListString realList new ArrayList(Arrays.asList(arr));这个操作会真正拷贝一次数据之后你就拥有了一个完全独立的、可变的ArrayList。顺带提醒如果不再需要修改列表只做读取和遍历Java 9 之后的List.of(arr)是更好的选择它返回一个不可变列表不允许set也不允许 null 元素语义更明确。3.3 int[] 与 Integer[]基本类型数组的另一个大坑Arrays.asList还有个非常隐蔽的坑遇到基本类型数组时会直接“翻车”int[] numbers {1, 2, 3}; Listint[] list Arrays.asList(numbers); System.out.println(list.size()); // 输出 1不是 3为什么是 1因为泛型T不能是基本类型int[]本身是一个引用类型所以T被推断成了int[]整个数组变成了一个元素。list.get(0)返回的是整个int[]而不是第一个整数。正确做法是手动装箱ListInteger list Arrays.stream(numbers) .boxed() .collect(Collectors.toList());如果手里已经是包装类数组Integer[]那么Arrays.asList(integerArray)就可以正常工作每一个元素都会被当做一个Integer。这两个写法的差异一定要分清。3.4 从 Collection 到数组类型保持的底层支持最后提一个源码层面的细节。new ArrayList(collection)构造器会把传入集合先toArray()然后再用Arrays.copyOf包装成Object[]。这一步看起来多此一举其实是为了保证即使传入集合底层的toArray()返回的是某种特殊类型数组最终ArrayList内部统一使用Object[]存储。这个约定让后面所有elementData的操作都建立在一种稳定类型之上也避免了后续扩容时出现数组类型不一致的麻烦。4. 从扩容到数组复制看 ArrayList 的性能边界读源码读到一定深度之后你会自然开始思考到底什么时候该用ArrayList什么时候该换数据结构这一节的判断标准其实在前面那些源码细节里已经埋下了伏笔。4.1 时间复杂度全景与 arraycopy 的真实成本把常用操作的时间复杂度摆出来看操作时间复杂度说明get(int index)O(1)底层数组随机访问set(int index, E)O(1)覆盖指定位置add(E)均摊 O(1)偶尔触发扩容复制平均下来接近 O(1)add(int index, E)O(n)需要移动 index 之后的元素remove(int index)O(n)需要前移后续元素contains(Object)O(n)线性查找remove(Object)O(n)先查再删通常会更慢中间插入和删除是ArrayList的软肋因为System.arraycopy会把一大段连续内存整体搬移虽然 native 拷贝很快但数据量上去之后仍然是有成本的。add(int index, E)内部执行的是System.arraycopy(elementData, index, elementData, index 1, size - index);一条指令搬移整个尾部而不是像链表那样改两个指针。所以如果你的业务大量集中在头部或中部增删ArrayList并不是首选。Arrays.copyOf在扩容时也会调用System.arraycopy而它本身就是 JVM 里高度优化的 native 方法连续内存拷贝速度远不是 for 循环能比的。这也是为什么“扩容次数多”这件事在数据量不是特别夸张时感受不明显。4.2 什么时候该换掉 ArrayList链表替代的真实性价比很多人一听说“频繁插入删除”第一反应是换LinkedList。但真相是LinkedList在随机访问上是 O(n)每个节点还要额外保存前后指针内存占用明显更大而且节点在堆里分散存放CPU 缓存很不友好。如果业务是“随机访问为主 偶尔在两端增删”ArrayList依然更合适如果业务是“大量在头部插入 遍历为主”可以考虑ArrayDeque而不是LinkedList。换结构的决策应该基于你 80% 的核心操作而不是某个单一的增删场景。我见过不少项目把LinkedList换回ArrayList之后遍历和随机访问的性能立刻提升了一个档次。4.3 ArrayList 自己的批处理优化batchRemove 的小门道源码里还有一个不容易注意到但很实用的优化removeAll和retainAll不是简单循环删除而是走batchRemove方法内部用一个布尔数组标记每个位置是否需要保留然后双指针原地压缩。这样一轮扫描就能完成批量删除避免反复System.arraycopy。所以在需要清掉一批元素时不要写成循环里逐个remove直接list.removeAll(toRemoveSet);如果集合很大toRemoveSet用HashSet而不是List可以把contains的代价从 O(n) 降到 O(1)整个批删操作从 O(n²) 降到 O(n)。5. 实战环节真正会让人翻车的 ArrayList 细节源码读完回到日常开发里还有几个高频翻车点值得单独拿出来讲。它们不是原理问题而是“知道就没事不知道就调半天”的实战陷阱。5.1 subList 是视图不是副本改它会改到原列表list.subList(from, to)返回的不是新列表而是原列表内部的视图持有原列表的elementData引用。因此在子列表上做add、remove、set影响会直接反映到原列表上。反过来如果创建subList之后又对原列表做了增删操作原列表的modCount变了子列表再次遍历时就会抛ConcurrentModificationException。这个特性的正确用法是做区间删除list.subList(from, to).clear();这比手动循环删除一段区间高效得多也是 JDK 文档里明确推荐的姿势。5.2 循环里删除元素别用正序 for-i删除特定元素时正序 for 循环是最容易写错的操作之一。因为每次remove都会让后续元素整体前移下标就错位了。推荐三类写法。倒序 for 循环简单直接for (int i list.size() - 1; i 0; i--) { if (shouldRemove(list.get(i))) { list.remove(i); } }或者用迭代器IteratorString it list.iterator(); while (it.hasNext()) { if (shouldRemove(it.next())) { it.remove(); } }Java 8 之后更推荐直接用removeIflist.removeIf(item - shouldRemove(item));removeIf内部封装了迭代器和modCount的一致性处理代码最干净性能也未必差因为底层同样走batchRemove的优化路径。5.3 面试追问四连高频考点的标准口径关于ArrayList面试里经常被连环追问我把标准口径整理一下为什么elementData是Object[]而不是E[]因为泛型在运行期被擦除无法安全创建泛型数组Object[]是最通用的存储容器。为什么无参构造的第一次 add 会直接扩到 10因为calculateCapacity对共享空数组做了兜底把minCapacity提升到DEFAULT_CAPACITY。扩容为什么是 1.5 倍oldCapacity (oldCapacity 1)这是空间浪费和扩容次数的折中方案比 2 倍更省空间比固定增量更平滑。为什么toArray()不能直接强转成具体类型数组因为运行期数组类型是Object[]类型不符直接抛ClassCastException需要传new String[0]或new String[size]来获得正确类型。这四个问题能答清楚说明对源码的阅读已经过了“看懂”阶段到了“能解释设计取舍”的水平。还有个容易忽略的细节值得单独说new ArrayList(list)的复制只在构造时发生一次两个列表之后互不影响。而subList不是复制是视图。如果业务里需要一份独立的数据快照应当用new ArrayList(subList)包一层而不是直接持有subList继续操作。最后分享一点个人经验读过源码再去写业务代码最大的变化是你会主动关心容量。我现在的习惯是任何能预估数据量的场景一律new ArrayList(expectedSize)任何批量填充一律走addAll任何需要区间删除一律用subList(from, to).clear()。这些习惯不需要额外依赖只是把源码里那些细节翻译成写代码时的自然选择。ArrayList并不复杂它全部的秘密就藏在一个Object[]、一次 1.5 倍扩容、一次System.arraycopy里。把这几个点彻底吃透日常使用、面试应答、性能排查基本都能覆盖了。