资讯详情

ArrayList扩容机制深度拆解:源码、数学与工程实践

📅 2026/10/10 4:39:40 | 华诺云谱 👁 阅读
ArrayList扩容机制深度拆解:源码、数学与工程实践
1. 一次被 System.arraycopy 卡住的排查扩容到底发生了什么我曾不止一次在别人的线程栈里看到这样的画面业务代码迟迟不返回堆栈却压在一行System.arraycopy上往上翻几帧要么是HashMap.resize要么就是ArrayList.grow。如果那是一个用来存配置项的ArrayList那多半就是扩容机制在作祟了。ArrayList 扩容机制本质上回答一个非常朴素的问题数组是定长的为什么 ArrayList 能一直往里塞数据答案也简单——它会在数组装不下的时候重新开辟一块更大的数组把旧数据整体搬过去然后丢掉旧数组。这个过程就是扩容。网上讲这个机制的文章很多但大部分只停留在“容量不够就 1.5 倍扩容”这个结论上很少有人把以下三件事讲透扩容的完整触发链路是什么样的add()方法内部到底做了几次判断为什么偏偏是 1.5 倍而不是 2 倍、不是固定加 10扩容除了复制数组还隐藏着哪些和 GC、迭代器、并发相关的连锁反应。这篇文章我会从源码、数学账和工程实战三个角度把 ArrayList 扩容机制彻底拆开。无论你是刚学集合框架的初学者还是写了好几年业务代码但没认真看源码的开发者都应该能从中拿到一些可以直接用在排障和性能优化里的东西。1.1 先搞清楚一个前提ArrayList 底层的数组不是“自动变长”的很多人对“动态数组”这个词有误解以为 ArrayList 内部有一个会自己伸长的特殊结构。实际上它内部就是一个普通的Object[]初始可能是空数组也可能是一块指定大小的连续内存。数组一旦创建长度就固定了Java 里没有任何语法能直接修改一个数组的长度。ArrayList 能“动态”靠的是替换当内部数组的长度不够容纳新元素时它会在堆上创建一个新的、更长的数组然后把旧数组里的元素逐个复制过去最后把内部引用指向新数组。旧数组失去引用后会被 GC 回收。这里有一个很关键但容易被忽略的细节扩容只会发生在“新增元素”的时候。set()修改已有元素不会触发扩容remove()、clear()也不会让数组缩回去。你删掉了一万个元素底层的Object[]依然保持着原来的长度内存并不会自动释放。真正能让容量收缩的是trimToSize()这个方法把数组长度裁剪到和元素个数一致后面我会专门提它。1.2 容量capacity和大小size是两回事在聊触发条件之前必须把两个概念拎清楚size是 ArrayList 里实际存放的元素个数capacity是底层Object[]数组的长度也就是“当前最多能装多少个元素而不扩容”。size永远小于等于capacity。当size capacity时再调用add()就装不下了必须扩容。这个判断看起来简单但不同 JDK 版本里它的位置和写法一直在变。JDK 8 的add()是这样的public boolean add(E e) { ensureCapacityInternal(size 1); // Increments modCount!! elementData[size] e; return true; }它先调用ensureCapacityInternal(size 1)这里的size 1表示“这次 add 之后至少需要的容量”。接着一路调用下去private void ensureCapacityInternal(int minCapacity) { // 如果底层的数组还是默认的空数组那么 minCapacity 会跟默认容量 10 取最大值 if (elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount; // overflow-conscious code if (minCapacity - elementData.length 0) grow(minCapacity); }只有当minCapacity elementData.length时才会真正进入扩容逻辑。换句话说如果当前数组还剩哪怕一个空位add()就是纯粹地往数组里赋值不产生任何额外的数组复制。到了 JDK 17内部实现做了一定重构add()直接判断s elementData.length后调用grow()不再走ensureCapacityInternal那层包装但核心逻辑等价空间够就存不够就长。所以无论你看哪个版本扩容的触发条件都是同一句话——当前真实元素个数追上了数组长度。2. 从 add() 到 grow()扩容调用链的逐行拆解现在我们把 JDK 8 的grow()和newCapacity()完整摆出来逐行看它做了什么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); }这段代码只有五行核心逻辑却浓缩了三个重要决策分支。我拆开说。2.1 位运算oldCapacity 1到底算什么第一行oldCapacity (oldCapacity 1)。是右移一位等价于整除 2。所以新容量的计算是newCapacity oldCapacity oldCapacity / 2 oldCapacity * 1.5这就是“1.5 倍扩容”的直接来源。注意这里用的是整数运算如果oldCapacity是奇数oldCapacity 1会向下取整。举个例子当前容量 1515 7 22当前容量 3333 16 49。所以实际的扩容序列并不是严格的小数乘法而是取整后的结果当前容量新容量增长量1015515227223311334916497324731093610916354163244812443661223665491835498232748231234411为什么用位运算而不是oldCapacity / 2 * 3一方面位运算在 JIT 编译后几乎无成本另一方面源码里这种写法也在提醒你扩容计算是极高频路径上的操作任何多余的开销都会被放大。2.2newCapacity - minCapacity 0兜底逻辑第二个分支是兜底。正常情况下 1.5 倍足以覆盖minCapacity但有三类场景会出现 1.5 倍还不够的情况空数组第一次 add。oldCapacity 0newCapacity 0 0 0而minCapacity至少是 1。如果走 1.5 倍新容量还是 0add 直接失败。所以此时需要强行把newCapacity提到minCapacity。通过ensureCapacity(很大的值)主动扩容比如当前容量 100你直接要求扩到 10000。1.5 倍只有 150远不够。addAll()批量添加大量元素时minCapacity是当前 size 加上集合元素个数这个值可能远超 1.5 倍。这个分支保证了扩容后的容量一定“够用”而不是机械地套 1.5 倍公式产生一个小于需求的容量。2.3MAX_ARRAY_SIZE上限与hugeCapacity()最后一个分支是上限保护。MAX_ARRAY_SIZE是Integer.MAX_VALUE - 8因为某些虚拟机会在数组对象里保留一小段头信息如果申请Integer.MAX_VALUE的数组实际可能会触发OutOfMemoryError。当 1.5 倍后的容量超过这个上限时会进入private static int hugeCapacity(int minCapacity) { if (minCapacity 0) // overflow throw new OutOfMemoryError(); return (minCapacity MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }这里有个很有意思的细节如果minCapacity是负数说明size 1在整数加法里溢出了数组长度不可能为负直接抛OutOfMemoryError。也就是说ArrayList 理论上能容纳的最大元素数量接近Integer.MAX_VALUE而在此之前你很可能已经先触发了堆内存溢出。2.4 顺带看清“第一次 add 变成 10”的反常现象结合ensureCapacityInternal的代码还有一个很多面试题爱问的点new ArrayList()默认构造不放任何元素时底层数组是DEFAULTCAPACITY_EMPTY_ELEMENTDATA一个空数组。第一次add()时elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA为 true于是minCapacity被强制提升为Math.max(10, 1)也就是 10。所以第一次扩容直接生成容量 10 的数组而不是容量 1。为什么要这么设计因为空数组本身不占堆内存如果创建一个 ArrayList 却永远不放元素就不必提前分配 10 个对象槽位。这是一种典型的“延迟分配”思想——把内存开销推迟到真正需要的时候而且第一次直接给默认容量 10避免了头几次 add 反复扩容的尴尬。3. 为什么偏偏是 1.5 倍容量策略背后的时间与空间账本你可能会想从 10 扩到 15再扩到 22次数这么多为什么不直接每次翻倍或者干脆每次固定加 100这里需要算一笔账涉及两个衡量维度扩容次数和总复制量。3.1 扩容次数与总复制量等比数列的均摊分析先看最坏方案——每次固定扩容 1 个容量。假设从容量 1 开始最终要装下 N 个元素那就要扩容 N 次每次复制 1、2、3、……、N 个元素总复制量是1 2 3 ... N N(N1)/2 ≈ O(N²)这种策略的时间复杂度是平方级的数据量大一点就完全不可接受。再看翻倍策略——每次扩容一倍。扩容次数是log2(N)次每次复制量分别是 1、2、4、8、……、N总复制量是1 2 4 ... N 2N - 1 ≈ O(N)均摊到每次 add成本是 O(1)。这是数学上最优的扩容节奏。1.5 倍策略同样是指数增长总复制量依然是等比数列求和复杂度同样是 O(N)。它只是把“公比”从 2 换成了 1.5并不破坏均摊 O(1) 的性质。换句话说只要扩容是按比例增长的时间上都没有问题差别在于“浪费多少空间”。3.2 空间账翻倍浪费 vs 1.5 倍从容我们对比两组扩容序列。假设一路 add 到元素个数刚好超过 10002 倍策略10、20、40、80、160、320、640、1280。数组停在 1280浪费了约 280 个槽位。1.5 倍策略10、15、22、33、49、73、109、163、244、366、549、823、1234。数组停在 1234浪费约 234 个槽位。这个差距在数据量小的时候不明显但当你面对一个装了上千万元素的 ArrayList 时翻倍策略可能白白多出几百万元素容量的内存。1.5 倍让数组在扩容后只富余约 50% 的空间比翻倍的 100% 更收敛。还有一层更隐蔽的考量翻倍策略虽然扩容次数少但单次复制量是 1.5 倍的三分之二处理大数组时一次超大容量的复制在 GC 停顿明显的场景里更容易制造长暂停。1.5 倍把复制拆得更碎每次触达的内存区域更小对垃圾回收更友好。工程上很多语言的标准库都做过类似权衡比如某些 C 标准库实现早期用 2 倍后来也趋向于更小的增长因子。3.3 和其他容量策略的对比找几个我们常见的扩容对象做横向对比结构增长策略增长因子备注ArrayList旧容量 旧容量 / 21.5 倍空间与时间折中HashMap旧容量左移一位2 倍需要配合 2 的幂次取模C std::vector旧容量 * 22 倍部分实现为 2 倍也有 1.5 倍固定增量每次 固定值线性总复制量 O(N²)不适用大列表按需精确分配恰好等于需求变长无浪费但需要预知大小HashMap 走 2 倍有它自己的理由哈希表底层依赖 2 的幂次长度来做位运算取模扩容到刚好两倍可以保持既有元素的位置计算逻辑简单。ArrayList 没有这种约束所以不需要 2 倍。固定增量这种策略几乎只在特定场景出现比如某些实现“分块”思想的数据结构如CopyOnWriteArrayList其实也算整块复制但 ArrayList 本身如果用固定增量性能会随数据量急剧恶化。最后补一个常见误区扩容不是“旧的复制过去就完事”Arrays.copyOf底层调用的是System.arraycopy这是一个native方法。它的复制效率极高但复制过程依然需要遍历每一个引用。哪怕效率再高数据量大时也是实打实的内存搬运开销这段开销是扩容机制永远甩不掉的固有成本。4. 扩容的隐性代价内存复制、modCount 与 GC 连锁反应很多人以为扩容的代价就是一次数组复制其实复制只是最表面的一层。从性能排查的角度看扩容至少带来了三方面代价其中后两点经常被忽略。4.1 System.arraycopy 并非免费System.arraycopy对于基本类型数组和引用数组的处理路径不同但本质上都是一段连续内存的拷贝。它对 CPU 缓存友好比手写 for 循环快很多但快不代表免费。有个很直观的测试思路准备一个容量 10 万的 ArrayList不断 add 到超过 100 万记录整个过程中的耗时另一个预先new ArrayList(1000000)add 同样数量对比两者耗时。实测下来后者往往快出数倍且差距随着数据量增大越来越明显。这还只是单线程场景如果是在多线程并发访问同一个列表当然ArrayList 并不线程安全扩容瞬间的复制操作会放大数据竞争的窗口。4.2 旧数组去哪了GC 压力与晋升问题每次扩容都会让旧数组失去引用。如果你的 ArrayList 里存放的是活得久的对象旧数组在年轻代里快速变成垃圾触发 minor GC如果列表特别大数组可能直接分配到老年代扩容后旧的大数组要等 major GC 才能回收。在高频扩容的场景下这会让 GC 日志里出现明显的“大对象分配”和“老年代增长”曲线。一个容易忽视的经验如果 ArrayList 的使用模式是“启动时一次性加载大量数据之后只读”那默认容量 10 会导致它在加载过程中被迫扩容很多次每次扩容都留下一块旧数组垃圾。与其让 GC 反复打扫不如构造时直接给一个接近预期的容量一次性分配到位。4.3 modCount 引发的 ConcurrentModificationException扩容必然伴随modCount。modCount是 AbstractList 里的一个计数器记录“结构性修改”的次数。ArrayList 的迭代器在创建时会保存当时的expectedModCount每次next()都会校验modCount expectedModCount一旦不等立刻抛ConcurrentModificationException。这意味着你在遍历 ArrayList 的过程中只要触发了扩容其实任何 add/remove 都算不只是扩容当前迭代器就会直接失效。很多线上问题不是并发导致的而是单线程内一边遍历一边 add踩中了这个 fail-fast 机制。解决方式要么用迭代器的remove()方法要么先收集到临时列表遍历结束后统一 addAll。4.4 扩容次数多真的可怕吗关键看“峰值容量”回到开头的问题扩容次数多本身不可怕真正可怕的是扩容带来的“重复复制总量”。我给个更直观的算例。从容量 10 开始一路按 1.5 倍扩容到 1234总共发生了 12 次扩容每次复制的元素数量加总约为10 15 22 33 49 73 109 163 244 366 549 823 2456这个数字大约是峰值容量 1234 的两倍。也就是说为了让一个最终能装 1234 个元素的列表成长起来你额外搬运了大约两倍于最终容量的元素。对于最终只有几十个元素的列表这个成本无所谓但对于百万级以上的列表这多出来的复制量很可能成为启动或批处理流程里的一个性能洼地。5. 工程层面的应对让 ArrayList 尽可能不扩容既然扩容有成本那实际项目里该怎么做我的建议分三个层次从“开箱即用”到“设计选型”按需取舍。5.1 预估容量是最简单也最有效的招如果数据量可预估务必用带初始容量的构造器// 坏默认容量 10塞 100 万条要扩容约 30 次 ListString list1 new ArrayList(); // 好一次性分配足够的空间 ListString list2 new ArrayList(1_000_000);初始容量给多少合适给“你预计的最大 size”就行稍微多点也没关系最多浪费一点内存。宁可多给不要少给因为少给意味着中途还要继续扩容。如果列表在运行过程中要经历多轮填充可以用ensureCapacityListString list new ArrayList(); // 某种业务条件下需要一次性加入大量元素 list.ensureCapacity(10_000); list.addAll(heavyData);ensureCapacity是公开方法内部直接调用ensureExplicitCapacity如果你传入的minCapacity大于当前数组长度就会当场扩容到 max(当前长度, 1.5 倍, 传入值)。注意它不改变 size只是提前把内部数组变大。5.2 addAll 的批量扩容优势如果你用addAll()添加一个集合ArrayList 是以“能一次性装下全部新增元素”为目标来计算容量的所以只会扩容一次而不是逐个 add 那样反复扩。这一点在 JDK 8 的源码里体现得很直接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; }所以与其写for (item : items) list.add(item)不如直接list.addAll(items)。当 items 足够大时后者不仅在容量规划上更合理还少走一轮 Java 层判断。比较一下两种写法逐条 add每次都要跑一遍ensureCapacityInternal一旦size length就触发一次扩容。批量 addAll只做一次容量合计一次System.arraycopy把所有新元素搬进去。数据量为 10 万时两种写法的耗时差距约能到 3 到 5 倍越大量差距越明显。5.3 从设计上绕开扩容如果对列表的使用场景是“一次性填充之后只做遍历或随机读”容器本身可以用数组代替String[] arr new String[100_000];数组没有任何扩容机制也没有额外对象头内存更紧凑遍历性能还略好。如果你需要的是“只读的固定列表”更省心的做法是用Arrays.asList它直接把已有数组包装成 List底层不复制、不扩容。不过要注意Arrays.asList返回的 List 不能增删元素只能set这点我放到下一节仔细说。对于“高频头部插入”的场景ArrayList 扩容反而不是最大问题System.arraycopy做元素挪移才是——往头部插入一条后面所有元素都要后移一位。这种需求应该换LinkedList或者直接用ArrayDeque。扩容机制只是 ArrayList 整体性能画像里的一环选型时要通盘考虑。6. 边界情况MAX_ARRAY_SIZE、OOM 与那些不扩容的“假 ArrayList”最后聊一些容易踩坑的边界情况。源码里那些看起来像是“死代码”的分支往往就对应着某个真实事故。6.1 Integer.MAX_VALUE - 8 是怎么来的OOM 又会发生在哪一步MAX_ARRAY_SIZE Integer.MAX_VALUE - 8源注释写得很清楚有些虚拟机会在数组头里存储对象元数据数组最大长度需要留一点余量。所以 ArrayList 的容量极限并不是Integer.MAX_VALUE而是比它小 8。什么时候会碰到 OOM当你实际申请一个巨大的数组堆内存不够时分配数组这一步就会抛OutOfMemoryError这跟扩容公式无关当size 1因为整数溢出变成负数时hugeCapacity会主动抛 OOM提示是数组长度溢出。所以别尝试new ArrayList(Integer.MAX_VALUE)或者往一个超大的列表里盲目 add等到扩容那一下你往往会同时获得一个 OOM 和一段性能事故报告。6.2 三个“看起来是 ArrayList实际不扩容”的列表现实里很多列表根本不是java.util.ArrayList本体自然也没有扩容机制但代码层面看起来非常像对象底层结构能否 add/remove能否 setnew ArrayList()可变数组可以会扩容可以Arrays.asList(...)固定长度数组视图不可以抛 UnsupportedOperationException可以List.of(...)不可变集合不可以不可以Collections.unmodifiableList(...)包装其他 List不可以不可以很多人写Arrays.asList(A, B, C)后直接 add结果运行时报错一脸懵。因为它返回的Arrays$ArrayList虽然类名里带 ArrayList但add/remove方法压根没实现直接抛异常。它连扩容的机会都没有。List.of是 Java 9 引入的不可变列表它对 null 元素都不允许更别说扩容了。这些“假 ArrayList”的好处是省内存、线程安全但也意味着你必须在创建时就确定好元素内容。6.3 trimToSize 之后还能扩容吗trimToSize()把内部数组裁剪到刚好等于 sizepublic void trimToSize() { modCount; if (size elementData.length) { elementData (size 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } }裁剪之后内部数组长度等于实际元素个数下一次 add 会立即触发扩容。所以trimToSize适合“内存敏感、只读为主”的场合列表创建完不再变压缩掉多余容量释放内存。如果压缩完还要频繁新增那就白压缩了反而多一次全量复制。配合size 0的情况它会把内部数组重置为空数组这样再走一次 add 又会回到默认容量 10 的成长路径上。6.4 经验不要迷信“默认容量 10”最后从实际项目中总结一条经验不要因为默认容量 10 的延迟分配设计很精巧就以为所有场景都适合裸new ArrayList()。这个设计是针对“创建了却很少使用”的对象的优化而不是让你在高吞吐路径上裸奔。凡是你知道数据的量级就应该预先分配。推荐做法是// 不知道精确个数但知道大概率有多少 int estimatedSize ...; // 从业务量、历史数据统计得出 ListItem items new ArrayList(estimatedSize);如果完全没法预估就用addAll做批量填充把扩容次数压到最低。如果你只是想临时转存一下直接用数组或者不可变列表连扩容这个念头都不用有。我个人在实际开发中的体会是ArrayList 扩容机制本身不难难的是把“何时扩容、扩容多贵、怎么躲开它”这套思维内化到每一次写列表的瞬间。扩容是一个典型的“均摊成本低、峰值成本高”的操作当你的系统对延迟敏感时峰值时刻的那一次大数组复制就值得你提前多做一次容量规划。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