杰瑞科技汇

ArrayList如何实现动态扩容与线程安全?

  1. ArrayList 的核心概念:它是什么,有什么特点。
  2. 内部数据结构ArrayList 是如何存储数据的。
  3. 核心源码分析:通过阅读源码,理解关键方法(如 add, get, remove)是如何工作的。
  4. 性能分析:不同操作的时间复杂度。
  5. LinkedList 的简单对比:帮助理解何时使用 ArrayList
  6. 代码示例:一个完整的、可运行的示例。

ArrayList 的核心概念

ArrayList 是 Java 集合框架中 List 接口的一个具体实现,它的主要特点是:

ArrayList如何实现动态扩容与线程安全?-图1
(图片来源网络,侵删)
  • 动态数组:它本质上是一个可以自动扩容的数组,与 Java 中固定长度的原生数组([])不同,ArrayList 的大小可以在运行时动态改变。
  • 有序:它维护了元素的插入顺序,你可以通过索引(0-based)来访问元素。
  • 允许重复:可以存储多个 null 值和重复的对象。
  • 非线程安全:如果在多线程环境下并发修改 ArrayList,可能会导致数据不一致或 ConcurrentModificationException,如果需要线程安全的版本,可以使用 Collections.synchronizedList(new ArrayList<>())CopyOnWriteArrayList

内部数据结构

ArrayList 的核心在于其内部维护的两个关键属性:

// 源码中的关键字段 (简化版)
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
private transient Object[] elementData; // 存储元素的数组
private int size; // 当前元素的数量 (不是数组的长度)
  • elementData:这是一个 Object 类型的数组,是 ArrayList 的真正数据容器。transient 关键字表示它不会被序列化。
  • size:这是一个整数,记录了当前 ArrayList 中实际存储了多少个元素,它永远小于或等于 elementData.length
  • DEFAULTCAPACITY_EMPTY_ELEMENTDATA:这是一个空的数组,用于无参构造函数初始化 ArrayList 时使用。

初始化: 当你使用 new ArrayList<>() 无参构造函数创建 ArrayList 时,elementData 会被初始化为 DEFAULTCAPACITY_EMPTY_ELEMENTDATA(一个空数组),size 为 0。


核心源码分析

理解了内部结构后,我们来看几个核心方法的实现。

a. add(E e) - 添加元素

这是 ArrayList 最核心也最复杂的操作,因为它涉及到扩容

ArrayList如何实现动态扩容与线程安全?-图2
(图片来源网络,侵删)
public boolean add(E e) {
    // 1. 确保内部数组有足够的空间,size + 1 是因为新加一个元素
    // size + 1 后超过了数组的长度,就需要扩容
    ensureCapacityInternal(size + 1);
    // 2. 将新元素添加到数组的 size 位置(即当前最后一个元素的下一个位置)
    elementData[size++] = e;
    return true;
}
// ensureCapacityInternal 的实现
private void ensureCapacityInternal(int minCapacity) {
    // 如果是刚创建的空 ArrayList,minCapacity 会是 1
    // 这时会使用 DEFAULT_CAPACITY (10) 作为初始容量
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    // 检查是否需要扩容
    ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
    // minCapacity 大于当前数组的长度,就需要扩容
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}
// grow() 方法是扩容的核心
private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    // 新容量 = 旧容量的 1.5 倍 (位运算更高效)
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 处理溢出情况,以及新容量仍然小于所需最小容量的问题
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    // 如果新容量超过了 MAX_ARRAY_SIZE,则使用一个巨大的值
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 使用 Arrays.copyOf 创建一个新数组,并将旧数组的内容复制过去
    elementData = Arrays.copyOf(elementData, newCapacity);
}

add 操作流程总结

  1. 检查容量:在添加元素前,先判断 size + 1 是否会超出当前数组的长度。
  2. 扩容:如果超出,则进行扩容,扩容策略是容量变为原来的 1.5 倍oldCapacity + oldCapacity / 2),这是一个经过优化的策略,在空间和时间上取得了较好的平衡。
  3. 复制与替换:扩容不是在原数组上修改,而是创建一个更大的新数组,然后将旧数组中的所有元素(elementData)复制到新数组中,最后让 elementData 引用指向这个新数组,这个过程是 Arrays.copyOf() 完成的。
  4. 添加元素:在新数组的 size 位置(即末尾)放入新元素,并 size++

时间复杂度

  • 平均情况O(1),大部分情况下,数组有足够空间,直接赋值即可。
  • 最坏情况O(n),当数组需要扩容时,需要复制所有 n 个元素到新数组,此时时间复杂度为 O(n),但由于扩容是指数级增长的(1.5倍),均摊下来,add 操作的时间复杂度仍然是 O(1)

b. get(int index) - 获取元素

这个操作非常简单,因为数组是随机存取结构。

public E get(int index) {
    // 1. 范围检查,防止索引越界
    Objects.checkIndex(index, size);
    // 2. 直接通过索引访问数组元素
    return (E) elementData[index];
}

时间复杂度O(1),无论 index 是多少,访问速度都是恒定的。

ArrayList如何实现动态扩容与线程安全?-图3
(图片来源网络,侵删)

c. remove(int index) - 删除元素

删除操作比添加更复杂,因为它需要移动元素。

