乐于分享
好东西不私藏

ArrayList 源码全解析:动态扩容、数组互转与底层原理

ArrayList 源码全解析:动态扩容、数组互转与底层原理

上一篇讲了数组和链表的基础概念,这篇直接进入正题——ArrayList。

面试里关于 ArrayList 的问题,问法五花八门,但基本都绕不开这几类:

  • ArrayList 底层是怎么实现的?
  • 它的扩容机制是什么样的?
  • new ArrayList(10)
     扩容了几次?
  • 数组和 List 怎么互相转换,转换之后互相修改有没有影响?

这些问题的答案全在源码里。JDK 1.8 的 ArrayList 源码不算复杂,跟着走一遍就能搞明白。

ArrayList 的底层数据结构

一句话:ArrayList 底层是用动态数组实现的。

transient Object[] elementData;private int size;

elementData 就是那个存数据的数组,size 是实际装了多少元素。注意 size 和 elementData.length 是两回事——前者是"装了多少",后者是"能装多少"(容量)。

成员变量

先看 ArrayList 里几个关键的成员变量:

// 默认初始容量private static final int DEFAULT_CAPACITY = 10;// 空数组实例,用于指定容量为 0 时private static final Object[] EMPTY_ELEMENTDATA = {};// 空数组实例,用于无参构造,跟 EMPTY_ELEMENTDATA 区分开// 目的是:第一次添加元素时,知道该扩容到 DEFAULT_CAPACITY(10)private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};// 真正存数据的数组transient Object[] elementData;// 实际元素个数private int size;

DEFAULTCAPACITY_EMPTY_ELEMENTDATA 和 EMPTY_ELEMENTDATA 虽然都是空数组,但用途不同。源码用前者来标记"这个 ArrayList 是通过无参构造创建的",这样第一次 add 时才能把容量初始化为默认的 10。

构造方法

ArrayList 有三个构造方法:

// 无参构造:空集合,第一次 add 时才初始化容量为 10publicArrayList() {    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;}// 指定初始容量publicArrayList(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);    }}// 从其他集合创建publicArrayList(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;    }}

无参构造是最常用的,注意它并没有直接创建长度为 10 的数组,而是用了"懒加载"——等你真的往里面放东西时才分配空间。

add 方法:添加元素的全过程

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

整个 add 的核心在 ensureCapacityInternal 这一行。它内部调了三个方法,逐层拆开来看:

private void ensureCapacityInternal(int minCapacity) {    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));}// 计算容量:如果是默认空数组,至少给 DEFAULT_CAPACITYprivate static int calculateCapacity(Object[] elementData, int minCapacity) {    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {        return Math.max(DEFAULT_CAPACITY, minCapacity);    }    return minCapacity;}// 如果需要的容量大于当前数组长度,扩容private void ensureExplicitCapacity(int minCapacity) {    modCount++;  // 结构化修改计数,用于 fail-fast    if (minCapacity - elementData.length > 0)        grow(minCapacity);}

源码里那个 modCount++ 不是可有可无的。modCount 记录的是 ArrayList 结构化修改的次数——添加、删除、扩容等改变数组结构的操作。它的核心用途是 fail-fast 机制:

// 用 for-each 或迭代器遍历时,如果另一个线程修改了 ArrayList// 迭代器的 next() 方法会检查 modCount 是否和预期值一致// 不一致就直接抛 ConcurrentModificationException,立刻失败

这样做的好处是"宁可快速报错,也不带着脏数据继续执行"。比如你在遍历一个 List 的时候,另一个线程删除了一个元素,如果没有 fail-fast,你可能读到不一致的状态,排查起来比直接抛异常麻烦得多。

注意,fail-fast 并不能保证一定捕获并发修改——它只是"尽力而为"的检测。所以不要在遍历时直接调用 list.remove(),要用迭代器的 remove() 方法,或者用 CopyOnWriteArrayList 这类并发安全的集合。

把整个添加流程用 mermaid 画出来:

扩容机制:grow 方法

privatevoidgrow(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);  // 数组拷贝}

核心公式:newCapacity = oldCapacity + (oldCapacity >> 1),也就是 原来的 1.5 倍。

老容量
新容量
10
15
15
22
22
33

每次扩容都会调用 Arrays.copyOf,这会创建一个新数组,然后把老数组里的元素挨个拷过去。这个过程是 O(n) 的。

扩容过程的三个阶段

用 new ArrayList() 无参构造,然后依次添加元素,容量变化分三个阶段。PPT 里专门用了 3 页来演示这个过程:

阶段一:第 1 次 add

关键点:无参构造的 ArrayList,第一次 add 时因为 elementData 还是 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,calculateCapacity 直接返回 DEFAULT_CAPACITY=10,触发扩容到 10。

阶段二:第 2~10 次 add

第 2 到第 10 次 add,每次需要的容量(2, 3, 4, …, 10)都不超过当前数组长度 10,所以一次都不扩容。10 次 add 总共扩容 1 次。

阶段三:第 11 次 add(触发扩容)

size 从 10 变成 11,超过了当前容量 10,触发扩容。10 + (10 >> 1) = 15,新容量变成 15。

把三个阶段串起来:

阶段
add 次数
触发操作
扩容后容量
阶段一
第 1 次
首次初始化扩容
10
阶段二
第 2~10 次
不扩容,直接放入
10
阶段三
第 11 次
容量不够,扩容
15

再往后,第 16 次 add 会再次扩容到 22(15 + 7),以此类推。每次都是容量不够了才扩,扩完是原来的 1.5 倍。

