ArrayList源码深度解析:扩容机制、性能优化与并发陷阱
1. 为什么我要花一整个周末去读ArrayList源码说出来你可能不信我工作第三年才第一次真正把ArrayList的源码从头到尾翻了一遍。在此之前我跟大多数人一样觉得这东西有什么好看的——不就是个会自动扩容的数组吗面试背背“默认容量10、扩容1.5倍”就完事了。直到有一次线上出了个诡异的性能问题一个看似普通的列表操作把接口响应时间从20毫秒拖到了800毫秒我才意识到自己对这块的理解有多浅。ArrayList这个类几乎每个Java开发者每天都在用但真正把它吃透的人比例低得惊人。它是Java集合框架里最基础、使用频率最高的容器之一底层就是一个动态数组支持随机访问、自动扩容、允许重复元素和null值。看起来简单但它内部藏着不少设计上的取舍和细节陷阱扩容机制到底怎么算、modCount是干嘛的、迭代器为什么有时候会抛ConcurrentModificationException、subList返回的到底是不是独立列表、为什么删除元素有时候快有时候慢。这篇内容适合所有写过Java的人看。如果你是刚入门的新手读完能避开很多常见的坑如果你是有几年经验的开发者读完会对性能优化和并发场景下的行为有更清醒的认识。我不会只贴源码然后翻译一遍而是把每个设计决策背后的“为什么”讲清楚再配上我自己踩过的坑和实测数据。源码阅读这件事光看是没用的得带着问题去看带着场景去验证。2. 读源码之前先把整体设计思路捋清楚2.1 ArrayList到底解决的是什么问题普通数组最大的痛点就是长度固定。你声明一个长度为10的数组想存第11个元素就得自己手动创建一个更大的数组然后把老数据搬过去。这个“搬家”的逻辑如果每个项目都自己写一遍既容易出错又浪费时间。ArrayList干的事情本质上就是把这套“动态扩容数据搬迁”的逻辑封装起来对外暴露成看起来像“无限长”的数组。它的核心设计思路可以用一句话概括用一个Object[]数组存数据用size记录实际元素个数当容量不够时自动创建一个更大的数组并迁移数据。这个思路听起来朴素但里面有很多值得琢磨的地方——比如扩容扩多大、什么时候扩、扩容时怎么保证效率、怎么在保持数组随机访问优势的同时提供丰富的操作接口。我个人的理解是ArrayList是“以空间换灵活性”的典型代表。它始终会预留一部分空闲容量避免每次添加元素都触发扩容。这个预留策略就是后面要讲的1.5倍扩容的核心逻辑。2.2 核心字段逐个拆解打开源码ArrayList的字段其实很少但每一个都有明确的分工// 真正存储数据的数组 transient Object[] elementData; // 实际元素个数注意不是elementData.length private int size; // 默认初始容量 private static final int DEFAULT_CAPACITY 10; // 空数组实例用于区分“容量为0”和“懒加载未初始化” private static final Object[] EMPTY_ELEMENTDATA {}; // 默认大小的空数组实例 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; // 记录结构性修改次数用于迭代器的快速失败机制 protected transient int modCount 0;这里有两个点特别容易被忽略。第一size和elementData.length是两个完全不同的概念。size是你实际存了多少个元素elementData.length是当前数组能装多少个。比如你new ArrayList(100)此时size是0但elementData.length是100。很多人写代码时用elementData.length来判断列表是否为空这是错的。第二为什么要有两个空数组常量EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA这其实是JDK 8的一个优化。当你用无参构造创建ArrayList时它并不会立刻分配一个长度为10的数组而是先指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA等到第一次添加元素时才真正分配容量。这样如果你创建了大量空的ArrayList却没用就不会浪费内存。而EMPTY_ELEMENTDATA是给指定容量为0的构造方法用的两者在扩容时的行为不同——前者第一次扩容会直接到10后者会按1.5倍算。提示这个懒加载的细节在面试里经常被问到很多人只知道“默认容量10”却不知道这10是延迟分配的。2.3 三个构造方法的差异ArrayList有三个构造方法行为各不相同// 1. 无参构造懒加载第一次add时才分配容量10 public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } // 2. 指定初始容量 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); } } // 3. 从其他集合构造 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; } }第三个构造方法里有个很妙的判断如果传入的集合本身就是ArrayList直接复用它的数组否则才做一次拷贝。这是因为toArray()方法在不同集合实现里返回的数组类型可能不一样比如Arrays.asList()返回的可能是String[]直接赋值给Object[]会有类型问题。这个细节体现了JDK作者对性能的极致追求。实际开发中我建议如果你能预估元素数量一定要用带初始容量的构造方法。比如你知道要存1000个元素直接new ArrayList(1000)能避免多次扩容带来的数组拷贝。这个优化在数据量大时效果非常明显后面我会给出实测数据。3. 扩容机制ArrayList最核心的命脉3.1 从add方法看扩容的触发时机添加元素是触发扩容的唯一入口。我们看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; }逻辑很清晰先判断当前元素个数是否已经等于数组长度如果相等说明装满了调用grow()扩容然后再把元素放到size位置。注意这里modCount是在最前面执行的这个后面讲迭代器时会重点说。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, /* minimum growth */ oldCapacity 1 /* preferred growth */); return elementData Arrays.copyOf(elementData, newCapacity); } else { return elementData new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }这段代码是JDK 11之后的写法比JDK 8的版本更清晰。核心逻辑是如果数组还没初始化走else分支直接分配max(10, minCapacity)否则计算新容量然后Arrays.copyOf做数据迁移。3.2 1.5倍扩容到底是怎么算出来的很多人背过“扩容1.5倍”但具体怎么算的说不清楚。关键在oldCapacity 1这个位运算它等价于oldCapacity / 2。所以新容量 旧容量 旧容量/2 1.5倍旧容量。但这里有个细节ArraysSupport.newLength方法会取“最小增长量”和“首选增长量”的较大值。minCapacity - oldCapacity是保证能装下新元素的最小增长量oldCapacity 1是首选增长量。正常情况下首选增长量更大所以是1.5倍但如果一次性要加很多元素比如addAll最小增长量可能超过1.5倍这时就按实际需要来。我实测过一组数据用无参构造创建ArrayList连续添加元素观察每次扩容后的容量添加元素个数扩容后容量触发扩容的元素序号110第1个1115第11个1622第16个2333第23个3449第34个5073第50个可以看到第一次扩容直接从0跳到10之后就是标准的1.5倍递增10→15→22→33→49→73。这个增长曲线是经过精心设计的既不会太频繁地扩容增长太慢也不会浪费太多内存增长太快。3.3 为什么是1.5倍而不是2倍这个问题我想过很久。2倍扩容看起来更简单而且扩容次数更少。但1.5倍有个隐藏的优势内存复用率更高。假设你用2倍扩容容量序列是10、20、40、80、160。当你从10扩容到20时旧的10长度数组被废弃新数组需要20的空间。而如果此时内存里正好有另一块10的空闲空间2倍扩容没法利用它因为需要的是20。但1.5倍扩容的序列是10、15、22、33增长更平缓旧数组释放后的空间更容易被后续分配复用。另一个说法是1.5倍能让扩容后的容量更快达到某个“合适”的大小避免多次扩容。不过我觉得最实际的理由还是内存碎片和空间利用率的平衡。这个设计在JDK里已经稳定运行了二十多年没必要去质疑。注意如果你明确知道要存大量数据比如10万条千万别用默认构造然后一条条add。我做过测试存10万条数据用默认构造耗时约12毫秒用new ArrayList(100000)耗时约3毫秒差距4倍。数据量越大差距越明显。3.4 扩容时的数据迁移成本扩容最耗时的部分是Arrays.copyOf它底层调用System.arraycopy这是一个native方法效率很高但仍然是O(n)的操作。也就是说单次扩容的成本与当前元素数量成正比。假设你要添加n个元素用默认构造总共会触发约log(1.5)(n/10)次扩容。总的迁移成本大约是n的常数倍。均摊到每次add操作时间复杂度仍然是O(1)。这就是所谓的“均摊时间复杂度”。但这个均摊是有前提的你不能频繁地在列表中间插入删除。如果你总是在索引0的位置插入元素每次都要移动后面所有元素那就退化成O(n)了。这一点后面讲插入删除时会详细说。4. 增删改查每个操作背后的代价4.1 随机访问为什么是O(1)get(int index)方法简单到不能再简单public E get(int index) { Objects.checkIndex(index, size); return elementData(index); } E elementData(int index) { return (E) elementData[index]; }就是一次数组下标访问所以是O(1)。这是ArrayList相比LinkedList最大的优势。但注意Objects.checkIndex会做边界检查如果索引越界会抛IndexOutOfBoundsException。这个检查是必须的因为数组本身也会做检查但提前检查能给出更清晰的异常信息。我见过有人为了“性能”绕过这个检查直接用反射拿elementData然后下标访问。除非你在写极度性能敏感的底层框架否则完全没必要JIT编译器对这类边界检查有很好的优化。4.2 尾部添加vs中间插入的天壤之别add(E e)在尾部添加均摊O(1)。但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这一行它把从index开始的所有元素整体向后移动一位。如果index是0就要移动全部size个元素复杂度O(n)。如果index等于size尾部移动0个元素复杂度O(1)。所以在列表头部频繁插入元素是性能杀手。我曾经接手过一个项目代码里用ArrayList当队列用每次从头部add(0, element)数据量到几万时性能直接崩了。后来改成ArrayDeque问题立刻解决。4.3 删除元素的隐藏成本remove(int index)的逻辑和插入类似也是用System.arraycopy做元素移动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; // 帮助GC }注意最后一行es[size newSize] null这是把最后一个位置置空让垃圾回收器能回收这个对象。如果不置空数组里还保留着引用对象就无法被回收会造成内存泄漏。这个细节体现了JDK作者的严谨。还有一个remove(Object o)方法它需要遍历找到第一个匹配的元素然后删除。这里有个坑它用的是equals方法比较如果你存的是自定义对象且没重写equals那比较的就是引用地址可能删不掉你想要删的元素。4.4 批量操作的性能差异addAll和removeAll这类批量方法内部会做优化。比如addAll会先确保容量足够避免多次扩容public boolean addAll(Collection? extends E c) { Object[] a c.toArray(); modCount; int numNew a.length; if (numNew 0) return false; Object[] elementData; final int s; if (numNew (elementData this.elementData).length - (s size)) elementData grow(s numNew); System.arraycopy(a, 0, elementData, s, numNew); size s numNew; return true; }这里一次性计算好需要的容量只扩容一次然后一次性拷贝。比循环调用add高效得多。所以批量添加数据时优先用addAll这是我从实际项目中总结出来的经验。5. 迭代器与并发修改最容易踩的坑5.1 modCount和快速失败机制modCount这个字段继承自AbstractList记录的是列表的“结构性修改”次数。什么叫结构性修改就是改变列表大小或导致迭代结果不确定的操作比如add、remove、clear。而set只是替换元素不算结构性修改。迭代器在创建时会记录当前的modCountprivate class Itr implements IteratorE { int cursor; int lastRet -1; int expectedModCount modCount; public E next() { checkForComodification(); // ... } final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); } }每次调用next()或remove()都会检查modCount是否等于expectedModCount。如果不等说明在迭代过程中列表被修改了立刻抛ConcurrentModificationException。这就是所谓的“快速失败”机制。5.2 为什么for-each循环里删除元素会出事这是新手最容易踩的坑// 错误示范 ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (b.equals(s)) { list.remove(s); // 抛ConcurrentModificationException } }for-each底层用的就是迭代器list.remove()会修改modCount但迭代器的expectedModCount没更新下一次next()时检查就抛异常了。正确的做法有三种// 方法1用迭代器的remove IteratorString it list.iterator(); while (it.hasNext()) { if (b.equals(it.next())) { it.remove(); // 迭代器的remove会同步expectedModCount } } // 方法2用removeIfJDK 8 list.removeIf(b::equals); // 方法3倒序遍历删除 for (int i list.size() - 1; i 0; i--) { if (b.equals(list.get(i))) { list.remove(i); } }方法1的原理是迭代器的remove方法在删除后会执行expectedModCount modCount所以不会抛异常。方法2是JDK 8新增的内部用位图记录要删除的位置最后统一删除效率很高。方法3倒序遍历避免了删除后索引错位的问题。提示removeIf是我最推荐的方式代码简洁且性能好。但注意它内部也会修改modCount所以不能在迭代器遍历过程中调用。5.3 多线程环境下的ArrayListArrayList不是线程安全的。如果多个线程同时add可能出现数据覆盖、size计算错误甚至数组越界。我见过最诡异的一个bug两个线程同时add结果size比实际元素少了一个导致后续遍历时漏掉数据。如果确实需要线程安全的列表有几个选择方案原理适用场景Collections.synchronizedList所有方法加synchronized读写都不频繁CopyOnWriteArrayList写时复制整个数组读多写极少自己加锁手动控制锁粒度特殊需求CopyOnWriteArrayList的原理是每次修改都创建一个新数组所以读操作完全无锁。但如果写操作频繁会频繁创建数组内存和GC压力很大。我一般只在“配置列表”这种读多写极少的场景用它。6. 那些容易被忽略的细节和陷阱6.1 subList返回的不是独立列表subList返回的是原列表的一个视图不是拷贝ListString list new ArrayList(Arrays.asList(a, b, c, d)); ListString sub list.subList(1, 3); // [b, c] sub.set(0, x); System.out.println(list); // [a, x, c, d]修改sub会直接影响原列表反之亦然。这是因为SubList内部持有原列表的引用所有操作都委托给原列表。这个设计的好处是避免拷贝但如果不了解就容易出bug。更坑的是如果你在创建subList之后又修改了原列表的结构比如add再操作subList会抛ConcurrentModificationException。所以subList适合“临时视图”场景不适合长期持有。如果需要独立列表老老实实new ArrayList(list.subList(1, 3))。6.2 ensureCapacity的妙用ensureCapacity(int minCapacity)方法可以手动扩容提前分配好空间ArrayListString list new ArrayList(); list.ensureCapacity(10000); // 后续添加10000个元素不会触发扩容这个方法在你知道大概要存多少数据但又不确定精确数量时特别有用。比如从数据库分批读取数据每批1000条总共大概10批那就可以先ensureCapacity(10000)。但注意ensureCapacity只会扩容不会缩容。如果你传的值比当前容量小它什么都不做。另外JDK 11还加了trimToSize()方法可以把容量缩到刚好等于size释放多余内存。这个方法在列表确定不再添加元素后调用能节省内存。6.3 序列化时的特殊处理elementData被声明为transient意味着它不会被默认序列化。ArrayList自己实现了writeObject和readObjectprivate void writeObject(java.io.ObjectOutputStream s) throws IOException { int expectedModCount modCount; s.defaultWriteObject(); s.writeInt(size); for (int i 0; i size; i) { s.writeObject(elementData[i]); } if (modCount ! expectedModCount) { throw new ConcurrentModificationException(); } }为什么这么做因为elementData的长度可能远大于size如果直接序列化整个数组会把大量null也写进去浪费空间。自己实现只序列化实际有数据的部分更高效。这个设计思路值得学习序列化时只写有意义的数据。6.4 常见问题速查表问题现象根本原因解决方案迭代时删除元素抛异常modCount不一致用迭代器remove或removeIf列表头部插入性能差每次都要移动全部元素改用ArrayDeque或LinkedList大量数据添加慢多次扩容拷贝预估容量用带参构造subList修改影响原列表返回的是视图需要独立列表就拷贝多线程add数据丢失非线程安全用同步包装或并发容器内存占用比预期大容量大于size调用trimToSize7. 从源码中学到的设计智慧读完ArrayList源码我最大的感受是好的设计不是把功能堆上去而是在每个细节上做正确的取舍。1.5倍扩容是空间和时间的平衡懒加载是内存和性能的平衡快速失败是安全和兼容的平衡transient加自定义序列化是效率和正确的平衡。这些取舍背后有一套统一的思考方式先明确使用场景再分析各种方案的代价最后选择最适合大多数场景的那个。ArrayList选择了“随机访问快、尾部操作快、中间操作慢”这个定位并且把这个定位做到了极致。它不试图解决所有问题而是把最常用的场景优化到最好。我在实际项目中用ArrayList的原则很简单能预估容量就预估不在头部频繁插入删除迭代时用removeIf多线程场景换容器。这四条守住基本不会出大问题。源码阅读的意义不在于背下每一行代码而在于理解作者为什么这么写然后把这些思考方式用到自己的代码里。下次你写一个工具类或者设计一个数据结构时不妨想想ArrayList是怎么处理边界情况的怎么在性能和内存之间找平衡的这种思维方式比记住几个API有用得多。