public E remove(int index) {
    // 1. 范围检查
    Objects.checkIndex(index, size);
    // 2. 获取要删除的元素,用于返回
    E oldValue = (E) elementData[index];
    // 3. 计算需要移动的元素数量
    int numMoved = size - index - 1;
    // 4. 如果不是删除最后一个元素,就需要进行元素移动
    if (numMoved > 0)
        // 将 index+1 位置开始的 numMoved 个元素,向前移动到 index 位置
        System.arraycopy(elementData, index + 1, elementData, index, numMoved);
    // 5. 清理最后一个元素,并让 size 减 1
    elementData[--size] = null; // help GC
    return oldValue;
}

remove 操作流程总结

  1. 范围检查:确保索引有效。
  2. 获取旧值:记录要被删除的元素,以便方法返回。
  3. 移动元素:从 index+1 开始,将后面的所有元素向前移动一位,覆盖掉被删除的元素,这是通过 System.arraycopy() 高效完成的。
  4. 清理与缩容:将被移动后空出来的最后一个元素(elementData[size-1])置为 null(帮助垃圾回收),然后将 size 减一。

时间复杂度

  • 平均情况O(n),因为最坏情况下(删除第一个元素),需要移动 n-1 个元素。
  • 最好情况O(1),当删除最后一个元素时,不需要移动任何元素,只需将 size 减一并将末尾置为 null

性能分析总结

操作 时间复杂度 备注
add(E e) (尾部添加) 均摊 O(1) 大部分情况是 O(1),扩容时为 O(n),但均摊后是 O(1)。
get(int index) O(1) 数组随机访问的巨大优势。
remove(int index) O(n) 需要移动被删除元素之后的所有元素。
set(int index, E e) O(1) 直接通过索引赋值,不涉及移动。
contains(E e) O(n) 需要遍历整个数组进行线性查找。

ArrayList vs LinkedList

特性 ArrayList LinkedList
数据结构 动态数组 双向链表
随机访问 (get) 快 (O(1)) 慢 (O(n)),需要从头或尾遍历
尾部添加 (add) 快 (均摊 O(1)) 快 (O(1))
中间插入 (add) 慢 (O(n)),需要移动元素 快 (O(1)),只需修改指针
中间删除 (remove) 慢 (O(n)),需要移动元素 快 (O(1)),只需修改指针
内存开销 较小,只有数组本身 较大,每个节点需要额外存储 prevnext 指针
适用场景 频繁查询,少量增删,例如缓存、列表数据 频繁增删,少量查询,例如队列、栈

代码示例

下面是一个完整的示例,演示了 ArrayList 的基本用法和性能特点。

import java.util.ArrayList;
import java.util.Arrays;
public class ArrayListImplementationDemo {
    public static void main(String[] args) {
        // 1. 创建 ArrayList
        ArrayList<String> fruits = new ArrayList<>();
        // 2. 添加元素 (add)
        System.out.println("--- 添加元素 ---");
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Orange");
        System.out.println("添加后: " + fruits); // [Apple, Banana, Orange]
        System.out.println("当前容量: " + getCapacity(fruits) + ", 大小: " + fruits.size());
        // 3. 在中间插入元素
        fruits.add(1, "Mango");
        System.out.println("在索引1插入Mango后: " + fruits); // [Apple, Mango, Banana, Orange]
        System.out.println("当前容量: " + getCapacity(fruits) + ", 大小: " + fruits.size());
        // 4. 获取元素 (get)
        System.out.println("\n--- 获取元素 ---");
        String fruit = fruits.get(2);
        System.out.println("索引2处的元素是: " + fruit); // Banana
        // 5. 修改元素 (set)
        System.out.println("\n--- 修改元素 ---");
        fruits.set(0, "Pineapple");
        System.out.println("将索引0修改为Pineapple后: " + fruits); // [Pineapple, Mango, Banana, Orange]
        // 6. 删除元素 (remove)
        System.out.println("\n--- 删除元素 ---");
        String removedFruit = fruits.remove(3);
        System.out.println("删除索引3的元素(" + removedFruit + ")后: " + fruits); // [Pineapple, Mango, Banana]
        System.out.println("当前容量: " + getCapacity(fruits) + ", 大小: " + fruits.size());
        // 7. 演示扩容
        System.out.println("\n--- 演示自动扩容 ---");
        System.out.println("当前容量: " + getCapacity(fruits));
        // ArrayList的初始容量是10,我们添加到第11个元素时会触发扩容
        for (int i = fruits.size(); i < 11; i++) {
            fruits.add("Fruit-" + i);
        }
        System.out.println("添加到11个元素后: " + fruits);
        System.out.println("扩容后容量: " + getCapacity(fruits)); // 应该是15 (10 * 1.5)
    }
    /**
     * 一个辅助方法,用于反射获取 ArrayList 的容量(elementData.length)
     * 注意:这只是一个演示用的工具方法,在生产代码中应避免使用反射。
     */
    private static int getCapacity(ArrayList<?> list) {
        try {
            // 获取 elementData 字段
            java.lang.reflect.Field field = ArrayList.class.getDeclaredField("elementData");
            field.setAccessible(true);
            // 获取 elementData 数组
            Object[] elementData = (Object[]) field.get(list);
            return elementData.length;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
            return -1;
        }
    }
}

运行结果分析

  • 你会看到 add 操作后,size 增加。
  • ArrayList 从空开始添加第一个元素时,它的容量会自动扩容到 10
  • 当你添加第11个元素时,你会看到容量从 10 变为 15 (10 * 1.5),这正是我们源码分析中看到的扩容策略。

希望这个详细的解释能帮助你彻底理解 ArrayList 在 Java 中的实现原理!

分享:
扫描分享到社交APP
上一篇
下一篇