ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java集合-02-ArrayList源码:扩容、System.arraycopy 与 fail-fast

Java集合-02-ArrayList源码:扩容、System.arraycopy 与 fail-fast 1. 结论先行ArrayList 本质是动态数组ArrayList 是 Java 集合框架中最常用的 List 实现之一其底层本质是一个可动态扩容的对象数组。它之所以查询快、增删慢根源就在于这个数组结构数组支持按下标 O(1) 随机访问但中间插入和删除需要整体挪动元素。一句话总结ArrayList 数组 扩容机制 迭代器保护机制。理解这三个部分就理解了 ArrayList 的核心。本文从源码角度拆解 ArrayList 的扩容、System.arraycopy 挪位和 fail-fast 机制并配图说明帮助你把源码读透。2. 核心字段先看 ArrayList 的几个关键字段它们是理解后续所有逻辑的基础。// 默认初始容量 private static final int DEFAULT_CAPACITY 10; // 空数组无参构造时使用 private static final Object[] EMPTY_ELEMENTDATA {}; // 默认容量空数组懒加载时使用 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; // 真正存储元素的数组 transient Object[] elementData; // 元素个数 private int size; // 结构性修改次数fail-fast 核心 protected transient int modCount 0;这里有两个容易混淆的空数组EMPTY_ELEMENTDATA用于指定容量为 0 的构造DEFAULTCAPACITY_EMPTY_ELEMENTDATA用于无参构造。两者的区别在于无参构造的数组在第一次 add 时会扩容到默认容量 10而指定容量 0 的数组则按 0 容量起步。3. 构造方法ArrayList 提供了三个构造方法分别对应不同的初始化场景。3.1 无参构造public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }无参构造只是把 elementData 指向一个空数组并没有真正分配 10 个容量的空间。这就是懒加载容量 10 的数组在第一次 add 时才真正创建。3.2 指定容量构造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); } }指定容量大于 0 时直接创建对应大小的数组等于 0 时使用空数组小于 0 时抛出异常。3.3 传入集合构造public ArrayList(Collection? extends E c) { Object[] a c.toArray(); if ((size a.length) ! 0) { if (c.getClass() ArrayList.class) { elementData a; } else { elementData Arrays.copyOf(a, size, Object[].class); } } else { elementData EMPTY_ELEMENTDATA; } }传入集合时直接把集合元素拷贝到新数组。如果传入的本身就是 ArrayList则直接复用其内部数组不复制元素否则通过 Arrays.copyOf 复制。4. 添加元素添加元素是 ArrayList 最核心的操作之一分为尾部追加和指定位置插入两种。4.1 add(E)尾部追加public boolean add(E e) { ensureCapacityInternal(size 1); // 确保容量足够 elementData[size] e; // 赋值并 size return true; }尾部追加的逻辑很简单先确保容量够用然后在下标 size 处赋值最后 size 自增。整个过程是 O(1) 摊还复杂度。4.2 add(int, E)指定位置插入public void add(int index, E element) { rangeCheckForAdd(index); // 越界检查 ensureCapacityInternal(size 1); // 关键把 index 及之后的元素整体后移一位 System.arraycopy(elementData, index, elementData, index 1, size - index); elementData[index] element; size; }指定位置插入需要先把 index 之后的元素整体后移一位再在 index 处赋值。这个挪位操作是 O(n) 的正是 ArrayList 中间插入慢的根本原因。下面用图说明 System.arraycopy 的挪位过程flowchart LR A[原数组: [A, B, C, D, E]] -- 在 index2 插入 X -- B[System.arraycopy 把 C,D,E 后移] B -- C[后移结果: [A, B, C, C, D, E]] C -- 在 index2 赋值 X -- D[最终: [A, B, X, C, D, E]]5. 扩容机制扩容是 ArrayList 最值得深入的部分也是面试高频考点。5.1 懒加载第一次 add 才扩到 10无参构造创建的 ArrayList 初始指向空数组第一次 add 时才真正分配容量 10 的数组。这就是懒加载也是面试中容易踩坑的点。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); } }当 elementData 还是默认空数组时minCapacity 会被提升到 DEFAULT_CAPACITY10从而在第一次 add 时扩容到 10。5.2 grow()1.5 倍扩容private void grow(int minCapacity) { int oldCapacity elementData.length; // 新容量 旧容量 旧容量右移一位 旧容量的 1.5 倍 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); }扩容的核心公式是newCapacity oldCapacity (oldCapacity 1)即每次扩容为原来的 1.5 倍。例如 10 扩容到 1515 扩容到 2222 扩容到 33。下面用图说明 1.5 倍扩容过程flowchart LR A[容量 10 已用 10] -- add 第 11 个元素 -- B[grow() 计算 newCapacity 10 5 15] B -- Arrays.copyOf 拷贝 -- C[新数组容量 15 旧元素全部搬入] C -- 继续 add -- D[容量 15 用满后 再扩到 22]5.3 MAX_ARRAY_SIZE 与 OutOfMemoryErrorprivate static final int MAX_ARRAY_SIZE Integer.MAX_VALUE - 8; private static int hugeCapacity(int minCapacity) { if (minCapacity 0) { throw new OutOfMemoryError(); // 溢出 } return (minCapacity MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }当扩容后的容量超过 MAX_ARRAY_SIZEInteger.MAX_VALUE - 8时会尝试使用更大的容量如果 minCapacity 已经溢出为负数则抛出 OutOfMemoryError。MAX_ARRAY_SIZE 预留 8 个位置是为了容纳对象头等 JVM 开销。6. 删除元素删除元素同样涉及数组挪位是 O(n) 操作。6.1 remove(int)按下标删除public E remove(int index) { rangeCheck(index); modCount; E oldValue elementData(index); int numMoved size - index - 1; if (numMoved 0) { // 把 index 之后的元素整体前移一位 System.arraycopy(elementData, index 1, elementData, index, numMoved); } elementData[--size] null; // 置空帮助 GC return oldValue; }按下标删除时把 index 之后的元素整体前移一位然后把最后一个位置置空并 size 减一。置空操作是为了让 GC 可以回收不再引用的对象。6.2 remove(Object)遍历查找后删除public boolean remove(Object o) { if (o null) { for (int index 0; index size; index) { if (elementData[index] null) { fastRemove(index); return true; } } } else { for (int index 0; index size; index) { if (o.equals(elementData[index])) { fastRemove(index); return true; } } } return false; }按对象删除时先遍历数组找到目标元素再调用 fastRemove 删除。这里用 equals 比较所以自定义对象需要正确重写 equals 方法。6.3 fastRemove跳过越界检查的快速删除private void fastRemove(int index) { modCount; int numMoved size - index - 1; if (numMoved 0) { System.arraycopy(elementData, index 1, elementData, index, numMoved); } elementData[--size] null; }fastRemove 与 remove(int) 逻辑相同只是跳过了越界检查因为调用方已经确认 index 合法。7. 查询与修改查询和修改是 ArrayList 的优势所在因为数组支持按下标随机访问。public E get(int index) { rangeCheck(index); return elementData(index); // 直接按下标取 } public E set(int index, E element) { rangeCheck(index); E oldValue elementData(index); elementData[index] element; return oldValue; }get 和 set 都是直接通过下标访问数组元素时间复杂度为 O(1)。这正是 ArrayList 查询快的原因不需要像链表那样从头遍历。8. 迭代器与 fail-fast迭代器是 ArrayList 中另一个高频考点尤其是 fail-fast 机制。8.1 Itr 的核心字段private class Itr implements IteratorE { int cursor; // 下一个要返回的元素下标 int lastRet -1; // 上一次返回的元素下标-1 表示没有 int expectedModCount modCount; // 期望的结构修改次数 }Itr 维护三个关键字段cursor 记录下一个元素位置lastRet 记录上一次返回位置expectedModCount 记录创建迭代器时的 modCount。8.2 checkForComodificationfail-fast 核心final void checkForComodification() { if (modCount ! expectedModCount) { throw new ConcurrentModificationException(); } }每次调用 next() 或 remove() 时都会检查 modCount 是否等于 expectedModCount。如果期间发生了结构性修改如 add、remove、clearmodCount 会变化从而抛出 ConcurrentModificationException。下面用流程图说明 fail-fast 机制flowchart TD A[创建迭代器 expectedModCount modCount] -- B[调用 next()] B -- C{modCount expectedModCount?} C -- 是 -- D[正常返回元素] C -- 否 -- E[抛出 ConcurrentModificationException] D -- B8.3 为什么 for-each 中删除会抛异常for-each 底层就是使用迭代器遍历。如果在遍历过程中调用 list.remove()会修改 modCount导致迭代器检测到 modCount 与 expectedModCount 不一致从而抛出 ConcurrentModificationException。// 这段代码会抛 ConcurrentModificationException for (String s : list) { if (s.equals(b)) { list.remove(s); // 直接调用 list.removemodCount 变化 } }8.4 正确删除方式正确的删除方式有两种使用 Iterator.remove() 或使用 removeIf。// 方式一Iterator.remove() IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (s.equals(b)) { it.remove(); // 会同步更新 expectedModCount } } // 方式二removeIfJDK 8 list.removeIf(s - s.equals(b));Iterator.remove() 之所以安全是因为它在删除后会同步更新 expectedModCount保持与 modCount 一致。removeIf 内部也做了同样的处理。9. subList 视图坑subList 返回的是原 List 的视图而不是独立副本这是一个容易踩坑的地方。ListString sub list.subList(0, 3); // 对 sub 的任何结构性修改都会反映到原 list 上 sub.add(x); // 原 list 也会多一个元素更危险的是如果 subList 创建后原 list 发生了结构性修改再操作 subList 会抛出 ConcurrentModificationException。因为 subList 内部也维护了 expectedModCount。10. 复杂度总结与使用场景下表总结了 ArrayList 各操作的时间复杂度操作时间复杂度说明get(index)O(1)数组按下标随机访问set(index, e)O(1)数组按下标赋值add(e) 尾部追加O(1) 摊还扩容时 O(n)但均摊 O(1)add(index, e)O(n)需要挪动元素remove(index)O(n)需要挪动元素remove(Object)O(n)先遍历查找再挪位contains(Object)O(n)线性遍历适用场景频繁按下标查询、尾部增删、元素数量可预估的场景。不适用场景频繁在中间插入/删除、需要频繁按值查找的场景此时应考虑 LinkedList 或 HashMap。11. 面试题速答最后整理几个高频面试题帮助快速复习。Q1默认容量是多少什么时候初始化默认容量是 10但无参构造并不会立即创建容
返回列表