缘起:反射被拦了,那就自己写一个
事情的起因很简单:我想知道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 |
|---|---|---|
| 10 | 15 | 10 |
| 15 | 22 | 15 |
| 22 | 33 | 22 |
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)内部干三件事:
新建一个长度为
newLength的新数组把老数组内容复制到新数组前面,多出来的槽保持默认值
null返回新数组
然后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
写这个类时复习到一组极易混的数字:
| ArrayList | HashMap | |
|---|---|---|
| 默认容量 | 10 | 16(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 倍扩容公式、越界检查。但写的过程暴露的问题远比代码本身有价值:
size 和 capacity 混淆→ 概念不清,后面全错
new Object[0]≠null→ 懒加载不是"什么都不做"oldCap + oldCap >> 1→ 运算符优先级,逻辑对也崩* 1.5行为对但不合规格→ 工程里"等价"和"符合规范"是两回事raw type→ 泛型信息丢失,编译期检查失效
异常无消息→ 可调试性也是质量
一句话总结这次经历:逻辑正确只是及格线,能自证 + 合规格才是工程。