bigywater.tech
Java 内部原理 · 05

ArrayList 扩容:5,000,000 次 add,不预设容量真实多搬运了 1230 多万个元素

"能预估数据量就在 new ArrayList<>(n) 里指定初始容量"是句很常见的建议,但很少有人给过具体数字。真实反射进 ArrayList 的私有数组字段,真实扫出扩容公式,再真实跑 500 万次 add,把这条建议值多少钱量化出来。

×1.5
真实扫描 200 次 add,验证出的扩容公式是旧容量 + 旧容量右移一位
34 次 / 0 次
500 万次 add,不预设容量真实重新分配 34 次数组,预设容量真实 0 次

上一篇:volatile:为什么能保证可见性却不能保证 i++ 安全

真实验证:空 ArrayList 共享同一个哨兵数组

刚 new 出来的 ArrayList,并没有马上分配任何容量。

和 HashMap 类似,ArrayList 底层用一个私有数组 elementData 存元素,反射读出来看看刚创建时是什么样:

真实实测
ArrayList<Integer> list = new ArrayList<>();
Object[] arr = (Object[]) elementDataF.get(list);
刚 new 出来,未 add 任何元素: elementData.length = 0 elementData 是不是同一个共享的空数组(和另一个空 ArrayList 比较): true

不只是长度为 0,两个完全独立的空 ArrayList 反射出来的 elementData 引用是同一个对象——JDK 内部有一个静态的空数组常量,所有刚创建、还没 add 过的 ArrayList 都先指向这同一份空数组,真正需要空间时才各自分配自己的数组,不会在创建阶段就浪费内存。

真实验证:扫描出扩容公式,不是查文档抄的

从第一次 add 开始,连续扫描 200 次,记录每次容量变化的瞬间。

真实实测
size=1 触发扩容 -> capacity = 10
size=11 触发扩容 -> capacity = 15  (从 10 扩到 15, 比例 1.500)
size=16 触发扩容 -> capacity = 22  (从 15 扩到 22, 比例 1.467)
size=23 触发扩容 -> capacity = 33  (从 22 扩到 33, 比例 1.500)
size=34 触发扩容 -> capacity = 49  (从 33 扩到 49, 比例 1.485)
size=50 触发扩容 -> capacity = 73  (从 49 扩到 73, 比例 1.490)
size=74 触发扩容 -> capacity = 109  (从 73 扩到 109, 比例 1.493)
size=110 触发扩容 -> capacity = 163  (从 109 扩到 163, 比例 1.495)
size=164 触发扩容 -> capacity = 244  (从 163 扩到 244, 比例 1.497)

第一次 add 直接从 0 跳到 10(默认初始容量),不是先分配 1 再逐步翻倍。之后每次比例都在 1.5 附近浮动,不是精确的 1.5——因为源码里用的是位运算,不是浮点乘法:

java.util.ArrayList · 扩容核心公式 int newCapacity = oldCapacity + (oldCapacity >> 1);

oldCapacity >> 1 是整数右移一位,等价于除以 2 再向下取整。旧容量是奇数时(比如 15),右移得到 7 而不是 7.5,新容量是 15+7=22,实际比例只有 1.467,不是精确的 1.5——这也是为什么真实扫描出来的比例会在 1.467~1.5 之间跳,而不是每次都严丝合缝的 1.5。

真实验证:500 万次 add,两种写法差多少

不测容易受机器负载干扰的墙钟时间,直接数底层数组真实被重新分配了几次、搬运了多少个元素引用。

每次扩容,JDK 都要用 Arrays.copyOf 把旧数组的全部内容整体搬进新数组——这一步的成本跟旧数组当时有多大成正比。用反射跟踪 elementData 的引用变化,统计真实发生了几次重新分配、累计搬运了多少个元素引用:

真实实测 · 添加 5,000,000 个元素
不预设容量: 底层数组真实重新分配了 34 次,累计真实搬运了 12306801 个元素引用
预设容量  : 底层数组真实重新分配了 0 次,累计真实搬运了 0 个元素引用
不预设容量的版本,光是"重新分配数组、搬运旧内容"这一件事,累计搬运的元素引用数(12,306,801)比实际添加的元素总数(5,000,000)还多出一倍还不止 预设容量的版本,从头到尾只分配一次数组,后续 500 万次 add 全部是原地写入,没有一次多余的搬运

之所以搬运总量会超过元素总数,是因为同一批元素在不同阶段的扩容里被反复搬过:第一批元素在容量还是 10 的时候就存在了,后面每扩容一次,这批元素都要跟着新加入的元素一起被整体拷贝一遍——越早添加的元素,被反复搬运的次数越多。

这也是为什么 ArrayList(int) 构造函数值得用——不是玄学层面的"最佳实践",是真实可以数出来的成本:能大致预估最终数据量时,new ArrayList<>(预估值) 能让底层数组从一开始就是最终大小,把这部分本可避免的搬运成本直接归零。

分步演示:ArrayList 内部数组是怎么真实一步步长大的

每一行都是真实用反射读取内部数组长度得到的结果。

用反射真实读取 ArrayList 内部数组长度:默认容量延迟到第一次add才分配,扩容真实按1.5倍而不是2倍
这一步新验证的容量

    参考与说明

    • 本文空数组共享、扩容公式、重新分配次数与搬运总量均在本机真实运行 Java 17(反射需要 --add-opens java.base/java.util=ALL-UNNAMED)得到,没有凭公式推算编造数字。
    • 同样是"预分配容量能省真实成本"的例子,参见HashMap 扩容那篇——两者的扩容触发条件和公式不一样(HashMap 是 2 倍、ArrayList 是 1.5 倍),但"扩容本质是搬一次旧数据"这件事是相通的。
    • 没有涉及:ArrayList 扩容触发 Integer.MAX_VALUE 附近的边界处理、trimToSize() 的收缩机制——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号