news 2026/10/2 15:18:12

ArrayList扩容机制深度解析:源码原理与性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ArrayList扩容机制深度解析:源码原理与性能优化实战

凌晨一点四十,监控平台突然跳了一连串红色告警,我负责的批量导入服务接口平均耗时从平时的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数组。之后随着元素越来越多,数组会在容量不够时进行扩容。

每一次扩容,都会经历三个步骤:

  1. 计算新容量;
  2. 分配一个新的Object数组;
  3. 把旧数组里的所有元素用System.arraycopy复制到新数组。

1.2 扩容次数比你想象的多

我基于那天的数据量做了个简单的模拟,假设要向ArrayList里插入100万条数据,默认从容量10开始,按JDK 8的1.5倍扩容策略,每次扩容前后容量变化是:

扩容前容量扩容后容量触发时机(已存元素数)
010第一次add
1015第11次add
1522第16次add
2233第23次add
3349第34次add
649973...
545149817723...

大致估算下来,往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很难复现,但一旦发生,数据错乱得极其隐蔽。我后来写了个小的线程压测用例,专门复现扩容并发场景,建议你也试试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 15:17:56

Codex CLI 深度体验:从代码补全到命令行编程代理的进化

1. 从一次更新说起&#xff1a;Codex 到底在往哪个方向走Codex 这个名字对很多人来说并不陌生。早几年它是以代码补全模型的身份出现的&#xff0c;后来逐渐演化成了一个完整的命令行编程代理。这次更新之后&#xff0c;我花了两天时间把新版本从安装到实际跑项目完整过了一遍&…

作者头像 李华
网站建设 2026/10/2 15:17:21

Agent安全实战:从论文攻击面到生产Guardrail落地

干Agent开发这一年多&#xff0c;被问得最多的一个问题就是&#xff1a;"你们家的Agent安全到底是怎么做的&#xff1f;"我每次都得先反问一句&#xff1a;你说的是论文里的Agent安全&#xff0c;还是生产环境里的Agent安全&#xff1f;因为这俩现在几乎处在两个平行…

作者头像 李华
网站建设 2026/10/2 15:16:44

微机原理与接口技术 · 第3章《STM32F1 系列微控制器》知识点梳理

微机原理与接口技术 第3章《STM32F1 系列微控制器》知识点全梳理 本文整理自福州大学《微机原理与接口技术》吴衔誉教授第三章课件&#xff0c;系统讲解 STM32F1 系列简介、系统架构与内部结构、存储器映像、时钟结构、引脚与启动配置、最小系统设计六大板块。 目录 一、STM3…

作者头像 李华
网站建设 2026/10/2 15:15:54

AI编程工具协同新范式:Herdr多路复用消息总线实战解析

先坦白一个现象&#xff1a;我身边越来越多做AI编程的人&#xff0c;电脑上同时装着Claude Code、Cline、Gemini CLI、Codex CLI&#xff0c;还有各种IDE插件&#xff0c;像Cline、Continue、Copilot这种能装的都装。表面上看是"工具多样性"&#xff0c;实际用起来却…

作者头像 李华
网站建设 2026/10/2 15:15:06

Win10家庭版安装CCS7.3被Defender拦截?排除项白名单方案一次搞定

我到现在都还记得第一次在win10家庭版上双击ccs_setup_7.3.0.00019.exe时的情形&#xff1a;图标转了两圈&#xff0c;然后就没然后了。任务管理器里看不到安装进程&#xff0c;安装日志也没生成&#xff0c;折腾了半小时&#xff0c;最后去Windows安全中心的“保护历史记录”里…

作者头像 李华