首页>Java>正文

ArrayList扩容源码级拆解:1.5倍策略、容量预分配与常见坑

Feng 39 阅读 Java

ArrayList 的扩容逻辑看起来简单,真正踩过坑的人才知道细节不少。下面从触发条件一路拆到容量上限,再聊几个生产环境里容易翻车的地方。

扩容到底什么时候发生

判断条件只有一句:size + 1 > elementData.length。

add 方法先调 ensureCapacityInternal 确认容量,再把元素塞到数组末尾。这是所有添加操作的统一入口。

调用 add 方法
    ↓
size + 1 > elementData.length?
    ↓ 是
触发 grow 扩容
    ↓
计算新容量
    ↓
旧容量 > 0?
    ├─ 是 → 新容量 = 旧容量 + 旧容量 >> 1 (即1.5倍)
    └─ 否 → 新容量 = DEFAULT_CAPACITY (10)
    ↓
新容量仍小于所需最小容量?
    ├─ 是 → 新容量 = 所需最小容量
    └─ 否 → 保持计算值
    ↓
Arrays.copyOf 复制数组
    ↓
新容量 > MAX_ARRAY_SIZE?
    ├─ 是 → 调用 hugeCapacity 处理,上限为 Integer.MAX_VALUE
    └─ 否 → 直接使用新容量

容量确保链分两步走:先处理默认空数组(首次添加时),再判断是否需要真正扩容。

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 确保容量足够
    elementData[size++] = e;            // 添加元素
    return true;
}

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);  // 容量不足时触发扩容
}

modCount 这个字段容易被忽略。它专门用于检测并发修改,迭代器遍历时会比对它的值,一旦发现变化就抛 ConcurrentModificationException。

grow 方法内部做了什么

真正的扩容动作集中在 grow。

新容量公式:oldCapacity + (oldCapacity >> 1),也就是 1.5 倍。

如果算出来的新容量还是不够 minCapacity,就直接采用 minCapacity。最后通过 Arrays.copyOf 建新数组并搬数据。

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);  // 1.5倍扩容
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;  // 新容量仍小于所需最小容量
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);  // 处理容量上限
    elementData = Arrays.copyOf(elementData, newCapacity);  // 复制数组
}

容量逼近上限时还有一层保护:超过 MAX_ARRAY_SIZE(通常是 Integer.MAX_VALUE – 8)后,按实际需要返回 Integer.MAX_VALUE 或直接抛 OOM。

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0)  // 溢出检查
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

1.5 倍是怎么算出来的

是右移,等价于除以 2 向下取整。所以增长序列是:10 → 15 → 22 → 33 → 49。

用几个节点感受一下:

添加次数 当前容量 触发扩容时容量 新容量
1-10 10 11 15
11-15 15 16 22
16-22 22 23 33
23-33 33 34 49

为什么不是 2 倍?1.5 倍是空间和时间之间的折中。倍增浪费空间更明显,1.5 倍省一些;扩容次数虽然更多,但两者均摊时间复杂度都是 O(1)。

构造方法决定初始容量

无参构造初始容量是 0,第一次 add 才扩到 10。指定容量则直接建对应大小的数组,后续不用再折腾。

添加元素 → 检查容量 → 容量足够?
    ├─ 是 → 直接添加
    └─ 否 → 计算新容量 → 复制数组 → 添加元素

扩容前:[e1, e2, e3, ..., e10, null, null, null] (容量10)
扩容后:[e1, e2, e3, ..., e10, null, null, ..., null] (容量15)

构造方法 初始容量 首次扩容目标 适用场景
无参构造 0→10 10 元素数量不确定
指定容量 指定值 指定值 数量基本确定
集合构造 集合大小 集合大小 从已有集合创建

扩容的代价与容量预分配

每次扩容都走 Arrays.copyOf,这是 O(n) 操作。分配新数组、复制旧元素、旧数组等待 GC,三步一个都跑不掉。

频繁扩容的性能损失很直观。能预估数量时,直接指定初始容量最省事。

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

批量添加前也可以调 ensureCapacity 一次性扩到位,避免多次扩容。

public ArrayList(int initialCapacity) {
    if (initialCapacity > 0)
        this.elementData = new Object[initialCapacity];
    else if (initialCapacity == 0)
        this.elementData = EMPTY_ELEMENTDATA;
    else
        throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}

这里补一个实际部署中容易遇到的报错:如果构造 ArrayList 时传入负数,比如 new ArrayList(-1),会直接抛 IllegalArgumentException: Illegal Capacity: -1。根因是构造方法里对 capacity 做了小于 0 的判断。解决方式就是保证传入值非负,或者干脆用无参构造。

旧数组、缩容和批量操作

Arrays.copyOf 执行后,旧数组不再被引用,交给 JVM 的 GC 回收,不用手动处理。

trimToSize 能把容量压到实际元素数量,释放多余内存,但频繁调用会有性能开销,不建议在生产环境当常规操作。

// 不推荐:频繁扩容
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
    list.add("item" + i);  // 多次扩容
}

// 推荐:预分配容量
ArrayList<String> list = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
    list.add("item" + i);  // 无扩容
}

添加大量元素时,几个实用原则:预估容量用指定构造;批量添加用 addAll 而不是循环 add;后续还会继续添加就别急着 trimToSize。

几个高频疑问

最大容量是多少?理论上 Integer.MAX_VALUE(约 21 亿),实际受 MAX_ARRAY_SIZE 限制,通常是 Integer.MAX_VALUE – 8,因为部分 VM 会保留头部信息。

size() 和数组长度为什么不一样?size() 是实际元素数量,elementData.length 是容量。扩容机制决定了容量总是大于等于元素数量。

modCount 为什么增加?每次结构修改(添加、删除、扩容)都会加。迭代器靠它检测并发修改。

ArrayList 和 Vector 扩容区别?ArrayList 是 1.5 倍、非线程安全;Vector 默认 2 倍、方法带 synchronized。Vector 已经过时,需要线程安全时用 Collections.synchronizedList 或 CopyOnWriteArrayList 更合适。

验证扩容行为的实操方法

想确认扩容是否按预期发生,可以开反射读 elementData.length。

public void ensureCapacity(int minCapacity) {
    int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
        ? 0
        : DEFAULT_CAPACITY;
    if (minCapacity > minExpand) {
        ensureExplicitCapacity(minCapacity);
    }
}

判定标准很直接:添加第 11 个元素后容量应为 15,第 16 个元素后应为 22。如果对不上,检查是不是构造时已经指定了初始容量。

一句话收尾

ArrayList 扩容 = 1.5 倍增长 + 数组复制 + 均摊 O(1)。预估容量是性能优化的关键,别等它一次次搬家。

public void trimToSize() {
    modCount++;
    if (size < elementData.length) {
        elementData = (size == 0)
          ? EMPTY_ELEMENTDATA
          : Arrays.copyOf(elementData, size);
    }
}

初始状态:size=0, capacity=10 (首次添加时扩容)
添加10个元素:size=10, capacity=10
添加第11个元素:触发扩容,capacity=15
添加15个元素:size=15, capacity=15
添加第16个元素:触发扩容,capacity=22
...以此类推

Feng
这位作者很神秘,还没有填写简介。