凌晨一点四十,监控平台突然跳了一连串红色告警,我负责的批量导入服务接口平均耗时从平时的80毫秒涨到了1200毫秒。当时第一反应是数据库慢查询或者GC出了问题,结果查了一圈,线程栈里最扎眼的反而是一行再普通不过的代码:
List<Long> idList = new ArrayList<>();整个导入任务里,这个list被不停地add,一次导入几十万条数据,add期间反复触发ArrayList底层数组的扩容。每次扩容都是一次System.arraycopy,旧数组作废变成垃圾等GC回收,新数组重新分配。数据量越大,这个动作越频繁,接口自然就被拖垮了。
说实话,ArrayList动态扩容机制我在面试里背过无数遍——“默认容量10,每次扩容1.5倍”,但真正遇到线上性能问题,才意识到这几个字背后藏着多少底层逻辑。这篇文章不打算给你念面试答案,我想从一个真实踩过坑的开发者视角,把扩容机制的源码、计算规则、性能影响、优化手段和常见误区分层拆开讲,希望能帮你避开我犯过的错。
1. 从一次线上故障说起:ArrayList扩容是怎样悄悄拖垮接口的
1.1 故障现场:无参构造加大量add的组合拳
那次故障的代码场景很典型:从文件或者接口读取一批ID,放进ArrayList里做批量查询。因为事先不确定数据量,大家习惯性地写了new ArrayList<>(),然后循环add。
像下面这样的写法在业务代码里非常常见:
List<Long> ids = new ArrayList<>(); for (String line : lines) { ids.add(Long.parseLong(line)); }如果我告诉你这几十万次add里,真正耗时的不是add本身,而是底层数组“不够用了要换大房子”的那一刻,你大概能意识到扩容的影响。JDK 8的ArrayList,无参构造时底层数组其实是空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,只有在第一次add时才会真正分配容量为10的Object数组。之后随着元素越来越多,数组会在容量不够时进行扩容。
每一次扩容,都会经历三个步骤:
- 计算新容量;
- 分配一个新的Object数组;
- 把旧数组里的所有元素用
System.arraycopy复制到新数组。
1.2 扩容次数比你想象的多
我基于那天的数据量做了个简单的模拟,假设要向ArrayList里插入100万条数据,默认从容量10开始,按JDK 8的1.5倍扩容策略,每次扩容前后容量变化是:
| 扩容前容量 | 扩容后容量 | 触发时机(已存元素数) |
|---|---|---|
| 0 | 10 | 第一次add |
| 10 | 15 | 第11次add |
| 15 | 22 | 第16次add |
| 22 | 33 | 第23次add |
| 33 | 49 | 第34次add |
| 649 | 973 | ... |
| 545149 | 817723 | ... |
大致估算下来,往ArrayList里添加100万条数据,会触发大约20多次扩容。每次扩容都要复制旧数组里的全部元素,累积复制的元素次数不是100万,而是扩容前所有容量之和,差不多几百万次。虽然System.arraycopy是JVM底层native方法,但对象引用复制加上新数组分配带来的内存压力,在老年代空间紧张时就是灾难。
1.3 我第一次排查时犯的错
当时我一开始怀疑是SQL问题,给慢查询日志翻了个底朝天,发现数据库那边一切正常。接着怀疑连接池,也排除了。最后是把线程dump拉出来,看到大量线程卡在System.arraycopy,才顺着调用栈看到ArrayList的扩容。
这里有个值得分享的经验:遇到性能问题,先看线程栈,别靠猜。不同的耗时分布特征对应的问题完全不同。如果大量线程阻塞在数组复制、哈希计算、字符串拼接这类基础库方法上,往往不是第三方组件的问题,而是我们写代码时对底层数据结构的特性考虑不足。
2. 源码级拆解:add方法、grow方法和1.5倍扩容的前世今生
2.1 add方法最容易被忽略的分支
很多人背ArrayList源码时只背一个grow方法,但真正看懂扩容要从add方法开始。以JDK 8的java.util.ArrayList为例:
public boolean add(E e) { ensureCapacityInternal(size + 1); elementData[size++] = e; return true; }这段代码逻辑很清晰:先确认容量够不够,而后再赋值。关键在于ensureCapacityInternal内部逻辑:
private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount++; if (minCapacity - elementData.length > 0) { grow(minCapacity); } }注意几个容易被忽略的细节:
- 无参构造创建的ArrayList,
elementData指向一个共享的空数组,此时size为0,但elementData.length也是0。 - 第一次add时,
minCapacity会直接取DEFAULT_CAPACITY和size+1的较大值,DEFAULT_CAPACITY是10,所以第一次扩容直接给10。 modCount也会在这次扩容中自增,这个字段是用来做快速失败fail-fast的,迭代时如果检测到modCount变化,会立刻抛ConcurrentModificationException。
2.2 grow方法的真正核心
grow方法才是扩容的实现主逻辑:
private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) { newCapacity = minCapacity; } if (newCapacity - MAX_ARRAY_SIZE > 0) { newCapacity = hugeCapacity(minCapacity); } elementData = Arrays.copyOf(elementData, newCapacity); }这个扩容倍数其实来自oldCapacity + (oldCapacity >> 1),其中oldCapacity >> 1相当于oldCapacity / 2。两种写法效果一样,但位运算在底层执行时更快,而且不会产生中间状态的浮点数。所以扩容倍数不是网上很多人说的“固定1.5倍”,准确说是在整数运算下向右扩展一半,大多数情况下表现为1.5倍,但遇到特殊值时会有舍入。
2.3 为什么是1.5倍而不是2倍
不少面试者被问到“ArrayList为什么扩容1.5倍”时都很懵。这个问题其实没有唯一的官方答案,但可以从几个维度去理解:
- 扩容倍数越大,复制次数越少,但内存浪费越多。如果直接扩到2倍,内存空闲比例更高,可能数组刚扩完就占了很多用不上的空间。比如当前容量是100万,扩到200万,实际只用到100万零1个,这100万个对象的引用空间就白白闲置。
- 扩容倍数越小,内存利用率越高,但扩容次数越多。如果只扩0.5倍,那容量增长太慢,频繁扩容,复制开销成倍增加。
- 1.5倍是一个折中方案,兼顾了复制开销和空间浪费。按照等比增长,恰好落在减少扩容次数和避免空间闲置的平衡点上。
我记得有人从算法角度分析过,扩容倍率在黄金分割比附近时,时间和空间综合最优,1.5倍大概就是这个思路在工程上的体现。当然这些分析不算官方解释,但它起码告诉我们,1.5倍不是拍脑袋定的。
2.4 超过MAX_ARRAY_SIZE后的处理
grow方法里还埋着一个边界条件:
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;为什么不是Integer.MAX_VALUE?因为某些JVM实现里,数组对象自身需要8字节来存储对象头信息,如果直接申请Integer.MAX_VALUE大小的数组,加上对象头可能超过Integer.MAX_VALUE的限制,导致OOM。所以JDK留了8个字节的余量。
如果扩容计算出来的newCapacity超过MAX_ARRAY_SIZE,会走hugeCapacity方法:
private static int hugeCapacity(int minCapacity) { if (minCapacity < 0) { throw new OutOfMemoryError(); } return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }这里还有个反直觉的点:当数组容量超过MAX_ARRAY_SIZE时,JVM允许的最大容量直接是Integer.MAX_VALUE,反而比MAX_ARRAY_SIZE还大。原因是MAX_ARRAY_SIZE是多数JVM实现的安全上限,而Integer.MAX_VALUE是语言层面的极限,真到了这一步已经是强弩之末,反正再往上也只有OOM一条路。
2.5 JDK不同版本的扩容差异
这个问题很多人不注意。JDK 8和JDK 11的ArrayList扩容逻辑完全一样,但JDK 17之后代码迁移到了ArraysSupport之类的新工具类,实现细节略有调整,扩容倍率仍然是1.5倍。真正有差异的其实是Vector,Vecto的扩容策略是oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity),也就是说capacityIncrement为0时扩容是翻倍,为2时每次只加2。如果你还在维护老系统,看到Vector千万别用ArrayList的扩容逻辑去推测它。
3. 扩容的隐性成本:数组复制、内存分配和GC压力没那么“小”
3.1 一次扩容背后隐藏的CPU和内存开销
很多人觉得System.arraycopy是native方法所以很快,这句话在单次操作层面是对的,但放到循环调用场景就不成立了。
还是回到那天的故障。批量导入任务里,ArrayList从容量10开始,最终装下约80万条数据。过程中复制元素的总次数大约是:
10 + 15 + 22 + 33 + 49 + 73 + 109 + 163 + 244 + 366 + 549 + 823 + 1234 + 1851 + 2776 + 4164 + 6246 + 9369 + 14053 + 21079 + 31618 + 47427 + 71140 + 106710 + 160065 + 240097 + 360145 + 540217粗算下来接近160万次引用复制。每次复制还伴随着新数组创建,旧数组失去引用后成为垃圾,GC需要扫描、标记、清理这些数组对象。如果这些旧数组恰好都在老年代,触发一次Full GC的代价就非常可观。
3.2 内存碎片化的隐患
另一个隐性成本是内存碎片化。扩容时旧数组被丢弃、新数组被分配,数组大小还越来越大,长期运行的服务在这个过程里会不断制造“大小不一的数组尸体”。CMS或者G1这种回收器面对频繁的大对象分配和回收,堆内存的碎片化程度会慢慢加重。严重时明明free内存还有很多,但连续空间不够,直接触发OOM。
3.3 我的一次OOM排查教训
有一次我遇到的OOM,堆dump显示byte[]占了大量空间,同时ArrayList对象挂着巨大的Object[],但里面实际只存了几万条数据。原因就是无参构造加连续add,扩容到最终容量后,中途产生的旧数组全成了死对象,排查时看着堆里一堆大小不一的Object[]残骸,特别容易误判成内存泄漏。
正确的分析方法应该是看垃圾回收之后存活的对象,而不是盯着GC Root直接引用的那一层。ArrayList的elementData数组一旦扩到很大,就算你只删掉了一部分元素,数组容量不会自动缩小,所以它持有的内存仍然很大。这里牵出的trimToSize方法,我们后面讲。
3.4 用JMH实测扩容的性能影响
为了验证扩容到底多耗时,我写了段简单的JMH基准测试,比较“预先指定容量”和“无参构造动态扩容”两种方式在插入100万条数据时的表现:
| 测试场景 | 插入数据量 | 耗时(约) |
|---|---|---|
| new ArrayList() 动态扩容 | 100万 | 快,但伴随大量数组分配 |
| new ArrayList<>(1000000) 预分配 | 100万 | 快,基本无扩容 |
| 动态扩容(同时开启G1日志) | 100万 | 可观察到频繁的年轻代回收 |
测试结论是,预分配容量能减少约80%以上的数组分配次数,这在数据量大的场景下非常明显。注意,如果你的数据量本身只有几百条,预分配容量反而浪费内存,因为ArrayList不会因为给了1000的初始容量就只用10。
4. 御敌于扩容之前:初始容量、ensureCapacity和构造器选型的实战建议
4.1 明确知道数据量时的正确姿势
如果你能预估数据量,一定要用带初始容量的构造器:
List<Long> ids = new ArrayList<>(expectedSize);这里有个容易翻车的点:你以为传入1000,它就能存1000条不扩容,其实是可以的。因为初始容量就是底层数组长度,只要size不超过这个值,就不会触发扩容。但如果你传入的是expectedSize + 1,比如1001,反而浪费了一个元素的空间。ArrayList不像HashMap有负载因子的概念,不需要额外乘0.75之类,直接按预期数据量来就行。
4.2 数据量不确定时使用ensureCapacity
有些场景数据量确实无法精确预估,但你大致知道高峰能到多少。比如批量查询接口,单批最多1万条,那就可以在接收数据之前调用一下:
List<Long> ids = new ArrayList<>(); ids.ensureCapacity(10000);ensureCapacity的作用是让底层数组容量至少达到指定值,调用后如果当前容量不够,会触发一次扩容。结合扩容规律来看,这里有个效率优化技巧:一次性扩到1万,总比从10开始逐级扩到1万要省去中间十几轮的数组复制。
不过要注意,ensureCapacity触发扩容之后,新容量的计算还是会走grow逻辑,如果你传的值比当前容量小,它会直接忽略,不会缩容。
4.3 构造器选型对GC的影响
从GC角度看,预分配容量还能减少Young GC次数。每次扩容都会在Eden区分配一个新数组,旧数组变成垃圾,短时间大量数组分配会让Eden区快速打满,触发Minor GC。数据量大时,这种GC频繁度是肉眼可见的。
我在一个批处理场景里做了对比,同样是写100万条数据,预分配容量的版本Minor GC次数比动态扩容版本少了将近三分之二。这是因为动态扩容版本每次扩容不只分配一次新数组,之前所有旧数组在同一个GC周期里被成批回收,Eden区压力特别不均匀。
4.4 批量添加集合时的addAll技巧
除了构造器传参,还有一个容易被忽略的入口是addAll。很多人不知道addAll内部会先计算两个集合元素数量之和,然后走一次扩容,把目标数组一次性扩到能装下所有元素的大小:
public boolean addAll(Collection<? extends E> c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; }所以,如果你有个临时ArrayList,想把它合并到另一个大ArrayList里,直接用addAll比循环add高效得多。因为循环add可能触发多次扩容,而addAll只触发一次。
5. 那些年我们背错的扩容知识:常见误区和不准确言论的梳理
5.1 “默认容量是10”这个说法并不完全准确
面试题里常讲“ArrayList默认容量是10”,这句话在JDK 8之后其实需要加个前提:懒加载。
无参构造时,elementData指向的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这个数组长度是0。只有第一次add时,ensureCapacityInternal才会把容量扩到10。所以严格来说,“默认初始容量10”指的是第一次扩容时的目标容量,而不是构造时立刻分配的容量。
这带来一个隐藏知识点:new ArrayList<>()之后你通过反射查看elementData.length,得到的值是0,不是10。如果你基于“必为10”写一些容量判断逻辑,就很容易踩坑。
5.2 “扩容1.5倍”并不是每次都是严格1.5倍
前面说了,扩容公式是oldCapacity + (oldCapacity >> 1)。由于位运算向下取整,奇数容量扩容后的倍数实际上会小于1.5倍。比如容量15扩容到22,22除以15约等于1.47。所以更严谨的说法是“扩容约1.5倍,准确说是加上一半并向下取整”。
5.3 “ArrayList查询快”也是个需要场景限定的论断
ArrayList的随机访问确实快,因为底层是数组,按下标取元素是O(1)。但这个“快”有一个前提:你拿到的下标是有效的,且确实在做随机访问。如果业务代码里频繁用indexOf查找元素,ArrayList遍历整个数组的代价是O(n),和LinkedList相比没有任何优势。
这个误区虽然不直接属于扩容,但和ArrayList核心特性绑定得很深。很多人以为“数组快”就是所有操作都快,然后在循环里用contains和indexOf写出O(n^2)的代码。实际上,ArrayList的contains是线性扫描,没有HashMap那样的哈希索引。
5.4 “ArrayList扩容不会OOM”是错的
有一种声音认为ArrayList动态扩容是自动的,所以一定不会因为容量不够而OOM。这完全错了。扩容时如果计算出的newCapacity超过数组上限,会直接OOM;更常见的情况是,扩容过程分配不到足够大的连续数组,也会OOM。容量不够只是不会抛出“数组越界”类的异常,但内存不够照样崩。
5.5 trimToSize的误用和正确用法
trimToSize可以把底层数组容量调整到刚好等于当前元素个数。很多人喜欢在数据填充完成后调用它来“瘦身”,但在数据量很大的场景下,这个操作要谨慎:
trimToSize本质上也是一次数组复制,如果你之后还要继续add,它又会扩容,等于白白折腾了两次复制。- 如果ArrayList里有大量被删除后留下的空位,比如你删到只剩1000条但底层数组是100万,这时候调用
trimToSize确实能释放大量内存,收益很高。 - 正确姿势是:确认这个list之后只会被读取、不会继续添加元素时,再考虑
trimToSize。
5.6 关于subList的隐性扩容关联
顺便提醒一下,subList返回的是原ArrayList的内部视图,不是新ArrayList。如果你对sublist进行add操作,会修改原list的结构,str变化后原ArrayList的迭代器也会fast-fail。这和扩容机制无关,但属于ArrayList使用中的常见坑,值得一并记住。
6. 更进一步:自定义增量策略和线程安全场景的取舍
6.1 为什么标准ArrayList满足不了所有场景
现实中有些场景需要更灵活的扩容策略。比如一个长期存活的大数组,数据量从几万慢慢涨到几亿,如果仍然用1.5倍扩容,中间产生的旧数组占用的内存和GC压力非常大。这时候你可能会想:能不能在数据量临近边界时小步扩容,在数据量小时大步扩容?
ArrayList本身没给你这个选项,它把容量增长策略写死在grow方法里。真要自定义扩容策略,要么自己造一个类似的动态数组结构,要么结合ensureCapacity按阶段扩容。
6.2 自己实现一个增量可调的动态数组
我以前在项目里自己实现过一个简化版的动态数组,核心逻辑就是让扩容倍率可配置。这里分享一个思路型的示例,帮你理解ArrayList内部机制之后如何扩展:
public class FlexibleArrayList<E> { private Object[] elementData; private int size; private final double growthFactor; public FlexibleArrayList(int initialCapacity, double growthFactor) { if (initialCapacity < 0) { throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); } this.elementData = new Object[initialCapacity]; this.growthFactor = growthFactor; } private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = (int) (oldCapacity * growthFactor); if (newCapacity < minCapacity) { newCapacity = minCapacity; } elementData = Arrays.copyOf(elementData, newCapacity); } public boolean add(E e) { if (size == elementData.length) { grow(size + 1); } elementData[size++] = e; return true; } }注意几个实现要点:
newCapacity = (int) (oldCapacity * growthFactor)这里把浮点数直接截断,如果你传1.5,逻辑上和JDK的位运算效果接近。- 初始容量为0时,
oldCapacity * growthFactor永远是0,所以一定要在grow里判断if (newCapacity < minCapacity),否则列表永远无法扩容。 - 真实的ArrayList还需要处理
modCount、fail-fast和删除逻辑,自定义实现时别只盯着add。
6.3 ArrayList的线程安全到底怎么选
ArrayList扩容机制本身是线程不安全的。有并发写入时,两个线程同时发现容量不足,可能都触发grow,导致其中一个线程的add结果丢失,甚至数组被越界覆盖。
常见的替代方案有三类:
| 方案 | 适用场景 | 说明 |
|---|---|---|
| Vector | 全是历史包袱的存量代码 | 方法加synchronized,全局锁粒度太大,不推荐新代码 |
| Collections.synchronizedList | 对性能要求不高的场景 | 每个方法粒度加锁,迭代时仍需手动同步 |
| CopyOnWriteArrayList | 读多写少、写时复制 | 每次写都复制整个数组,扩容时复制代价极高,不适合大列表频繁写 |
| 外部加锁 | 业务并发度可控 | 在add/remove外层包synchronized或ReentrantLock,最灵活 |
我自己的实践是:并发写场景尽量不用ArrayList,优先考虑并发容器;如果必须用,就单独维护一个ReentrantLock,并且预估好容量,尽量减少扩容带来的锁内耗时。
6.4 对ArrayList的未来:从源码变化看设计取舍
到了最近的JDK版本,ArrayList的底层实现基本稳定,核心仍是动态数组加System.arraycopy。它不像HashMap那样有树化、红黑树之类的复杂结构,也没有ConcurrentHashMap那样的分段锁机制。这种简单直接的设计,让它成为日常开发里最可靠的集合之一。但简单不等于没门槛,扩容机制里这些边界条件、性能取舍和隐藏的GC成本,恰恰是决定你是否能把ArrayList用得高效的关键。
我在自己的项目规范里加了一条规则:凡是批量场景里涉及大量add操作,必须先问三个问题——数据量能否预估,扩容次数能否控制,并发写入是否存在。这三个问题想清楚,再用ArrayList,基本不会再踩扩容的坑。
最后再分享一个小技巧:如果你在排查某个诡异的多线程问题,记得看看有没有线程在ArrayList扩容瞬间交替add。两个线程同时ensureCapacityInternal,其中一个可能拿到已经扩容后的数组引用,但size却没有同步更新,这种bug很难复现,但一旦发生,数据错乱得极其隐蔽。我后来写了个小的线程压测用例,专门复现扩容并发场景,建议你也试试。