☰
从零实现 MiniArrayList 吃透扩容机制
2026/10/22 2:29:44 网站建设 项目流程

缘起:反射被拦了,那就自己写一个

事情的起因很简单:我想知道ArrayList到底在什么时候扩容、每次扩多大。于是写了段反射代码,去读它内部的elementData数组长度:

Field f = ArrayList.class.getDeclaredField("elementData"); f.setAccessible(true); return ((Object[]) f.get(list)).length;

在 JDK 8 时代这能跑。但在我现在用的 JDK 17 上,直接抛了:

java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.lang.Object[] java.util.ArrayList.elementData accessible: module java.base does not "opens java.util" to unnamed module

原因是 JDK 9 引入模块系统后,java.base模块默认不对外开放反射访问私有成员,JDK 16 起强制封装。加启动参数--add-opens java.base/java.util=ALL-UNNAMED能绕过,但这终究是“偷看”,依赖字段名,将来 JDK 改实现就失效。

于是换了个思路:与其窥探真实的 ArrayList,不如自己写一个 MiniArrayList,把扩容逻辑原原本本摊开。想扩多大、什么时候扩、怎么复制,全部自己控制,还能暴露一个capacity()方法随时观测。

这一写,从第一版 46 分写到 92 分,改的却几乎不是“逻辑”,而是一堆认知盲区。下面一个个说。


地基:size 和 capacity 是两回事

写之前必须先把这两个概念钉死,否则后面全乱。

概念字段含义
size(大小)private int size实际装了几个元素
capacity(容量)elementData.length底层数组能装几个,不扩容的前提下

它们的关系永远是size <= capacity。举例:new ArrayList<>()之后 size 是 0;add了 3 个元素后 size 是 3,但 capacity 是 10(数组长度)。

只有size == capacity时,下一次 add 才会触发扩容。其余时候 add 就是往elementData[size]里写一下、size++,O(1)。

顺带一个有意思的点:真实的ArrayList根本不提供capacity()方法。容量是内部实现细节,官方不希望外部依赖它。所以"看容量"这件事,只有自己写实现、或者靠调试器/反射才能做到。这也正说明了封装的意义——调用方关心size(),实现者才关心容量。


第一个坑:new Object[0]不是null

我最开始的构造方法是空的:

public MiniArrayList() { } // 什么都没做

字段private Object[] elementData;默认是null。然后add第一行:

