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

发布时间:2026/10/2 15:18:12
ArrayList扩容机制深度解析:源码原理与性能优化实战 凌晨一点四十监控平台突然跳了一连串红色告警我负责的批量导入服务接口平均耗时从平时的80毫秒涨到了1200毫秒。当时第一反应是数据库慢查询或者GC出了问题结果查了一圈线程栈里最扎眼的反而是一行再普通不过的代码ListLong idList new ArrayList();整个导入任务里这个list被不停地add一次导入几十万条数据add期间反复触发ArrayList底层数组的扩容。每次扩容都是一次System.arraycopy旧数组作废变成垃圾等GC回收新数组重新分配。数据量越大这个动作越频繁接口自然就被拖垮了。说实话ArrayList动态扩容机制我在面试里背过无数遍——“默认容量10每次扩容1.5倍”但真正遇到线上性能问题才意识到这几个字背后藏着多少底层逻辑。这篇文章不打算给你念面试答案我想从一个真实踩过坑的开发者视角把扩容机制的源码、计算规则、性能影响、优化手段和常见误区分层拆开讲希望能帮你避开我犯过的错。1. 从一次线上故障说起ArrayList扩容是怎样悄悄拖垮接口的1.1 故障现场无参构造加大量add的组合拳那次故障的代码场景很典型从文件或者接口读取一批ID放进ArrayList里做批量查询。因为事先不确定数据量大家习惯性地写了new ArrayList()然后循环add。像下面这样的写法在业务代码里非常常见ListLong 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倍扩容策略每次扩容前后容量变化是扩容前容量扩容后容量触发时机已存元素数010第一次add1015第11次add1522第16次add2233第23次add3349第34次add649973...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); } }注意几个容易被忽略的细节无参构造创建的ArrayListelementData指向一个共享的空数组此时size为0但elementData.length也是0。第一次add时minCapacity会直接取DEFAULT_CAPACITY和size1的较大值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倍。真正有差异的其实是VectorVecto的扩容策略是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 明确知道数据量时的正确姿势如果你能预估数据量一定要用带初始容量的构造器ListLong ids new ArrayList(expectedSize);这里有个容易翻车的点你以为传入1000它就能存1000条不扩容其实是可以的。因为初始容量就是底层数组长度只要size不超过这个值就不会触发扩容。但如果你传入的是expectedSize 1比如1001反而浪费了一个元素的空间。ArrayList不像HashMap有负载因子的概念不需要额外乘0.75之类直接按预期数据量来就行。4.2 数据量不确定时使用ensureCapacity有些场景数据量确实无法精确预估但你大致知道高峰能到多少。比如批量查询接口单批最多1万条那就可以在接收数据之前调用一下ListLong 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扩容到2222除以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 FlexibleArrayListE { 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很难复现但一旦发生数据错乱得极其隐蔽。我后来写了个小的线程压测用例专门复现扩容并发场景建议你也试试。