扩容的边界处理:hugeCapacity

grow 方法里还有一个细节容易被忽略:

if (newCapacity - MAX_ARRAY_SIZE > 0)    newCapacity = hugeCapacity(minCapacity);

MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8。数组在 JVM 里有一些 header 开销,所以实际能分配的最大数组长度不是 Integer.MAX_VALUE,而是减掉 8 字节的 header。

当新容量超过这个阈值时,会调用 hugeCapacity:

privatestaticinthugeCapacity(int minCapacity) {    if (minCapacity < 0) // 溢出,说明 minCapacity 已经超过了 Integer.MAX_VALUE        throw new OutOfMemoryError();    return (minCapacity > MAX_ARRAY_SIZE) ?        Integer.MAX_VALUE :        MAX_ARRAY_SIZE;}

简单说就是:正常情况下扩容到 MAX_ARRAY_SIZE 就封顶;极端情况下(minCapacity 在 MAX_ARRAY_SIZE 和 Integer.MAX_VALUE 之间)允许扩到 Integer.MAX_VALUE;真的超了 Integer.MAX_VALUE 就抛 OutOfMemoryError。

所以如果预先知道要存多少数据,最好在构造时就指定初始容量,避免反复扩容和数组拷贝。

new ArrayList(10) 扩容了几次?

这是一个经典面试题。

publicArrayList(int initialCapacity) {    if (initialCapacity > 0) {        this.elementData = new Object[initialCapacity];  // 直接创建容量为 10 的数组    } else if (initialCapacity == 0) {        this.elementData = EMPTY_ELEMENTDATA;    } else {        throw new IllegalArgumentException(...);    }}

带参数的构造函数直接 new Object[10],创建出来容量就是 10,存了数组就知道自己有多大。add 元素时,只有 size 超过 10 之后才会触发第一次扩容。

所以答案是:0 次。new ArrayList(10) 只是声明和实例化了一个 ArrayList,指定了容量为 10,没有发生扩容。 等你 add 第 11 个元素时才会触发第一次扩容(10 × 1.5 = 15)。

和 new ArrayList() 的区别:

数组与 List 互转

数组转 List:Arrays.asList

String[] strs = {"aaa", "bbb", "ccc"};List<String> list = Arrays.asList(strs);

这个方法返回的不是 java.util.ArrayList,而是 Arrays 内部的一个私有静态类 ArrayList。它底层直接引用原数组,没有拷贝数据。

// 修改数组的内容strs[1] = "ddd";System.out.println(list);  // [aaa, ddd, ccc] —— list 也变了!

因为底层用的是同一个数组,所以修改数组,List 会受影响。

注意:Arrays.asList 返回的 List 不支持 add/remove 操作。想得到一个真正独立的 ArrayList,应该这样写:

List<String> list = new ArrayList<>(Arrays.asList(strs));

List 转数组:toArray

List<String> list = new ArrayList<>();list.add("aaa");list.add("bbb");list.add("ccc");// 方式一:无参,返回 Object[]Object[] arr1 = list.toArray();// 方式二:指定类型,更常用String[] arr2 = list.toArray(new String[0]);

无参 toArray 返回 Object 数组,需要自己强转。推荐用带参版本,直接拿到正确的类型。

关键点:toArray 底层做了数组拷贝,返回的是一个全新的数组。

String[] array = list.toArray(new String[list.size()]);list.add("ddd");System.out.println(Arrays.toString(array));  // [aaa, bbb, ccc] —— 不受影响

List 修改之后,toArray 生成的数组不受影响,因为底层拷贝了一份。

ArrayList 是线程不安全的

ArrayList 的方法没加 synchronized,多线程并发 add 会出问题。

典型场景:两个线程同时 add,都读到 size = 5,都往 elementData[5] 写数据,结果一个元素被覆盖。或者扩容时,线程 A 正在 Arrays.copyOf,线程 B 读到一半拷贝了一半没拷贝的数组,直接崩。

需要线程安全的 List 有两种方案:

// 方案一:Collections.synchronizedList 包装List<String> syncList = Collections.synchronizedList(new ArrayList<>());// 方案二:直接用 CopyOnWriteArrayListList<String> cowList = new CopyOnWriteArrayList<>();

synchronizedList 是给所有方法加锁,写操作性能一般。CopyOnWriteArrayList 是写时复制,适合读多写少的场景。

面试模板

如果面试官问"说一下 ArrayList 的底层实现原理",可以这样回答:

ArrayList 底层是用动态数组实现的。成员变量 elementData 是 Object 数组,size 记录实际元素个数。

无参构造时,并没有直接创建数组,而是把 elementData 指向一个共享的空数组实例。第一次 add 时,才会把容量初始化为默认的 10。

add 方法先调用 ensureCapacityInternal 检查容量是否够用,不够就调 grow 扩容。扩容大小是原来容量的 1.5 倍,通过 oldCapacity + (oldCapacity >> 1) 计算,底层用 Arrays.copyOf 把老数据拷贝到新数组。

如果构造时指定了初始容量,比如 new ArrayList(10),就直接创建长度为 10 的数组,在存满之前不需要扩容。数组转 List 用 Arrays.asList,它底层直接引用原数组,互相修改会影响。List 转数组用 toArray,底层做了拷贝,所以互不影响。

ArrayList 不是线程安全的,多线程场景可以用 Collections.synchronizedList 包装或者用 CopyOnWriteArrayList。

相关学习资料