if (size == elementData.length) { // elementData 是 null → NullPointerException

直接 NPE。

这里我犯了一个典型的概念混淆:把"懒加载"理解成了"什么都不做"。真正的懒加载是先给一个长度为 0 的空数组,不是给null。null和"长度 0 的数组"完全是两回事——前者调用任何方法、访问任何字段都会 NPE,后者是个合法的、长度为 0 的对象。

三种写法对比:

if (size == elementData.length) { // elementData 是 null → NullPointerException

为什么 JDK 要用 ③?因为绝大多数ArrayList创建了却根本不放东西,每个实例都new Object[0]会多创建一堆无用的空数组对象。共享一个静态空数组,省内存。行为上 ② 和 ③ 完全一样,但 ③ 更符合工程习惯。这是评分里扣掉的那 2 分。


核心:扩容为什么是oldCap + (oldCap >> 1)

写对了懒加载,扩容逻辑其实就几行:

if (size == elementData.length) { int oldCapacity = elementData.length; int newCapacity = (oldCapacity == 0) ? DEFAULT_CAPACITY // 10 : oldCapacity + (oldCapacity >> 1); // 1.5 倍 elementData = Arrays.copyOf(elementData, newCapacity); }

为什么是 1.5 倍

扩容倍数是"时间"和"空间"的折中。数组扩容 = 新建数组 + 全量复制,成本 O(n)。倍数选多大,直接决定两件事:扩容频率、空间浪费。

倍数扩容频率空间浪费结论
+1极高,几乎每次 add 都复制几乎不浪费❌ 总成本 O(n²),灾难
1.5 倍较低最多浪费约 1/3✅ Java 的选择
2 倍更低最多浪费约 1/2⚠️ C++vector常用
3 倍以上极低浪费巨大❌ 内存吃不消

1.5 倍是经验上的平衡点:扩容次数少(比 +1 好太多),浪费可控(比 2 倍省内存)。对应的容量序列是:

text

0 → 10 → 15 → 22 → 33 → 49 ...

增长平缓,不像 2 倍那样迅速膨胀到几十个空槽。

为什么用>> 1而不是/ 2或* 1.5

先说>> 1是什么:把二进制位整体右移一位,对正整数等于除以 2 向下取整。

text

10 的二进制:1010 >> 1: 0101 = 5

10 >> 1 = 5,15 >> 1 = 7,22 >> 1 = 11。所以oldCapacity + (oldCapacity >> 1)就是"旧容量 + 旧容量的一半",即 1.5 倍。

三种写法的对比:

  • oldCap + (oldCap >> 1):JDK 源码写法,全程 int 运算,无溢出风险。

  • oldCap + oldCap / 2:结果完全等价,现代 JIT 会把/2优化成移位,实际性能无差别。只是不符合"源码写法"这个规格。

  • (int)(oldCap * 1.5):结果也等价,但有三个问题——多一次浮点运算;oldCap接近Integer.MAX_VALUE时(int)转换会溢出;不合规格。

我第一版用的就是* 1.5,逻辑对、结果对,但评分项叫"公式用源码写法",只给了一半分。这让我第一次意识到:行为等价 ≠ 合规格。


第二个坑(致命):oldCap + oldCap >> 1

从* 1.5改到移位写法后,我写成了这样:

int newSize = oldSize == 0 ? 10 : (int) (oldSize + oldSize >> 1);

看起来没问题,跑起来第 11 次 add 直接数组越界。

原因在运算符优先级。Java 里+是第 4 级,>>是第 5 级,数字越小优先级越高。所以:

oldSize + oldSize >> 1

被编译器解析成:

(oldSize + oldSize) >> 1

而不是我想要的:

oldSize + (oldSize >> 1)

结果天差地别:

oldSize我想要的oldSize + (oldSize>>1)实际算出(oldSize+oldSize)>>1
101510
152215
223322

newSize 恒等于 oldSize——扩容根本没扩大。第 11 次 add 时size == length,进扩容分支,copyOf后数组长度还是 10,然后elementData[10] = e越界崩溃。

修复就是加括号:

int newCapacity = oldCapacity == 0 ? DEFAULT_CAPACITY : oldCapacity + (oldCapacity >> 1);

顺手把多余的(int)去掉——oldCapacity和>> 1都是 int,结果本来就是 int。

这条教训我记了很久:混合运算符时,不确定就加括号。括号不加,逻辑再对也白搭。而且加括号不仅保证正确,还让读代码的人一眼看懂意图。


扩容真正的落点:Arrays.copyOf做了什么

java

elementData = Arrays.copyOf(elementData, newCapacity);

这一行是整个grow真正"扩容"的地方。前面算的oldCapacity、newCapacity只是长度,数组本身还没变。

Arrays.copyOf(original, newLength)内部干三件事:

  1. 新建一个长度为newLength的新数组

  2. 把老数组内容复制到新数组前面,多出来的槽保持默认值null

  3. 返回新数组

然后elementData = ...把字段引用从老数组切换到新数组,老数组没人指向了,交给 GC 回收。

为什么不能原地变长?因为 Java 数组长度在创建时就固定,length是 final,永远不能改。所谓"扩容"永远是"申请一块更大的内存 + 搬数据 + 换引用"。

这也解释了为什么触发扩容的那次 add 是 O(n)——copyOf要逐个复制所有已有元素。均摊下来 add 是 O(1),但某一次可能很慢。所以实际开发中,已知数据量时用new ArrayList<>(容量)预设容量能避免反复搬家,这是最常见的性能优化之一。


复盘:从 46 分到 92 分,改的不是逻辑

第一版评分 46 分,我自己都很意外——逻辑我明明写对了。看评分表才明白,扣分全在"没自证"和"不合规格"上:

失分项扣分教训
缺 main + 打印-40代码要能自证,功能对不对要有可运行的证据
浮点乘-10行为等价 ≠ 合规格
每实例 new 空数组-2性能细节也是考点
异常无消息-2可调试性也是质量
raw type-3丢泛型信息,编译期检查失效

逐条说下后两个:

异常无消息。我最初写的是throw new IndexOutOfBoundsException(),抛对了,但调试时看到这个异常不知道是哪个 index 越界、size 多大。改成带上下文:

java

throw new IndexOutOfBoundsException("索引越界: " + index + ", size=" + size);

raw type。我在 main 里写的是MiniArrayList list = new MiniArrayList();,左边没写泛型。这会让编译器放弃所有泛型检查,list.add("字符串")也能编译通过,运行时才可能炸。正确写法:

java

MiniArrayList<Integer> list = new MiniArrayList<>();

有意思的是,正因为我在get里用了(E)强转 +@SuppressWarnings("unchecked"),评分方才提醒:一半的 unchecked 警告其实是 raw type 带来的。

加分项:我自己加了个capacity()方法方便观察容量,被评了超纲加分。这也印证了前面说的——真实ArrayList不暴露容量,自己写实现才能观测,封装的意义就在这。


顺带:ArrayList 的 10 vs HashMap 的 16

写这个类时复习到一组极易混的数字:

ArrayListHashMap
默认容量1016(1 << 4)
扩容倍数1.5 倍2 倍(必须是 2 的幂)
为什么这样经验值2 的幂才能用(n-1) & hash代替%定位桶
负载因子无0.75,阈值16 × 0.75 = 12,第 13 个元素扩到 32

HashSet底层就是HashMap,默认容量也是 16。另外new HashMap<>(0)实际容量是 1(构造时向上取整到 2 的幂),而new ArrayList<>(0)就是 0,两者行为不同。


完整代码

import java.util.Arrays; public class MiniArrayList<E> { private static final int DEFAULT_CAPACITY = 10; private static final Object[] EMPTY_ELEMENTDATA = {}; private Object[] elementData; private int size; public MiniArrayList() { elementData = EMPTY_ELEMENTDATA; } public boolean add(E e) { if (size == elementData.length) { int oldCapacity = elementData.length; int newCapacity = (oldCapacity == 0) ? DEFAULT_CAPACITY : oldCapacity + (oldCapacity >> 1); System.out.println("扩容:" + oldCapacity + " -> " + newCapacity); elementData = Arrays.copyOf(elementData, newCapacity); } elementData[size++] = e; return true; } public int size() { return size; } public int capacity() { return elementData.length; } @SuppressWarnings("unchecked") public E get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("索引越界: " + index + ", size=" + size); } return (E) elementData[index]; } public static void main(String[] args) { MiniArrayList<Integer> list = new MiniArrayList<>(); for (int i = 0; i < 23; i++) { list.add(i); System.out.println("size=" + list.size() + ", capacity=" + list.capacity()); } } }

运行输出:

扩容:0 -> 10 size=1, capacity=10 ... 扩容:10 -> 15 size=11, capacity=15 ... 扩容:15 -> 22 size=16, capacity=22 ... 扩容:22 -> 33 size=23, capacity=33

扩容只发生在 size 追上 capacity 的那一刻,0 → 10 → 15 → 22 → 33,和真实ArrayList完全一致。


写在最后

这个 MiniArrayList 很简单,核心就四个东西:Object[]字段、size变量、1.5 倍扩容公式、越界检查。但写的过程暴露的问题远比代码本身有价值:

  1. size 和 capacity 混淆→ 概念不清,后面全错

  2. new Object[0]≠null→ 懒加载不是"什么都不做"

  3. oldCap + oldCap >> 1→ 运算符优先级,逻辑对也崩

  4. * 1.5行为对但不合规格→ 工程里"等价"和"符合规范"是两回事

  5. raw type→ 泛型信息丢失,编译期检查失效

  6. 异常无消息→ 可调试性也是质量

一句话总结这次经历:逻辑正确只是及格线,能自证 + 合规格才是工程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询