1. 灵魂拷问为什么搞懂 ArrayList 这么重要让我先说个现象我前面后面加起来面了差不多小一百人Java 岗位的候选者只要问到集合十个人里有九个都会说“我熟悉 ArrayList”。但是呢真正把 ArrayList 讲透的说实话五个手指头数得过来。原因很简单大部分人对 ArrayList 的了解停留在“能自动扩容的数组”这个层面再往深一点比如扩容的具体逻辑、add 方法的底层流程、迭代时修改为什么会抛异常、为什么查询快但是插入慢这些细节就支支吾吾了。但恰恰是这些细节才是面试官判断你是“背过答案”还是“真的写过代码”的分水岭。而且不光是面试日常开发里 ArrayList 的出镜率实在是太高了。接口返回列表、数据库查询结果映射、批量参数组装、缓存临时数据……几乎所有业务代码里都有它的身影。你如果只是“会用”遇到性能问题、并发问题、数据错乱问题的时候就只能干瞪眼。这篇文章我打算换个讲法不按教科书顺序来而是按我平时排查问题、看源码、带新人的实际路径来拆。从底层结构讲到扩容机制再到增删查改的源码级分析、线程安全替代方案最后整理一份高频面试题和踩坑清单。不管你是准备面试的求职者还是刚接触 Java 的初学者或者写了两三年代码但从来没翻过 ArrayList 源码的老开发这篇文章都能让你对 ArrayList 的认识上一个台阶。2. ArrayList 的设计哲学为什么选数组作为底层结构2.1 数组的“快”和“慢”分别在哪里ArrayList 从名字就能看出来它的底层就是一个 Object 数组。这个数组会在第一次 add 元素的时候被初始化之后所有的元素都存放在这个数组里。为什么不直接用现成的数组因为 Java 原生数组长度固定一旦创建就无法改变而业务场景里我们很难提前知道数据量。ArrayList 的核心价值就是在数组之上封装了一层“自动扩容”的能力让你既能享受数组的随机访问性能又不需要操心容量不够的问题。理解 ArrayList 的行为本质上就是理解数组的优劣。数组在内存里是一段连续空间每个元素的大小相同所以可以通过“起始地址 索引 * 单个元素大小”这个公式在 O(1) 时间内直接算出第 i 个元素的内存地址。这就是 ArrayList 的 get 方法为什么那么快的原因——它不需要从头遍历就像你去图书馆找一本书只要知道它在第几排第几列直接走过去就能拿到。数组的短板也很明显插入和删除需要移动元素。想象一下一排人站得整整齐齐你要在第 3 个位置插一个人那从第 3 个到最后一个都要往后退一位要删掉第 2 个人那后面的人都要往前挪。数据量小的时候无所谓但如果 list 里有几万个元素你在头部插入一个元素涉及到几万个元素的整体搬移性能可想而知。这正是 ArrayList “尾部操作飞快、头部操作崩溃”的根本原因。2.2 自动扩容其实是一个“笨办法”ArrayList 的扩容策略并不高深核心就一句话数组不够用的时候新建一个更大的数组把旧元素全部 copy 过去。默认初始容量是 10当数组填满后再 add就会触发扩容新容量是旧容量的 1.5 倍严格说是 oldCapacity (oldCapacity 1)也就是原来的容量加上原来容量的一半。这个 1.5 倍不是随便定的。扩容太频繁比如每次只多扩一个那 add 操作就会反复触发数组复制性能急剧下降扩容太激进比如直接翻倍甚至翻三倍虽然减少了扩容次数但会一次性占用更多内存特别是存储大对象的时候浪费明显。1.5 倍是在“扩容次数”和“空间浪费”之间取了一个平衡点。用位运算 oldCapacity 1 代替 oldCapacity / 2是因为位运算在底层执行更快这也是 JDK 源码里常见的性能优化手法。这里有一个关键细节值得单独记一下扩容时调用的 Arrays.copyOf 方法底层是 System.arraycopy这是一个 native 方法直接操作内存复制效率非常高。但就算效率再高扩容仍然是一个 O(n) 的操作。所以如果你能提前知道大概的数据量比如从数据库查出了 10 万条数据要封装进列表就一定要用 new ArrayList(100000) 这种指定容量的构造方式把扩容次数从多次直接降为 0 次效果立竿见影。3. 源码级拆解从 add 到 remove每一步在干什么3.1 add 方法的完整执行链路我建议每个 Java 开发者至少要亲手把 ArrayList 的几个核心方法的源码读一遍。不要求背下来但要知道每一步在干什么。以 add(E e) 为例完整流程是这样的public boolean add(E e) { modCount; add(e, elementData, size); return true; } private void add(E e, Object[] elementData, int s) { if (s elementData.length) elementData grow(); elementData[s] e; size s 1; }第一步是 modCount这个变量是集合的修改计数器后面讲迭代器快速失败机制的时候还要提到它先记着。第二步是检查当前数组是否已满如果已满就调用 grow() 进行扩容。第三步是直接把新元素放到数组中 size 的位置然后 size 加 1。注意第三步非常朴素就是在数组的尾部写入一个元素没有任何额外的判断和计算所以尾部追加的效率极高均摊时间复杂度是 O(1)。这也是 ArrayList 最适合“顺序追加、随机访问”的场景的原因。那 add(int index, E element) 这种指定位置插入就完全不一样了public void add(int index, E element) { rangeCheckForAdd(index); modCount; final int s; Object[] elementData; if ((s size) (elementData this.elementData).length) elementData grow(); System.arraycopy(elementData, index, elementData, index 1, s - index); elementData[index] element; size s 1; }它先检查索引是否越界然后判断是否需要扩容最关键的一步是 System.arraycopy(elementData, index, elementData, index 1, s - index)这行代码的含义是把从 index 开始到数组末尾的元素整体向后移动一位给新元素腾出位置。如果 index 是 0意味着所有元素都要往后挪这就是头部插入代价最高的原因。3.2 remove 方法的“伪删除”真相remove 方法的内部逻辑同样值得深挖。按索引删除的源码核心是public E remove(int index) { Objects.checkIndex(index, size); final Object[] es elementData; E oldValue (E) es[index]; fastRemove(es, index); return oldValue; } private void fastRemove(Object[] es, int index) { modCount; final int newSize; if ((newSize size - 1) index) System.arraycopy(es, index 1, es, index, newSize - index); es[size newSize] null; }看到 es[size newSize] null 这一行了吗删除之后数组最后一个位置被显式置为 null。这可不是多此一举这是一个非常重要的细节。原因在于ArrayList 底层持有的是对象引用数组如果删除元素后不把最后一个位置置空那个位置仍然引用着原先的对象。如果这个 ArrayList 被长期持有就会无意中阻止垃圾回收器回收那些本应被回收的对象造成内存泄漏。虽然这种泄漏不会立刻导致 OOM但在长期运行的服务器上积少成多内存占用会持续增长。我接手过一个遗留系统中偶尔出现的内存缓慢增长问题排查到最后就是类似的引用未清理问题。虽然原代码用的不是 ArrayList但原理一样很强的对象引用被无意保留GC 无法回收相关对象。定位这种问题相当花时间通常需要通过分析堆转储快照逐一排查没有被释放的引用链。所以每次看到 ArrayList 源码里那行置空操作我都会特意强调这不是无关紧要的小动作而是释放内存的关键一步。另一个要点是ArrayList 的 remove 只是把引用从数组里去掉并没有真正把对象销毁。对象真正的回收动作是 JVM 的 GC 决定的只要没有其他对象持有引用下次 GC 时它就会被标记并回收。这一点很多初学者容易混淆以为 remove 之后对象就立刻被销毁了完全不是这样。3.3 get 方法为什么是 O(1)get(int index) 的源码极其简单public E get(int index) { Objects.checkIndex(index, size); return elementData(index); }它就是先做一次性越界检查然后直接通过数组下标返回元素。没有任何遍历没有任何计算时间复杂度恒定为 O(1)。这就是 ArrayList 在随机访问场景下的碾压性优势。不过越界检查这里有个小细节值得注意。JDK 不同版本对越界抛出的异常类型不一样。早期版本抛的是 IndexOutOfBoundsException而较新的 JDK比如 9 以后普遍使用 Objects.checkIndex 方法默认抛出 IndexOutOfBoundsException 的实例但内部实现更规范。不管怎样你要记住一个原则访问 ArrayList 之前如果拿不准索引是否合法要么用 contains 或 indexOf 先判断要么用增强 for 循环避免手写下标。自己手写 fori 循环去 get 的时候一定要确认循环边界没有问题。4. 迭代器、快速失败机制和并发安全的正确解法4.1 增强 for 循环背后的迭代器很多人写代码喜欢用增强 for 循环遍历 ArrayList但不知道它底层用的是迭代器。看一段常见代码ListString list new ArrayList(); list.add(a); list.add(b); for (String s : list) { if (a.equals(s)) { list.remove(s); } }这段代码运行时必然抛出 ConcurrentModificationException为什么因为增强 for 循环本质上使用了迭代器而迭代器内部维护了一个 expectedModCount 字段它初始化的时候被赋值为集合当前的 modCount。遍历过程中每次调用 next() 都会调用 checkForComodification()检查 expectedModCount 是否仍然等于 modCount。当你调用 list.remove(s) 时modCount 会自增于是和迭代器持有的 expectedModCount 不再相等迭代器立刻抛出 ConcurrentModificationException。这个机制有个专门的术语叫 fail-fast快速失败。它的设计意图是当检测到集合在迭代过程中被结构性修改时尽早抛出异常而不是等到后面出现莫名其妙的数据错乱。这是一种“宁可错杀不可放过”的策略。那么正确的遍历删除姿势是什么至少有两种。第一种是用 Iterator 自身的 remove 方法IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (a.equals(s)) { it.remove(); } }因为 Iterator.remove() 内部会把 expectedModCount 同步更新为最新的 modCount所以不会触发快速失败。第二种更推荐的是使用 JDK 8 引入的 removeIf 方法一行搞定语义也更清晰是使用 lambda 表达式时的最佳方式list.removeIf(s - a.equals(s));4.2 Vector 为什么没人用CopyOnWriteArrayList 为什么有适用场景既然说到并发问题就绕不开线程安全这个经典话题。ArrayList 本身是完全不线程安全的这一点背诵即可两个线程同时往一个 ArrayList 里 add轻则元素丢失重则数组越界异常因为扩容和写入不是一个原子操作线程 A 判断需要扩容后线程 B 也判断需要扩容两个线程同时触发扩容就可能出现元素覆盖或者索引越界。那为什么 Vector 这个号称线程安全的类没人用因为 Vector 的所有方法都用 synchronized 修饰是粗粒度的全方法级加锁。并发量低的时候体现不出问题并发量一上来锁竞争导致的性能下降非常明显。更重要的是即使你把 Vector 的每个单独方法都加锁了两个线程分别调用 size() 和 remove() 这种复合操作还是会有竞态条件也就是说 Vector 的“线程安全”在实际业务场景里根本不够用处处还是要自己加锁那还不如直接用 ArrayList 加锁更灵活。真正在高并发下适合读多写少场景的替代品是 CopyOnWriteArrayList。它的核心设计是写时复制任何写操作add、set、remove都会先复制一份完整数组在副本上修改修改完成后用 volatile 变量把原数组引用替换为新数组。这样读操作永远不需要加锁因为读到的要么是修改前的完整数组要么是修改后的完整数组永远不会看到中间状态。它的代价也很明显每次写操作都 O(n) 复制如果频繁写入GC 压力会很大。所以如果你遇到的是读多写少、高并发的场景比如缓存黑白名单、订阅关系配置这种CopyOnWriteArrayList 是非常好的选择。如果写多读少那老老实实自己加锁或者用别的并发容器。我个人的经验是小项目下没必要纠结这个ArrayList 加 Collections.synchronizedList 包装一下也完全够用配合 synchronized 块去处理复合操作就行。只有当你明确遇到并发写入导致数据丢失或者并发修改异常时再升级到 CopyOnWriteArrayList 或者使用并发集合类避免过早优化。5. ArrayList 的“兄弟”们对比与选型的心法5.1 LinkedList 的优势为什么有人在夸大面试里最常被拿来和 ArrayList 对比的就是 LinkedList。教科书上通常会说LinkedList 插入删除快ArrayList 查询快。这句话原则上没错但如果不加限定条件很容易误导人。LinkedList 是一个双向链表每个节点除了存储数据外还要维护 prev 和 next 两个引用。在头部插入元素确实是 O(1)但在中间或者指定位置插入仍然是 O(n) 的复杂度因为你需要先从头或者从尾遍历到目标位置。所以“LinkedList 插入快”只有在头部插入、尾部插入这两个特定场景下才成立。如果你要频繁在中间插入两者都不快差别是 LinkedList 不需要移动元素但需要遍历找位置ArrayList 不需要遍历找位置但需要移动元素具体哪个更快要看数据量和插入位置。更麻烦的是 LinkedList 的 CPU 缓存不友好。数组元素在内存中是连续的遍历时 CPU 缓存命中率极高而链表的节点分散在堆内存各处每次访问下一个节点都可能发生缓存未命中。所以即使理论复杂度相同链表的实际执行效率通常也明显低于 ArrayList。我在本地做过简单的测试往两个列表的相同位置循环插入大量元素最终结果是 LinkedList 在部分场景下反而比 ArrayList 慢属实是反直觉。还有一个东西很多人忽略LinkedList 每个节点都要额外存储两个引用内存占用比 ArrayList 大得多。如果你存储的是几百万条记录这个差距会非常明显。所以我的选型原则很简单——除非你确定自己需要频繁的头尾插入删除且需要保持顺序否则一律优先用 ArrayList。JDK 官方和大量开源项目的代码也印证了这一点ArrayList 的使用率远远高于 LinkedList。5.2 为什么 ArrayList 遍历比 fori 用 removeIf 更优越在现代 Java 开发里遍历并过滤集合的场景非常多。我比较推荐直接用 Stream 的 filter 和 collect 操作比如ListString result list.stream() .filter(s - !a.equals(s)) .collect(Collectors.toList());这种写法有两个好处。第一是语义非常清晰读代码的人一看就知道你在做什么以后维护成本低。第二是它完全不改变原列表而是生成一个新列表从根本上避免了遍历中修改集合的问题。如果你的逻辑是“保留符合条件的元素丢弃不符合的”那就别再用 remove 了直接 filter 生成新集合又安全又简洁。这个思维转变对从 C 转过来的程序员来说可能需要一点时间适应但一旦习惯了就会觉得真香。5.3 底层数据结构相同但用途不同的HashSet 和 HashMap顺带说一句有人会把 ArrayList 和 HashSet、HashMap 放在一起比较这三者其实完全没有可比性。ArrayList 是有序、可重复、带索引的序列HashSet 是无序、不可重复的集合底层基于 HashMapHashMap 是键值对结构。面试的时候也经常被问到“为什么 HashMap 的 key 不能重复ArrayList 可以”本质就是数据结构的设计目标不同。ArrayList 对标的是数组关注的是顺序和访问效率HashSet 对标的是数学上的集合关注的是元素唯一性。搞清楚这些“兄弟”之间的差异能帮你更好地理解 Java 集合框架的整体设计思路。6. 高频面试题和必须避开的坑6.1 常见问题速问速答我把过去几年在面试中高频出现的 ArrayList 相关问题整理成了一张表建议你对照着自查一遍看能不能不经思考就答出来问题核心答案ArrayList 默认容量是多少10但实际是在第一次 add 时才初始化数组扩容后容量变成多少旧容量的 1.5 倍get(index) 的时间复杂度是多少O(1)直接数组下标访问和 LinkedList 的区别是什么底层结构不同数组 vs 双向链表随机访问、指定位置插入性能不同为什么增强 for 循环删除元素会抛异常迭代器的快速失败机制expectedModCount ! modCount如何安全地在遍历时删除Iterator.remove() 或 removeIf()最大容量是多少Integer.MAX_VALUE - 8因为数组对象头有固定开销null 可以存入吗可以ArrayList 允许存 null是线程安全的吗不是需要并发场景用 CopyOnWriteArrayList 或加锁6.2 实战里踩过的坑最后必须分享几个实战中很容易踩的坑每一个我都在真实项目里见过。第一个是 subList 的“隐形引用”。ArrayList.subList(fromIndex, toIndex) 返回的不是一个独立的 List 副本而是原列表的一个视图。它持有原列表的引用任何对视图的结构性修改比如 add、remove都会直接反映到原列表上同时原列表的修改也会让视图失效再操作视图就会抛 ConcurrentModificationException。这个坑我见过不止一次某人把 subList 当独立列表用往里加了元素结果原列表凭空多出数据排查半天找不到原因。如果你确实需要一个独立的子列表请显式复制一份比如 new ArrayList(list.subList(0, 5))。否则明确知道它是视图才用别掉进引用陷阱。第二个是批量插入时没有指定初始容量。从数据库一次查出几十万条数据然后循环调用 add期间触发多次扩容和全量复制。虽然 System.arraycopy 效率很高但数据量大的时候扩容本身的复制成本依然可观。解决方式简单粗暴查出来就立刻 new ArrayList(resultSet.size())哪怕估算容量大了一点也没关系总比多次扩容好。第三个是 removeAll 和 retainAll 的滥用。这两个方法的实现是基于两层循环查找时间复杂度是 O(n * m)。如果两个集合都是几十万条数据这个操作会让程序卡到“生无可恋”。正确做法是用空间换时间先把目标集合构造成 HashSet然后用 removeAll(new HashSet(coll))或者干脆用 Stream filter 配合 Set.contains 来做时间复杂度降为 O(n)。第四个是不要用 来判断对象相等。ArrayList 的 contains、indexOf、remove(Object) 都依赖 equals 方法。如果你往 ArrayList 里放自定义对象又不重写 equals() 和 hashCode()那 contains 永远返回 false因为 Object 的默认 equals 用的是引用比较。这个问题在存对象列表、做去重判断时特别隐蔽。如果需要按对象内容判断相同一定得重写 equals 和 hashCode这是所有集合框架使用的共同前提。7. 手动实现一个简易 ArrayList我经常建议来问我该怎么学 Java 的新朋友找机会亲手实现一个简易 ArrayList这是快速建立底层理解的最佳路径。实现的时候你会被迫思考几个平时根本不会想的问题什么时候扩容扩容多少删除元素后要不要置空迭代器怎么实现越界检查在哪一层做下面是一个核心骨架示例覆盖了构造、扩容、add、get、remove、迭代器几个关键部分public class SimpleArrayListE { private static final int DEFAULT_CAPACITY 10; private Object[] elementData; private int size; public SimpleArrayList() { this.elementData new Object[DEFAULT_CAPACITY]; } public SimpleArrayList(int initialCapacity) { if (initialCapacity 0) { throw new IllegalArgumentException(Illegal Capacity: initialCapacity); } this.elementData new Object[initialCapacity]; } public boolean add(E e) { ensureCapacity(size 1); elementData[size] e; return true; } SuppressWarnings(unchecked) public E get(int index) { rangeCheck(index); return (E) elementData[index]; } SuppressWarnings(unchecked) 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; return oldValue; } public int size() { return size; } private void ensureCapacity(int minCapacity) { if (minCapacity elementData.length) { int newCapacity elementData.length (elementData.length 1); if (newCapacity minCapacity) { newCapacity minCapacity; } elementData Arrays.copyOf(elementData, newCapacity); } } private void rangeCheck(int index) { if (index size || index 0) { throw new IndexOutOfBoundsException(Index: index , Size: size); } } }这里有个容易遗漏的细节值得说明一下ensureCapacity 里的 newCapacity minCapacity 判断是防止一次性扩容时因为数据量突然暴增导致新容量仍然不够的情况。源 JDK 的 grow 方法也有类似的保护逻辑——你调用 addAll 一次塞入几十万个元素时如果只按 1.5 倍扩容可能连两倍都覆盖不了所以要用 minCapacity 兜底。我写这段代码的时候特意保留了这个判断因为这就是 ArrayList 源码里扩容逻辑的关键。还有一点remove 方法最后一行 elementData[--size] null双层含义第一是让 size 真正减小第二是释放最后一个位置的引用避免内存泄漏。这也是前面讲的“伪删除”在动手实现时的直接体现。手写一遍之后再去读真正的 JDK 源码你会发现自己能看懂很多以前觉得晦涩的代码比如 modCount 的作用、fastRemove 的优化、rangeCheck 的位置选择等等。强烈推荐每位刚接触 Java 的人都做一次这个练习效果远好于单纯背面试题。关于 ArrayList 要说的其实就这么多。从底层数据结构到扩容策略从增删改查到迭代器机制从单线程应用到并发替代方案每一个细节都贯穿着“空间换时间、时间换空间”这样的权衡思想。我个人在实际项目里反反复复用下来的体会是想不踩坑核心就是两件事——知道它在底层做了什么知道你当前的数据规模有多大。这两点搞清楚了ArrayList 会是你开发工具箱里最趁手的一件工具。