资讯动态

ArrayList源码拆解:扩容原理、快速失败机制与实战避坑

发布时间:2026/10/14 7:20:12 来源:尧图企业网站定制
网上讨论内存分配、并发编程和数据结构绕来绕去总是回避不开一个最基础的类ArrayList。很多人用了几年的ArrayList问起底层原理能答出“动态扩容”“底层是数组”但再往深了问——每次扩容多少为什么偏偏用这个比例size和capacity到底差在哪迭代器抛出的ConcurrentModificationException是从哪一行代码开始触发——不少人是懵的。我之前在某公司做线上性能排查时遇到过一位同事用while (list.remove(obj))去清空一个上万条数据的列表结果整个接口响应变了倍。当时他很不服气说remove肯定要移动元素为什么这么慢我让他猜ArrayList.remove底层到底做了几件事他答不上来。后来我把他带到源码面前把System.arraycopy和modCount那一行行拆给他看他才意识到凡是建立在错误理解上的代码性能上一定会加倍偿还。这篇博文就从源码入手把ArrayList的物理结构、扩容原理、方法实现、边界情况和实战避坑完整过一遍。无论你是刚学集合的小白还是已经写了多年的服务端老手只要你想把ArrayList用得明明白白这篇文章都值得花十分钟静下心来看完。1. 先从数据结构说起动态数组的底层骨相1.1 为什么是数组而不是链表ArrayList的核心本质是一个可以自动增长长度的对象数组。它的声明最早可以追溯到JDK 1.2到今天基本没有大的整体变更只做了一些局部优化。我们平时说它是一棵“动态数组”但这个“动态”并不是底层结构变成了链表而是“数组装不下了就换一个更大的数组再装”。数组的特性决定了ArrayList的随机访问效率是常量级elementData[index]一行代码就能算出内存地址偏移量不需要遍历。这在读多写少的业务场景里非常吃香比如查询订单列表、加载配置项、批量DTO转换这些操作几乎都是“先装数据再按下标或遍历读走”很少有反复插入中间位置的需求。链表恰恰相反它擅长的是头部或中间的高频插入这一点LinkedList在JDK中就是以双向链表实现的但它的随机访问需要从头或尾逐节点遍历复杂度是O(n)。这就是为什么很多老手给出建议时总说“如果你不知道用哪个就用ArrayList”。1.2 容量和size以及默认构造器的懒加载设计先看源码中两个核心字段transient Object[] elementData; private int size;elementData是真正存元素的地方size是当前列表里有多少个元素也就是“逻辑长度”。很多人把size当成数组的长度这是第一个常见误区。举个例子你new ArrayList(10)然后add了3个元素此时size是3但elementData.length是10。容量是10实际元素是3后面再添加时不会立即触发扩容。再看JDK 8之后默认构造器的写法private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }知道这意味着什么吗用new ArrayList()创建出来的对象最开始底层的数组是一个长度为0的空数组。只有当第一次add时数组才会被真正扩容到默认容量10。这个设计非常讨巧它避免了很多“创建了列表却从不添加元素”的场景白白占用10个对象槽位的内存。比如你有一个MapString, ListX大量Value创建出来只是作为占位可能永远不往里放数据懒加载能省下一笔不小的内存。这里需要顺带解释一个细节JDK 8及以后普通构造器和带初始容量构造器走了两条路径。如果执行new ArrayList(0)elementData会赋值成EMPTY_ELEMENTDATA而不是DEFAULTCAPACITY_EMPTY_ELEMENTDATA。这两者的区别在于当第一次add时前者直接扩容到max(0, 1)——答案是1而后者会扩容到max(10, 1)——答案是10。也就是说哪怕你传容量0它也不会按默认10来扩。这确实是个微妙的边界行为知道以后能解释不少奇怪的现象。2. 核心方法源码拆解每一行代码都得看懂2.1 add方法扩容触发点就是这里ArrayList最常用的add(E e)方法在JDK 17的源码里长这样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被放在了第一行。modCount是AbstractList里定义的一个记录结构化修改次数的字段所谓结构化修改就是改变列表元素个数或数组长度的操作比如add、remove、clear。修改某个位置的值也就是set不算结构化修改所以modCount不会递增。这个字段是后面迭代器快速失败机制的基石。真正触发扩容的条件是判断size elementData.length也就是逻辑长度已经追上了物理长度装不下了。此时调用grow()。如果还没装满直接elementData[size] esize加一。这说明在不扩容的情况下add的代价极其低廉就是一次数组赋值。同样的道理add(int index, E element)在插入中间位置之前要先把index及后面的元素集体后移一格。这一步用的也是System.arraycopy。先扩容再移位再赋值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; }读者可以自己算一笔账在10000个元素的列表中往第0个位置插入一个新元素实际是要移动整整10000个元素一次性arraycopy需要做一万次引用拷贝。这种操作如果被放在循环里反复执行复杂度直接变成O(n²)线上卡顿也就顺理成章了。2.2 get、set、remove索引访问与被移位的快乐get和set是ArrayList引以为傲的招牌操作底层就是数组下标访问public E get(int index) { Objects.checkIndex(index, size); return elementData(index); } public E set(int index, E element) { Objects.checkIndex(index, size); E oldValue elementData(index); elementData[index] element; return oldValue; }这里有一个经常被吐槽的细节get没有做“数组越界”检查本质上它检查的是index size。如果数组长度明明还很大但你传进一个大于等于size的索引照样会抛出IndexOutOfBoundsException。这其实是一种语义上的保护——列表的逻辑范围只允许访问到size之前的位置而不是底层数组的物理长度。源码层面其实还会先看index是不是负数防止负索引被当作合法参数。remove(int index)的源码更有意思public E remove(int index) { Objects.checkIndex(index, size); final Object[] es elementData; SuppressWarnings(unchecked) 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目的是帮助垃圾回收。如果没有这一步数组里那个位置还保留着最后一个对象的引用哪怕你已经用不到它了GC也不能把它标记为不可达从而可能造成内存滞留。我在某一次排查服务内存缓慢上升时就见过有人用ArrayList缓存海量对象又不清理尾部引用结果白白占用了几十兆内存的情况。2.3 迭代器与modCount快速失败机制ArrayList的迭代器是一个私有内部类Itr它实现了IteratorE接口。关键字段有三个int cursor; int lastRet -1; int expectedModCount modCount;cursor指向下一个要返回的位置lastRet记录最后一次返回元素的下标expectedModCount在创建迭代器时快照了当时的modCount。每次调用next()代码开头都会有这么一句checkForComodification();这个方法的内容是final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); }也就是说迭代器在做任何操作前都会拿当前列表的modCount和自己创建时的快照比对。如果外部代码在迭代过程中调用了add、remove、clear等会改变结构的方法modCount就会变化进而与expectedModCount不一致迭代器立刻抛出异常。这就是“快速失败”的源码级真相。设计意图不是保证并发安全而是尽早暴露错误防止你在迭代中基于一个已被修改过的列表继续做无意义或错误的事。很多人用增强for循环删除元素时踩到异常根因就是语法糖背后隐藏了迭代器。这里我额外补充一个实战坑在迭代中删除“符合条件的前N个”时如果直接用外部list.remove(...)很容易抛ConcurrentModificationException但如果只用迭代器自己的iterator.remove()就没问题。因为Itr.remove内部会调用ArrayList.this.remove(lastRet)然后同步更新expectedModCount modCount把快照刷新到最新。3. 扩容机制详解从grow到Arrays.copyOf参数怎么选3.1 从默认10到1.5倍背后的性能算账前面提到grow()时还没有拆开看它的内部实现。在JDK 17的源码中扩容逻辑主要集中在newCapacity方法private Object[] grow(int minCapacity) { return elementData Arrays.copyOf(elementData, newCapacity(minCapacity)); } private int newCapacity(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); if (newCapacity - minCapacity 0) { if (elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA) return Math.max(DEFAULT_CAPACITY, minCapacity); if (minCapacity 0) throw new OutOfMemoryError(); return minCapacity; } return (newCapacity - MAX_ARRAY_SIZE 0) ? newCapacity : hugeCapacity(minCapacity); }核心扩容公式是newCapacity oldCapacity (oldCapacity 1)。oldCapacity 1就是除以2所以新容量是旧容量的1.5倍。为什么选1.5倍而不是2倍也不是1.1倍这是一个经典的工程权衡问题。倍数太小扩容频繁反复Arrays.copyOf搬移数据的开销变大倍数太大内存浪费严重明明只add一次可能一次性多分配几万个槽位。1.5倍是经过长期实践验证的经验值既能保证均摊下来单次add的复杂度是O(1)又能把内存浪费控制在可接受的范围。拿具体数字推算一下初始容量10以后依次扩容到15、22、33、49、73、109……一直增加到1000万级别的容量扩容次数大约是log1.5(1000万/10)也就是三十几次。如果不扩容直接在数组尾部模拟动态追加均摊成本就是O(1)扩容之所以偶发高开销是因为Arrays.copyOf要把旧数组的所有引用搬到新数组里这是一次O(n)操作但平摊到每一次add上依然接近常数。3.2 MAX_ARRAY_SIZE与hugeCapacity接近极限的边界处理源码里定义了一个常量private static final int MAX_ARRAY_SIZE Integer.MAX_VALUE - 8;为什么不直接用Integer.MAX_VALUE因为这个跟JVM底层对象头布局有关。数组对象在HotSpot虚拟机里也属于普通对象有对象头。数组长度存放在对象头的一个字段里但数组长度字段的最大可表示范围可能因为对象头对齐、压缩指针等因素受限所以保守起见JDK用一个比Integer.MAX_VALUE小8的值作为安全上限。实际上绝大多数机器根本不可能在报错之前就分配出这么大的连续数组空间这个常量更多是理论上的护栏。hugeCapacity的长相是这样的private static int hugeCapacity(int minCapacity) { if (minCapacity 0) throw new OutOfMemoryError(); return (minCapacity MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }逻辑很简单如果要的最小容量已经超出逻辑上限就直接给Integer.MAX_VALUE而一旦minCapacity变成负数说明整数溢出了直接抛OutOfMemoryError。这类极端路径在平时的业务代码里很难触发但面试或源码分析时经常被拿来考察细节。3.3 用trimToSize收缩容量给列表瘦身扩容是自动的但缩容不是。如果某个ArrayList曾经被填充过大量数据后来又删除到只剩几条记录底层数组仍然保持着大的物理长度。这时可以显式调用public void trimToSize() { modCount; if (size elementData.length) { elementData (size 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } }这个方法会把数组精确缩放到size大小。如果size 0直接把数组替换成静态共享的空数组。为什么需要手动触发因为日常使用中列表生命周期结束就跟随容器一起释放没必要提前缩容只有面对长期存活且大进大出的对象时主动瘦身才有意义。比如某个缓存对象的某个字段长期指向一个曾经有上百万条记录的列表即便记录已经删光底层数组还拿着上百MB引用这就必须调用trimToSize把坑填上。一个容易忽略的点trimToSize会递增modCount所以如果你正在迭代突然调用trimToSize同样会触发快速失败。这在业务代码里几乎不会出现但确实有想当然的同事踩过。4. 实用选择ArrayList、LinkedList与Vector别再盲目抄4.1 LinkedList的两张王牌什么时候才是加分项LinkedList在JDK中底层是一个双向链表每个节点包含前驱引用、后继引用和数据。数据量大之后每个元素额外占用两个引用的空间而且节点在内存里往往不连续缓存命中率极低。遍历链表的过程会频繁发生缓存未命中这在现代CPU上对性能的伤害比算多少个等式的计算都大。那LinkedList真正擅长的是什么头部插入、尾部插入和从头或尾的双向遍历。比如你要维护一个“最近访问的10个记录”的LRU结构LinkedList可以在O(1)时间内完成头插和尾删这种场景它就比ArrayList强得多。然而如果你需要随机访问链表的中间元素比如list.get(5000)LinkedList要从头或从尾往中间一步步跳复杂度O(n)而ArrayList只需一次数组偏移高下立判。实际项目中我看到很多同学之所以选LinkedList仅仅是听别人说“插入删除快”但没有确认这个结论的前提是“已持有节点引用”还是“按下标插入”。如果你已经持有下标ArrayList的删除虽然要做arraycopy但那个拷贝是内存级别的批量操作速度惊人而LinkedList按下标查找的过程已经先浪费了一遍O(n)。所以在“按下标删除”这个高频操作上LinkedList往往反而不如ArrayList。4.2 Vector同步不是银弹Vector是JDK 1.0时代的老集合它的单线程访问性能和ArrayList差不多只是每个关键方法上都有synchronized修饰。这带来的问题是即使你的场景根本没有并发竞争解锁加锁的开销也在白白损耗性能而如果真有并发竞争Vector的粗粒度同步又显得太粗糙容易在复合操作上出现竞态条件。假如你真的需要线程安全的列表现代JDK里更靠谱的做法是按需选择读多写少、并发迭代频繁用CopyOnWriteArrayList它的迭代器不会抛ConcurrentModificationException。需要独占锁保护复杂复合操作用Collections.synchronizedList(new ArrayList())同时在外部对每个复合操作统一加锁。追求高性能无锁读配合原子变量自行管理数组则是另一个层面的设计了。Vector在今天更多像一个历史教学案例用它写新代码基本等于给后来的维护者埋雷既不快也不够细还带着陈旧的命名习惯addElement、elementAt这类老API。除非你在维护老系统否则我不会推荐它。4.3 32位和压缩指针下的内存解剖有人觉得ArrayList用起来很省内存其实它的省是和LinkedList比相对而言。单独看一个ArrayList实例底下一层Object[]数组数组对象在堆里有对象头mark word、klass pointer32位或开启压缩指针的64位JVM下对象头一般12字节再对齐填充。数组元素每个引用在压缩指针下是4字节关闭压缩指针的环境下是8字节。假设存100万个字符串引用纯引用这部分就是4MB字符串对象本身的内容另算。LinkedList每个节点额外保存前后两个引用100万个节点光链接字段就要8MB再加上节点对象头和padding往往比ArrayList高出两三倍的额外内存。所以在海量数据场景里ArrayList通常是更经济的选择。不过如果你知道集合大小固定也可以直接使用原生数组或Arrays.asList包装进一步减少包装对象的开销。5. 常见问题与排查技巧实录这里把我平时见过的高频问题和排查思路整理成一张速查表遇到问题可以直接对照典型现象源码层原因解决办法迭代时删除元素抛ConcurrentModificationExceptionmodCount ! expectedModCount触发快速失败使用迭代器自己的remove或者在收集完条件后统一用removeIfsubList修改后操作原列表抛异常SubList内部持有父列表和独立modCount快照把subList当作临时视图做完操作立即丢弃不要长期保存视图引用调用remove传错了重载参数remove(int)和remove(Object)语义不同remove(1)删下标remove(Integer.valueOf(1))删元素明确意图后用包装类型但注意Integer.valueOf缓存范围向超大列表频繁插入首位性能骤降每次插入都要arraycopy整体移动后半部分改用头插频繁的场景应使用LinkedList或ArrayDeque打印大列表卡死toString底层迭代拼接所有元素只打印list.size()或列表的大小别直接LOGGER.info(list)列表里自定义对象contains失效indexOf依赖对象equals自定义类未重写重写equals和hashCode并保证两个方法的语义一致下面挑几个源码向的细节重点展开。5.1 subList的陷阱视图共享底层的数组subList返回的并不是一个独立的新列表而是原列表内部类SubList的实例。它持有父列表的引用所有操作都直接作用于原数组。对subList做add、remove会同步影响原列表。最坑的是SubList构造函数中保存了当前的modCount一旦你在拿到subList之后又通过原列表改了结构再回头操作subList它会在很多方法的入口做一次checkForComodification然后直接抛ConcurrentModificationException。我见过一个典型事故分页接口里先用list.subList(from, to)截取一页数据返回给前端然后又在后面继续往原列表里追加数据。下次请求进来时那个缓存的subList已经不合法了一调用就抛异常。解决方案很朴素如果需要独立数据就用new ArrayList(list.subList(from, to))再包一层切断共享。5.2 初始化容量到底有没有必要很多初级开发者觉得new ArrayList()简单方便但如果你明确知道元素数量比如从数据库查出10000条记录要封装成列表直接传容量会省下数次扩容的Arrays.copyOf。这个优化的性价比很高一次扩容要将旧数组所有引用复制到新数组一万条记录可能要经历从10到15、22、33……一直扩到16384的大约12次搬家累计复制的元素总数超过3万次引用拷贝。虽然现代CPU跑这种操作很快但在需要极致性能的批处理场景里这份开销确实可以砍掉。更精细的做法是估算如果无法精确知道数量但能知道上限就按上限的1.1倍传初始容量如果完全不知道可以观察基线数据比如平均每批到达多少条再设定一个比均值翻倍也远小于峰值容量的初始值。5.3 删除全部元素的两种姿势辨析清空列表写list.clear()是最自然的。但有些场景不是清空而是“保留列表对象只剥离所有元素引用”clear()其实做的和你想的平均一致public void clear() { modCount; final Object[] es elementData; for (int to size, i size 0; i to; i) es[i] null; }它会把每个槽位都置为null同时size归零。这里的for循环清空全部引用是有意为之目的是协助GC。如果只是设置size 0而保留数组里的引用那些对象依然被数组强引用GC根本无法回收可能造成内存泄漏。源码里连这种清理都考虑到了更说明我们平时用完集合后保留引用的隐患有多大。6. 从源码中带走的个人体会研究ArrayList源码给我最大的启发不是学会了某几个方法怎么用而是重新理解了“数据结构选型”是怎么渗透到每一行业务代码里的。扩容策略的1.5倍、快速失败的modCount、删除尾部置空的GC协助这些都不是看文档能记住的知识而是要看到那一行代码才能体会到设计者当时面对的性能和内存压力。我后来带新人做代码审查时最喜欢问的一个问题就是“你这个列表创建的时候数组长度是多少”十个人里有七个答不出来。没有这种级别的自觉性能问题往往就会在数据量大之后突然冒出来而且很难定位。如果你只能记住这篇文章里一句话我希望是这句ArrayList从来没承诺过“什么操作都快”它只承诺了“按下标访问最快”其他一切操作的成本都由你自己在调用前算清楚。很多线上性能事故归根到底不是JDK写得差而是System.arraycopy被悄悄触发了很多次。最后再分享一个小习惯我打开源码时会先把该类的字段读一遍再看构造器然后按函数列表逐个点进去遇到grow、System.arraycopy、modCount这类高频动作会做高亮笔记。这套方法在一个类上花不了二十分钟但对理解整个集合框架都有长线收益。下次面试官问你“ArrayList扩容多少倍”你就不仅能答出1.5倍还能顺手把为什么是1.5倍、MAX_ARRAY_SIZE怎么来的、modCount快速失败怎么实现的讲得明明白白。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