ArrayList扩容机制
在Java集合框架中,ArrayList是最常用的动态数组实现类,其核心优势在于支持动态扩容,能够根据元素数量的变化自动调整底层数组容量,兼顾了数组的高效访问和链表的灵活扩容。但很多开发者只知其然,不知其所以然------ArrayList的扩容机制到底是怎样的?为什么会出现扩容?如何优化扩容性能?以及为什么ArrayList不是线程安全的?今天就带着这些问题,从源码角度+实际场景,彻底吃透ArrayList扩容机制,助力大家写出更高效、更健壮的Java代码。
一、ArrayList底层结构基础
ArrayList的底层是基于数组 实现的,其内部维护了一个名为elementData的Object类型数组(JDK1.8及以上),用于存储实际的元素;同时维护了一个size变量,用于记录当前集合中实际存储的元素个数(注意:size不是数组长度,而是元素个数,数组长度是底层数组的容量,即elementData.length)。
核心关联关系:当我们向ArrayList中添加元素时,会先判断当前元素个数(size)+1是否超过底层数组的容量(elementData.length),如果超过,则触发扩容操作;否则直接将元素存入数组对应下标位置。
java
// ArrayList底层核心属性(JDK1.8)
private transient Object[] elementData; // 存储元素的底层数组
private int size; // 当前集合中元素的实际个数
private static final int DEFAULT_CAPACITY = 10; // 默认初始容量
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 空数组(无参构造初始化用)
二、ArrayList扩容核心原理(源码级拆解)
ArrayList的扩容触发时机主要是在调用add()方法时,整个扩容流程分为「判断是否需要扩容」「计算新容量」「数组复制」三个核心步骤,下面结合源码和细节,一步步拆解每一个环节,确保无死角理解。
2.1 扩容触发时机:add()方法的底层逻辑
当我们调用add(E e)方法添加单个元素,或addAll(Collection<? extends E> c)方法批量添加元素时,都会先检查当前容量是否足够,核心逻辑在ensureCapacityInternal(int minCapacity)方法中(该方法是扩容的入口)。
java
// 单个元素添加(核心方法)
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 关键:检查容量,size+1是"最小需要容量"
elementData[size++] = e; // 给数组赋值,size自增
return true;
}
// 批量添加元素
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew); // 最小需要容量 = 当前size + 批量添加的元素个数
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
这里有一个关键细节:minCapacity(最小需要容量)。对于单个add,minCapacity = size + 1(因为要在size下标位置插入元素,size是当前元素个数,下标从0开始,比如当前size=9,下一个插入位置是9,size+1=10,即需要至少10的容量);对于批量add,minCapacity = size + 批量元素个数(比如当前size=5,批量添加20个元素,最小需要容量就是25)。
ensureCapacityInternal方法的作用,就是判断当前底层数组的容量是否能满足minCapacity,如果不能,就触发扩容(调用grow()方法)。
2.2 扩容核心步骤:grow()方法详解
grow()方法是ArrayList扩容的核心实现,整个方法逻辑清晰,分为「计算新容量」「数组复制」两个关键操作,我们结合源码逐行解析:
java
private void grow(int minCapacity) {
// 1. 获取当前底层数组的容量(旧容量)
int oldCapacity = elementData.length;
// 2. 计算新容量:默认扩容为旧容量的1.5倍(oldCapacity + oldCapacity/2,等价于oldCapacity >> 1右移一位)
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 3. 校验新容量:如果1.5倍容量仍小于最小需要容量,直接用最小需要容量作为新容量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 4. 校验新容量是否超过最大容量(ArrayList有最大容量限制:Integer.MAX_VALUE - 8)
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 5. 数组复制:通过Arrays.copyOf()创建新数组,将原数组元素复制到新数组,更新elementData引用
elementData = Arrays.copyOf(elementData, newCapacity);
}
2.3 扩容关键细节补充(必看,高分核心)
很多开发者对扩容的细节存在误解,这里重点澄清3个核心细节,也是面试和实际开发中高频考点:
细节1:初始容量的真相(无参构造的底层逻辑)
很多人以为"new ArrayList<>()"会直接初始化一个容量为10的数组,但实际上并非如此!
JDK1.8中,无参构造方法初始化时,会将elementData赋值为一个空数组(DEFAULTCAPACITY_EMPTY_ELEMENTDATA),只有当第一次调用add()方法时,才会真正分配容量,此时会将容量扩容为默认的10。
java
// 无参构造方法(JDK1.8)
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; // 初始为空数组
}
// ensureCapacityInternal方法中,第一次add时的逻辑
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 第一次add,minCapacity=1,此时取默认容量10和minCapacity的最大值,即10
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
补充:如果使用带参构造new ArrayList(int initialCapacity),则会直接初始化一个容量为initialCapacity的数组(如果initialCapacity>0),避免第一次add时的扩容操作。
细节2:1.5倍扩容的计算方式
源码中newCapacity的计算方式是oldCapacity + (oldCapacity >> 1),这里的">>1"是右移一位,等价于oldCapacity / 2(整数除法,忽略小数),所以整体就是oldCapacity * 1.5。
示例:旧容量为10,10 >>1 =5,新容量=10+5=15;旧容量为15,新容量=15+7=22(15/2=7.5,整数除法取7);旧容量为22,新容量=22+11=33,以此类推。
细节3:最小需要容量的特殊情况(批量添加场景)
当批量添加元素时,可能会出现"1.5倍旧容量仍小于最小需要容量"的情况,此时会直接用minCapacity作为新容量,避免多次扩容。
示例:用无参构造创建ArrayList(初始为空数组),第一次调用addAll()批量添加20个元素。此时:
-
minCapacity = size + 20 = 0 + 20 = 20
-
oldCapacity = 0(空数组容量为0)
-
newCapacity = 0 + 0 = 0,此时newCapacity < minCapacity(20),所以newCapacity直接设为20
-
最终底层数组容量为20,只需要一次扩容,避免了多次扩容的耗时
2.4 数组复制的耗时点
扩容的核心耗时操作是Arrays.copyOf(elementData, newCapacity),该方法的底层是调用System.arraycopy()(native方法,由C语言实现),本质是将原数组的所有元素复制到新数组中。
这里需要注意:数组复制的时间复杂度是O(n)(n为原数组元素个数),元素越多,复制耗时越长。如果频繁触发扩容,会多次执行数组复制,严重影响程序性能------这也是我们需要优化ArrayList扩容的核心原因。
三、ArrayList扩容优化方案(实战级,直接复用)
扩容优化的核心目标:避免多次扩容,减少数组复制的次数和耗时。结合ArrayList的扩容机制,我们可以从2个方面进行优化,覆盖绝大多数实际开发场景。
优化方案1:创建时预估元素数量,指定初始容量
这是最常用、最有效的优化方式。如果我们能提前预估ArrayList中要存储的元素个数,在创建时直接通过带参构造指定初始容量,就能避免后续多次扩容。
反例(不推荐):
java
// 无参构造,初始为空数组,添加100个元素会触发多次扩容
List<String> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add("test" + i);
}
扩容次数分析:初始容量0 → 第一次add扩容到10 → 10满了扩容到15 → 15满了扩容到22 → ... → 直到容量满足100,总共会触发7-8次扩容,多次执行数组复制。
正例(推荐):
java
// 预估要存储100个元素,直接指定初始容量100,全程不扩容
List<String> list = new ArrayList<>(100);
for (int i = 0; i < 100; i++) {
list.add("test" + i);
}
补充:如果预估不准,建议"略估多一点",比如预估100个,指定初始容量120,避免因预估不足导致少量扩容,同时也不会浪费过多内存(数组的空闲空间不会太大)。
优化方案2:批量添加前,手动调用ensureCapacity()扩容
如果无法提前预估元素个数,或者需要动态批量添加元素(比如从数据库查询数据批量添加),可以在批量添加前,手动调用ensureCapacity(int minCapacity)方法,手动指定最小需要容量,触发一次扩容,避免多次扩容。
示例:
java
List<String> list = new ArrayList<>();
// 假设从数据库查询到200条数据,批量添加前手动扩容
list.ensureCapacity(200);
// 批量添加数据(此时只需一次扩容,避免多次复制)
list.addAll(dbDataList);
原理:ensureCapacity()方法会直接调用ensureCapacityInternal(),触发grow()方法扩容,将数组容量一次性扩容到满足minCapacity的大小,后续批量添加时无需再扩容。
注意:ensureCapacity()是ArrayList的public方法,专门用于手动扩容,开发中可以直接调用,尤其适合批量添加场景。
优化补充:避免不必要的扩容场景
-
如果元素个数固定,优先使用数组(如String[]),避免ArrayList的扩容开销;
-
如果需要频繁添加/删除元素(而非查询),可以考虑LinkedList(无需扩容,但查询效率低,根据场景选择);
-
如果元素个数不确定,但知道大致范围,尽量"略估多一点",平衡内存占用和扩容耗时。
四、延伸:为什么ArrayList不是线程安全的?(扩容相关)
很多开发者会疑惑:ArrayList的扩容机制和线程安全有什么关系?其实,ArrayList的线程不安全,核心原因是「size操作非原子性」和「多线程并发修改底层数组」,而扩容过程中的数组复制和size更新,会进一步放大这种不安全问题。
首先明确:ArrayList的add()方法不是同步的,在高并发场景下,多个线程同时调用add()方法,会出现3种典型问题,我们结合扩容和add()的三步操作,逐一分析。
4.1 add()方法的三步核心操作(再次强调)
无论是否触发扩容,add()方法的核心逻辑都可以分为3步:
-
读取当前size,判断是否需要扩容(size+1与数组容量比较,需要则调用grow());
-
将元素赋值到elementData[size]位置;
-
size自增(size++)。
这三步操作不是原子性的(无法保证同一时间只有一个线程执行),这是线程不安全的根源。
4.2 三种线程不安全问题的详细分析(结合扩容场景)
问题1:部分元素为null(未手动添加null)
场景:两个线程同时添加元素,且当前数组容量足够(无需扩容),但size更新不及时。
执行流程:
-
线程1进入add()方法,读取size=9,数组容量=10(无需扩容),此时CPU执行权切换到线程2;
-
线程2进入add()方法,读取size=9(未被线程1更新),数组容量=10(无需扩容),CPU执行权切换回线程1;
-
线程1执行步骤2:将元素赋值到elementData[9],此时未执行size++,CPU执行权再次切换到线程2;
-
线程2执行步骤2:将自己的元素赋值到elementData[9](覆盖线程1的元素),然后执行size++,size变为10;
-
线程1恢复执行,执行size++,size变为11;
-
最终,elementData[10]位置没有被赋值(因为两个线程都只赋值了index=9的位置),导致该位置为null。
问题2:索引越界异常(ArrayIndexOutOfBoundsException)
场景:两个线程同时添加元素,数组容量刚好满,扩容判断被同时执行,导致其中一个线程赋值时超出数组容量。
执行流程:
-
线程1进入add()方法,读取size=9,数组容量=10(无需扩容),CPU执行权切换到线程2;
-
线程2进入add()方法,读取size=9,数组容量=10(无需扩容),CPU执行权切换回线程1;
-
线程1执行步骤2:赋值elementData[9],然后执行size++,size变为10;
-
线程2恢复执行,执行步骤2:此时size=10,但数组容量仍为10(线程2未触发扩容),赋值elementData[10],超出数组下标范围(数组下标0-9),抛出索引越界异常。
补充:如果线程2在执行步骤1时,线程1已经完成扩容,也可能出现其他异常,但索引越界是最典型的情况。
问题3:size与实际添加的元素个数不符
场景:多个线程同时执行size++,导致size自增操作被覆盖,最终size小于实际添加的元素个数。
原因:size++本身不是原子操作,其底层分为3步:①读取size的值;②将size的值加1;③将新值覆盖原size。
执行流程:
-
线程1和线程2同时读取size=9(步骤①);
-
线程1执行步骤②:9+1=10,步骤③:将size更新为10;
-
线程2执行步骤②:9+1=10,步骤③:将size更新为10(覆盖线程1的结果);
-
两个线程都添加了元素,但size只增加了1,导致size与实际元素个数不符(实际2个,size=10)。
注意:这种问题在高并发场景下几乎必然发生,也是ArrayList线程不安全的最直观表现。
4.3 线程安全的替代方案
如果需要在多线程场景下使用动态数组,可以选择以下方案:
-
使用
Collections.synchronizedList(new ArrayList<>()):对ArrayList进行同步包装,所有方法都加了synchronized锁,线程安全,但性能较低; -
使用
CopyOnWriteArrayList:采用"写时复制"机制,读取无锁,写入时复制一份新数组,线程安全,适合读多写少的场景; -
使用并发集合(如ConcurrentLinkedQueue):如果不需要随机访问,仅需要添加/删除,可选择并发队列。
五、总结(高分重点)
ArrayList的扩容机制是其核心特性,也是面试高频考点,掌握以下核心要点,就能轻松应对面试和实际开发:
-
核心逻辑:add()方法触发扩容,先判断最小需要容量,再计算新容量(默认1.5倍,不足则用最小需要容量),最后通过数组复制完成扩容;
-
关键细节:无参构造初始为空数组,第一次add扩容到10;1.5倍扩容通过右移一位计算;批量添加时会优先使用最小需要容量;
-
优化核心:避免多次扩容,通过"指定初始容量"和"手动调用ensureCapacity()"减少数组复制耗时;
-
线程安全:ArrayList非线程安全,根源是add()三步操作非原子性,高并发下会出现null、越界、size不符问题,需使用同步方案替代。