1. 为什么我要花一个周末去读ArrayList源码
说出来你可能不信,我工作第三年的时候,在一次技术评审会上被一个刚入职的应届生问住了。当时我们讨论一个订单列表的去重逻辑,我随口说了句“用ArrayList存就行了,反正数据量不大”,结果他追问了一句:“那ArrayList在扩容的时候,旧数组是怎么处理的?会不会有一瞬间内存里同时存在两份数据?”我当时脑子里一片空白,只能含糊地说“应该是GC会处理吧”。
那次之后我就下定决心,把ArrayList的源码从头到尾读一遍。读完之后最大的感受是:这东西就像你每天开的车,你会踩油门、踩刹车,但你未必知道发动机里活塞是怎么运动的。而恰恰是那些你不知道的细节,在关键时刻决定了程序的性能和稳定性。
这篇文章就是我当时阅读源码的完整笔记和心得整理。我会从核心数据结构、扩容机制、增删改查的底层实现、迭代器与并发问题、性能优化实践这几个维度,把ArrayList彻底拆开讲清楚。适合有一定Java基础、想从“会用”进阶到“懂原理”的开发者。不管你是准备面试,还是想在实际项目里写出更靠谱的代码,相信都能有所收获。
2. ArrayList的整体设计与核心字段拆解
2.1 底层数据结构:一个会自己长大的数组
ArrayList的底层就是一个Object类型的数组。对,就这么简单。它所有的操作,本质上都是在对这个数组做文章。
你可能会问:数组不是定长的吗?我创建一个长度为10的数组,就永远只能存10个元素。那ArrayList怎么做到想存多少就存多少的?
答案就是扩容。当数组装满了,ArrayList会创建一个更大的新数组,把旧数组里的元素全部复制过去,然后用新数组替换旧数组。这个过程对使用者是透明的,你只管往里面add,它自己在背后默默搬家。
这个设计思路的核心优势在于:随机访问极快。因为数组在内存里是连续存放的,只要知道第一个元素的内存地址,就能通过“首地址 + 索引 × 元素大小”直接算出任意位置元素的地址。这就是为什么list.get(5000)和list.get(0)的耗时几乎一样,时间复杂度是O(1)。
但代价也很明显:中间插入和删除慢。因为数组要求元素连续存放,你在中间删掉一个元素,后面所有元素都得往前挪一格。这就像一排人坐在长凳上,中间有个人走了,后面所有人都得往前面挪一个位置,才能保持座位连续。
2.2 核心字段:size和elementData的分工
打开ArrayList的源码,你会看到两个最核心的字段:
transient Object[] elementData; private int size;elementData就是那个底层数组,所有的元素都存在这里面。size记录的是实际存储的元素个数,注意不是数组的长度。
这里有个很容易混淆的点:elementData.length是数组的容量,size是实际元素个数。比如你new ArrayList<>()然后add了3个元素,此时size是3,但elementData.length可能是10(默认容量)或者更大。
为什么要区分这两个概念?因为数组容量和实际元素数量是两回事。容量是“能装多少”,size是“已经装了多少”。就像你有一个能装10升水的桶,但里面只装了3升水,桶的容量是10升,水的体积是3升。
还有一个字段modCount,这个字段继承自AbstractList,用来记录结构性修改的次数。每次add、remove都会让modCount加一。它的作用是配合迭代器做快速失败检测——如果你在迭代过程中修改了列表结构,迭代器会立刻抛出ConcurrentModificationException,而不是等到出现莫名其妙的结果才报错。
2.3 三个构造方法背后的不同策略
ArrayList有三个构造方法,每个背后的策略都不一样:
// 构造方法一:指定初始容量 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); } } // 构造方法二:无参构造 public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } // 构造方法三:从其他集合创建 public ArrayList(Collection<? extends E> c) { Object[] a = c.toArray(); if ((size = a.length) != 0) { if (c.getClass() == ArrayList.class) { elementData = a; } else { elementData = Arrays.copyOf(a, size, Object[].class); } } else { elementData = EMPTY_ELEMENTDATA; } }注意无参构造方法里用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这是一个共享的空数组常量。也就是说,你new十个ArrayList,它们的elementData都指向同一个空数组,不占用额外内存。只有当你第一次add元素的时候,才会真正分配容量为10的数组。
这个设计非常巧妙。很多场景下我们创建了ArrayList但可能一个元素都不放,如果一上来就分配10个元素的数组,那就是浪费。延迟分配的策略让空列表几乎不占内存。
而EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA虽然都是空数组,但用途不同:前者用于new ArrayList(0)的情况,后者用于new ArrayList()的情况。区别在于扩容时,前者会按“需要多少扩多少”的策略,后者会直接扩到默认容量10。
实操心得:如果你能预估数据量大小,强烈建议使用
new ArrayList<>(expectedSize)指定初始容量。这能避免多次扩容带来的数组复制开销。我实测过一个场景:往ArrayList里放100万条数据,不指定初始容量耗时约85ms,指定初始容量后降到约45ms,差距接近一倍。
3. 扩容机制:ArrayList最核心的底层逻辑
3.1 从add方法看扩容的触发时机
add方法是触发扩容的最主要入口。我们来看源码:
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; }逻辑很清晰:先判断当前元素个数是否已经等于数组长度,如果是,说明数组装满了,调用grow()扩容;如果没满,直接往elementData[size]的位置放元素,然后size加一。
这里有个细节值得注意:modCount++是在add方法开头就执行的,不管后面是否真的添加成功。这意味着即使添加失败(比如抛异常),modCount也已经变了。这是为了确保迭代器能检测到任何结构性修改的意图。
3.2 grow方法:1.5倍扩容的计算过程
grow()方法是扩容的核心:
private Object[] grow() { return grow(size + 1); } private Object[] grow(int minCapacity) { int oldCapacity = elementData.length; if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { int newCapacity = ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1); return elementData = Arrays.copyOf(elementData, newCapacity); } else { return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }关键在ArraysSupport.newLength这个方法,它的逻辑是:
public static int newLength(int oldLength, int minGrowth, int prefGrowth) { int prefLength = oldLength + Math.max(minGrowth, prefGrowth); if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) { return prefLength; } else { return hugeLength(oldLength, minGrowth); } }翻译成人话就是:新容量 = 旧容量 + max(需要的最小增量, 旧容量的一半)。
假设当前容量是10,你要add第11个元素,此时minGrowth = 1,prefGrowth = 10 >> 1 = 5,所以新容量 = 10 + max(1, 5) = 15。这就是所谓的1.5倍扩容。
如果当前容量是15,要add第16个元素,新容量 = 15 + max(1, 7) = 22。每次扩容大约是原来的1.5倍。
为什么是1.5倍而不是2倍?这是个很有意思的设计权衡。如果扩容倍数太大(比如2倍),虽然扩容次数少了,但浪费的内存可能更多。如果扩容倍数太小(比如1.1倍),虽然内存利用率高了,但扩容太频繁,每次都要复制数组,性能下降。1.5倍是Oracle工程师经过大量测试后选定的折中值,在内存利用率和扩容频率之间取得了较好的平衡。
3.3 扩容时的数组复制:System.arraycopy有多快
扩容时调用的Arrays.copyOf底层是System.arraycopy,这是一个native方法,由JVM直接实现。它的速度非常快,因为它是整块内存的复制,而不是逐个元素的遍历赋值。
我做过一个简单的测试:复制一个100万元素的int数组,System.arraycopy大约需要1-2毫秒,而用for循环逐个赋值大约需要5-8毫秒。差距在数据量大的时候会更明显。
但即便如此,频繁扩容仍然是昂贵的。假设你要往ArrayList里添加100万个元素,从容量10开始,大约需要扩容多少次?
计算一下:10 → 15 → 22 → 33 → 49 → 73 → 109 → ... 每次乘以1.5。大约需要扩容约30次。虽然每次扩容的数组复制很快,但30次累积起来,加上旧数组等待GC回收的时间,开销不容忽视。
注意事项:
Arrays.copyOf在复制时会创建一个新数组,这意味着在扩容的瞬间,内存中同时存在旧数组和新数组两份数据。如果列表非常大(比如几百万元素),这个瞬间的内存峰值可能是平时的两倍。在内存紧张的环境下,这可能触发OOM。所以对于已知的大数据集,预先指定容量不仅是为了性能,也是为了内存安全。
3.4 手动扩容:ensureCapacity的正确用法
ArrayList提供了一个公开方法ensureCapacity(int minCapacity),允许你手动触发扩容:
public void ensureCapacity(int minCapacity) { if (minCapacity > elementData.length && !(elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA && minCapacity <= DEFAULT_CAPACITY)) { modCount++; grow(minCapacity); } }这个方法的使用场景是:你已经知道接下来要往列表里放大量数据,但还没开始放。这时候可以先调用ensureCapacity一次性把容量扩到位,避免后续add过程中反复扩容。
比如你要从数据库查出一万条记录放到ArrayList里,可以这样写:
List<Order> orders = new ArrayList<>(); orders.ensureCapacity(10000); // 然后循环add一万条数据这样只需要一次扩容(或者零次,如果初始容量就够),而不是反复扩容十几次。
4. 增删改查的底层实现细节
4.1 get和set:O(1)随机访问的真相
get方法的源码简单到令人发指:
public E get(int index) { Objects.checkIndex(index, size); return elementData(index); } E elementData(int index) { return (E) elementData[index]; }就两步:检查索引是否越界,然后直接返回数组对应位置的元素。没有循环,没有查找,就是一次内存访问。这就是O(1)随机访问的底气所在。
set方法同样简单:
public E set(int index, E element) { Objects.checkIndex(index, size); E oldValue = elementData(index); elementData[index] = element; return oldValue; }保存旧值,写入新值,返回旧值。干净利落。
这里有个细节:Objects.checkIndex是Java 9引入的,在之前的版本里是手动判断if (index >= size) throw new IndexOutOfBoundsException(...)。用Objects.checkIndex的好处是JVM可能对它做内联优化,而且代码更简洁。
4.2 add中间插入:数组挪移的代价
在指定位置插入元素的方法:
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那一行:把从index开始的所有元素,整体往后挪一个位置。比如数组是[A, B, C, D, E],你要在索引2的位置插入X,那么C、D、E都要往后挪,变成[A, B, _, C, D, E],然后把X放到索引2的位置,得到[A, B, X, C, D, E]。
这个挪移操作的时间复杂度是O(n),n是插入位置之后元素的个数。所以在列表头部插入元素是最慢的,因为要挪移所有元素;在列表尾部插入是最快的,因为不需要挪移(除非触发扩容)。
4.3 remove操作:两种重载的差异
remove有两个重载:remove(int index)按索引删除,remove(Object o)按对象删除。
按索引删除的源码:
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 i) { modCount++; final int newSize; if ((newSize = size - 1) > i) System.arraycopy(es, i + 1, es, i, newSize - i); es[size = newSize] = null; }逻辑是:把index之后的所有元素往前挪一格,然后把最后一个位置置为null(帮助GC回收),size减一。
按对象删除的源码:
public boolean remove(Object o) { final Object[] es = elementData; final int size = this.size; int i = 0; found: { if (o == null) { for (; i < size; i++) if (es[i] == null) break found; } else { for (; i < size; i++) if (o.equals(es[i])) break found; } return false; } fastRemove(es, i); return true; }它会遍历数组,找到第一个匹配的元素,然后调用fastRemove删除。注意这里用的是o.equals(es[i]),所以如果你的元素类型没有正确重写equals方法,可能会删不掉或者删错。
踩坑记录:我曾经遇到过一个bug,往ArrayList里放了两个内容相同但不同实例的对象,调用remove删除时只删掉了第一个。后来才发现是因为equals方法没有重写,默认比较的是引用地址。所以如果你要往ArrayList里放自定义对象,并且需要按内容删除,一定要重写equals方法(和hashCode方法,虽然后者在ArrayList里不直接用,但养成习惯是好的)。
4.4 contains和indexOf:线性查找的典型
public int indexOf(Object o) { return indexOfRange(o, 0, size); } int indexOfRange(Object o, int start, int end) { Object[] es = elementData; if (o == null) { for (int i = start; i < end; i++) { if (es[i] == null) { return i; } } } else { for (int i = start; i < end; i++) { if (o.equals(es[i])) { return i; } } } return -1; }就是从头到尾遍历,找到就返回索引,找不到返回-1。时间复杂度O(n)。contains方法底层就是调用indexOf,判断返回值是否大于等于0。
这里有个小优化:如果查找的是null,用==比较;如果不是null,用equals比较。这样避免了在循环里反复判断o是否为null。
5. 迭代器与并发修改的那些坑
5.1 Itr内部类的实现
ArrayList的迭代器是Itr内部类:
private class Itr implements Iterator<E> { int cursor; int lastRet = -1; int expectedModCount = modCount; public boolean hasNext() { return cursor != size; } public E next() { checkForComodification(); int i = cursor; if (i >= size) throw new NoSuchElementException(); Object[] elementData = ArrayList.this.elementData; if (i >= elementData.length) throw new ConcurrentModificationException(); cursor = i + 1; return (E) elementData[lastRet = i]; } final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); } }关键在expectedModCount这个字段。创建迭代器时,它记录了当前的modCount。每次调用next(),都会检查modCount是否还等于expectedModCount。如果不相等,说明在迭代过程中列表被修改了,立刻抛出ConcurrentModificationException。
这就是所谓的快速失败机制。它的目的是尽早暴露问题,而不是让程序在错误的状态下继续运行,产生难以排查的bug。
5.2 为什么for-each循环里删除元素会报错
很多人踩过这个坑:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (String s : list) { if ("b".equals(s)) { list.remove(s); // 抛出ConcurrentModificationException } }for-each循环底层用的就是迭代器。当你调用list.remove(s)时,modCount加一,但迭代器的expectedModCount没变。下一次调用next()时,checkForComodification发现不一致,抛出异常。
正确的做法有三种:
第一种,用迭代器的remove方法:
Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if ("b".equals(s)) { it.remove(); // 迭代器的remove会同步更新expectedModCount } }第二种,用removeIf方法(Java 8+):
list.removeIf(s -> "b".equals(s));第三种,倒序遍历删除:
for (int i = list.size() - 1; i >= 0; i--) { if ("b".equals(list.get(i))) { list.remove(i); } }倒序遍历之所以可行,是因为删除元素后,后面的元素索引会变化,但前面的元素索引不受影响。如果你正序遍历删除,删掉一个元素后,后面的元素会往前挪,而你的索引还在往前走,就会漏掉一些元素。
5.3 多线程环境下的ArrayList:为什么它不是线程安全的
ArrayList的所有方法都没有加锁。在多线程环境下,如果多个线程同时add元素,可能会出现各种问题:
- 两个线程同时判断
size == elementData.length,都认为需要扩容,然后各自创建一个新数组,最后一个覆盖前一个,导致数据丢失。 - 一个线程在add,另一个线程在remove,可能导致size值不对。
- 一个线程在扩容(数组正在复制),另一个线程在get,可能读到null或者旧值。
如果需要在多线程环境下使用列表,有几种选择:
Collections.synchronizedList(new ArrayList<>()):给所有方法加上synchronized锁,性能一般。CopyOnWriteArrayList:写时复制,适合读多写少的场景。- 自己加锁:在业务层面控制并发访问。
实操心得:我见过很多项目里用ArrayList作为成员变量,然后在多线程环境下访问,出了问题才来排查。建议在代码审查时特别关注这一点:如果一个ArrayList被多个线程共享,要么换成线程安全的实现,要么确保所有访问都在同一个锁的保护下。
6. 性能优化与常见问题速查
6.1 初始容量估算的实用公式
指定初始容量时,怎么估算比较合理?我通常用这个公式:
初始容量 = 预估元素个数 / 0.75
为什么除以0.75?因为ArrayList的扩容阈值是容量满的时候,而不是容量用到75%的时候。但考虑到扩容的代价,留一些余量是合理的。比如你预估有1000个元素,初始容量设为1334左右,这样在添加过程中基本不会触发扩容。
当然,如果你能精确知道元素个数,直接设为那个数就行。比如从数据库查出来的记录数,或者从文件读出来的行数。
6.2 批量添加时trimToSize的取舍
trimToSize()方法会把底层数组的容量缩减到实际元素个数:
public void trimToSize() { modCount++; if (size < elementData.length) { elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } }这个方法的适用场景是:你已经往列表里添加完所有元素,确定不会再添加了,而且内存比较紧张。调用它可以释放多余的内存。
但要注意,trimToSize会触发一次数组复制,有性能开销。如果列表很大,这个操作可能耗时较长。而且如果之后又添加元素,会再次触发扩容。所以它适合“一次性填充,之后只读”的场景。
6.3 常见问题排查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 添加元素越来越慢 | 频繁扩容导致数组复制 | 打印elementData.length观察变化 | 预先指定初始容量 |
| 迭代时抛ConcurrentModificationException | 迭代过程中修改了列表 | 检查循环体内是否有add/remove | 用迭代器remove或removeIf |
| remove删不掉元素 | equals方法未重写 | 检查元素类的equals实现 | 重写equals和hashCode |
| 内存占用异常高 | 容量远大于实际元素数 | 检查elementData.length和size的比值 | 调用trimToSize |
| 多线程下数据丢失 | ArrayList非线程安全 | 检查是否有共享的ArrayList | 换用线程安全实现或加锁 |
| get返回null | 索引位置确实存了null | 检查添加逻辑 | 确认业务逻辑是否允许null |
6.4 几个容易被忽略的细节
第一个细节:ArrayList允许存储null值。add(null)是合法的,get返回null时可能是元素本身就是null,也可能是索引越界(但索引越界会抛异常,不会返回null)。所以如果你用get的返回值来判断元素是否存在,要小心null的情况。
第二个细节:subList返回的是视图,不是新的列表。对subList的修改会反映到原列表上,反之亦然。这个特性很容易导致bug:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d")); List<String> sub = list.subList(1, 3); // [b, c] sub.set(0, "x"); // list变成[a, x, c, d] list.set(2, "y"); // sub变成[x, y]如果你想要一个独立的子列表,应该用new ArrayList<>(list.subList(1, 3))。
第三个细节:Arrays.asList返回的列表是固定长度的,不支持add和remove。它底层是Arrays的内部类,不是ArrayList。很多人会搞混:
List<String> list = Arrays.asList("a", "b", "c"); list.add("d"); // 抛出UnsupportedOperationException如果需要可变列表,要这样写:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); list.add("d"); // 正常第四个细节:ArrayList的clone()方法是浅拷贝。克隆出来的新列表和原列表共享元素对象的引用。如果元素是可变对象,修改其中一个列表里的元素,另一个列表也会受影响。
List<StringBuilder> list = new ArrayList<>(); list.add(new StringBuilder("hello")); List<StringBuilder> clone = (List<StringBuilder>) list.clone(); clone.get(0).append(" world"); System.out.println(list.get(0)); // 输出"hello world"如果需要深拷贝,得自己遍历元素逐个克隆。
6.5 什么时候该用ArrayList,什么时候不该用
ArrayList最适合的场景是:读多写少,主要是随机访问,或者只在尾部添加删除。
不适合的场景包括:
- 频繁在列表中间插入删除:应该考虑LinkedList。
- 需要频繁按值查找:应该考虑HashSet或HashMap。
- 多线程并发写:应该考虑CopyOnWriteArrayList或加锁。
- 需要队列语义(先进先出):应该考虑ArrayDeque或LinkedList。
我个人的经验是,日常开发中90%的列表场景用ArrayList就够了。LinkedList虽然插入删除快,但随机访问慢,而且每个元素都有额外的节点对象开销,内存占用更大。除非你确实需要频繁在头部插入,否则ArrayList通常是更好的选择。
7. 从源码中学到的设计思想
读ArrayList源码最大的收获,不是记住了某个方法的实现,而是理解了背后的设计权衡。
延迟分配的思想:无参构造时不分配数组,等到第一次add时才分配。这个思想在很多地方都有体现,比如懒加载、写时复制。核心逻辑是:如果某个资源可能用不到,就不要提前分配。
1.5倍扩容的折中:扩容倍数太大浪费内存,太小影响性能。1.5倍是在两者之间找到的平衡点。这种“不追求单方面最优,而是追求整体平衡”的思路,在系统设计中非常常见。
快速失败的哲学:与其让程序在错误状态下继续运行产生难以排查的bug,不如尽早抛出异常。这种“早失败、早暴露”的思路,在分布式系统、并发编程中都有广泛应用。
modCount机制的巧妙:用一个简单的计数器,就实现了对结构性修改的检测。这个设计成本极低(一次自增),但效果很好。
这些设计思想,比具体的代码实现更有价值。因为代码会随着版本更新而变化,但设计思想是相对稳定的。理解了这些思想,你不仅能读懂ArrayList,还能读懂其他集合类,甚至能在自己的代码里运用这些思想。
最后分享一个我个人的习惯:每读一个类的源码,我都会问自己三个问题——它解决了什么问题?它是怎么解决的?它为此付出了什么代价?把这三个问题想清楚了,这个类就算真正读懂了。ArrayList的答案是:解决了数组定长的问题,通过动态扩容实现,代价是扩容时的数组复制和内存浪费。想清楚这个,你就掌握了ArrayList的精髓。