Java容器之ArrayList源码分析
一、概述
ArrayList是一种变长的集合类,基于定长数组实现。ArrayList允许空值和重复元素,当往ArrayList中添加的元素数量大于其底层数组容量时,其会通过扩容机制重新生成一个更大的数组。另外,由于ArrayList底层基于数组实现,所以其可以保证在O(1)复杂度下完成随机查找操作。其他方面,ArrayList是非线程安全类,并发环境下,多个线程同时操作ArrayList,会引发不可预知的错误。
ArrayList是大家最为常用的集合类,作为一个变长集合类,其核心是扩容机制。所以只要知道它是怎么扩容的,以及基本的操作是怎样实现就够了。
二、源码分析
2.1 属性
/**
* 默认容量
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 空数组,如果传入的容量为0时使用
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 空数组,传入容量时使用,添加第一个元素的时候会重新初始为默认容量大小
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 存储元素的数组
*/
transient Object[] elementData;
/**
* 集合中元素的个数
*/
private int size;
1)、DEFAULT_CAPACITY
默认容量为10,也就是通过new ArrayList()创建时的默认容量。
2)、EMPTY_ELEMENTDATA
空的数组,这种是通过new ArrayList(0)创建时用的是这个空数组。
3)、DEFAULTCAPACITY_EMPTY_ELEMENTDATA
也是空数组,这种是通过new ArrayList()创建时用的是这个空数组,与EMPTY_ELEMENTDATA的区别是在添加第一个元素时使用这个空数组的会初始化为DEFAULT_CAPACITY(10)个元素。
4)、elementData
真正存放元素的地方,使用transient是为了不序列化这个字段。
5)、size
真正存储元素的个数,而不是elementData数组的长度。
2.2 构造方法
ArrayList有两个构造方法,一个是无参,另一个需传入初始容量值。大家平时最常用的是无参构造方法,相关代码如下:
//指定容量的空List
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
// 如果传入的初始容量大于0,就新建一个数组存储元素
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
// 如果传入的初始容量等于0,使用空数组EMPTY_ELEMENTDATA
this.elementData = EMPTY_ELEMENTDATA;
} else {
// 如果传入的初始容量小于0,抛出异常
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
//空List
public ArrayList() {
// 如果没有传入初始容量,则使用空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA
// 使用这个数组是在添加第一个元素的时候会扩容到默认大小10
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//指定集合元素的列表
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// 如果elementData类型不是Object数组
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// 初始化elementData为空数组
this.elementData = EMPTY_ELEMENTDATA;
}
}
上面的代码比较简单,前两个构造方法做的事情并不复杂,目的都是初始化底层数组elementData。区别在于无参构造方法会将elementData初始化一个空数组,插入元素时,扩容将会按默认值重新初始化数组。而有参的构造方法则会将elementData初始化为参数值大小(>= 0)的数组。
一般情况下,我们用默认的构造方法即可。倘若在可知道将会向ArrayList插入多少元素的情况下,应该使用有参构造方法。按需分配,避免浪费。
2.3 插入
对于数组(线性表)结构,插入操作分为两种情况。一种是在元素序列尾部插入,另一种是在元素序列其他位置插入。ArrayList的源码里也体现了这两种插入情况,如下:
/** 在元素序列尾部插入 */
public boolean add(E e) {
//检测是否需要扩容
ensureCapacityInternal(size + 1); // Increments modCount!!
//将新元素插入序列尾部
elementData[size++] = e;
return true;
}
/** 在元素序列 index 位置处插入 */
public void add(int index, E element) {
//检查是否越界
rangeCheckForAdd(index);
//检测是否需要扩容
ensureCapacityInternal(size + 1); // Increments modCount!!
//将 index 及其之后的所有元素都向后移一位
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
//将新元素插入至index的位置
elementData[index] = element;
size++;
}
对于在元素序列尾部插入,这种情况比较简单,只需两个步骤即可:
- 检测数组是否有足够的空间插入
- 将新元素插入至序列尾部
如下图:

如果是在元素序列指定位置(假设该位置合理)插入,则情况稍微复杂一点,需要三个步骤:
- 检测数组是否有足够的空间
- 将
index及其之后的所有元素向后移一位 - 将新元素插入至
index处
如下图:

从上图可以看出,将新元素插入至序列指定位置,需要先将该位置及其之后的元素都向后移动一位,为新元素腾出位置。这个操作的时间复杂度为O(N),频繁移动元素可能会导致效率问题,特别是集合中元素数量较多时。在日常开发中,若非所需,我们应当尽量避免在大集合中调用第二个插入方法。
以上是ArrayList插入相关的分析,上面的分析以及配图均未体现扩容机制。那么下面就来简单分析一下ArrayList的扩容机制。对于变长数据结构,当结构中没有空余空间可供使用时,就需要进行扩容。在ArrayList中,当空间用完,其会按照原数组空间的1.5倍进行扩容。相关源码如下:
/** 计算最小容量 */
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 如果是空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,就初始化为默认大小10
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
/** 扩容的入口方法 */
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
// 扩容
grow(minCapacity);
}
/** 扩容的核心方法 */
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 新容量为旧容量的1.5倍
// newCapacity = oldCapacity + oldCapacity / 2 = oldCapacity * 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);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
// 如果最小容量超过 MAX_ARRAY_SIZE,则将数组容量扩容至 Integer.MAX_VALUE
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
关于grow方法
1)、检查是否需要扩容;
2)、如果elementData等于DEFAULTCAPACITY_EMPTY_ELEMENTDATA则初始化容量大小为DEFAULT_CAPACITY;
3)、新容量是老容量的1.5倍(oldCapacity + (oldCapacity >> 1)),如果加了这么多容量发现比需要的容量还小,则以需要的容量为准;
4)、创建新容量的数组并把老数组拷贝到新数组;
2.4 删除
不同于插入操作,ArrayList没有无参删除方法。所以其只能删除指定位置的元素或删除指定元素,这样就无法避免移动元素(除非从元素序列的尾部删除)。相关代码如下:
/** 删除指定位置的元素 */
public E remove(int index) {
rangeCheck(index);
modCount++;
// 返回被删除的元素值
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
// 将 index + 1 及之后的元素向前移动一位,覆盖被删除值
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 将最后一个元素置空,并将 size 值减1
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
E elementData(int index) {
return (E) elementData[index];
}
/** 删除指定元素,若元素重复,则只删除下标最小的元素 */
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;
}
/** 快速删除,不做边界检查,也不返回删除的元素值 */
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; // clear to let GC do its work
}
上面的删除方法并不复杂,这里以第一个删除方法为例,删除一个元素步骤如下:
- 获取指定位置
index处的元素值 - 将
index + 1及之后的元素向前移动一位 - 将最后一个元素置空,并将
size值减1 - 返回被删除值,完成删除操作
如下图:

上面就是删除指定位置元素的分析,并不是很复杂。
现在,考虑这样一种情况。我们往ArrayList插入大量元素后,又删除很多元素,此时底层数组会空闲处大量的空间。因为ArrayList没有自动缩容机制,导致底层数组大量的空闲空间不能被释放,造成浪费。对于这种情况,ArrayList也提供了相应的处理方法,如下:
/** 将数组容量缩小至元素数量 */
public void trimToSize() {
modCount++;
if (size < elementData.length) {
elementData = (size == 0)
? EMPTY_ELEMENTDATA
: Arrays.copyOf(elementData, size);
}
}
通过上面的方法,我们可以手动触发ArrayList的缩容机制。这样就可以释放多余的空间,提高空间利用率。

2.5 遍历
ArrayList实现了RandomAccess接口(该接口是个标志性接口),表明它具有随机访问的能力。ArrayList底层基于数组实现,所以它可在常数阶的时间内完成随机访问,效率很高。对ArrayList进行遍历时,一般情况下,我们喜欢使用foreach循环遍历,但这并不是推荐的遍历方式。ArrayList具有随机访问的能力,如果在一些效率要求比较高的场景下,更推荐下面这种方式:
for (int i = 0; i < list.size(); i++) {
list.get(i);
}
至于原因也不难理解,foreach最终会被转换成迭代器遍历的形式,效率不如上面的遍历方式。
2.5.1 关于遍历时删除
遍历时删除是一个不正确的操作,即使有时候代码不出现异常,但执行逻辑也会出现问题。关于这个问题,阿里巴巴Java开发手册里也有所提及。这里引用一下:
【强制】不要在
foreach循环里进行元素的remove/add操作。remove元素请使用Iterator方式,如果并发操作,需要对Iterator对象加锁。
相关代码(稍作修改)如下:
List a = new ArrayList();
a.add("1");
a.add("2");
for (String temp : a) {
System.out.println(temp);
if("1".equals(temp)){
a.remove(temp);
}
}
}
相信有些朋友应该看过这个,并且也执行过上面的程序。上面的程序执行起来不会虽不会出现异常,但代码执行逻辑上却有问题,只不过这个问题隐藏的比较深。我们把temp变量打印出来,会发现只打印了数字1,2没打印出来。初看这个执行结果确实很让人诧异,不明原因。如果死抠上面的代码,我们很难找出原因,此时需要稍微转换一下思路。我们都知道Java中的foreach是个语法糖,编译成字节码后会被转成用迭代器遍历的方式。所以我们可以把上面的代码转换一下,等价于下面形式:
List a = new ArrayList<>();
a.add("1");
a.add("2");
Iterator it = a.iterator();
while (it.hasNext()) {
String temp = it.next();
System.out.println("temp: " + temp);
if("1".equals(temp)){
a.remove(temp);
}
}
这个时候,我们再去分析一下ArrayList的迭代器源码就能找出原因。
private class Itr implements Iterator {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
// 并发修改检测,检测不通过则抛出异常
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
// 省略不相关的代码
}
我们一步一步执行一下上面的代码,第一次进入while循环时,一切正常,元素1也被删除了。但删除元素1后,就无法再进入while循环,此时it.hasNext()为false。原因是删除元素1后,元素计数器size = 1,而迭代器中的cursor也等于1,从而导致it.hasNext()返回false。归根结底,上面的代码段没抛异常的原因是,循环提前结束,导致next方法没有机会抛异常。不信的话,大家可以把代码稍微修改一下,即可发现问题:
List a = new ArrayList<>();
a.add("1");
a.add("2");
a.add("3");
Iterator it = a.iterator();
while (it.hasNext()) {
String temp = it.next();
System.out.println("temp: " + temp);
if("1".equals(temp)){
a.remove(temp);
}
}
以上是关于遍历时删除的分析,在日常开发中,我们要避免上面的做法。正确的做法使用迭代器提供的删除方法,而不是直接删除。
三、序列化
elementData设置成了transient,那ArrayList是怎么把元素序列化的呢
transient是一个关键字,它的作用为:在不需要序列化的属性前添加transient,每当序列化对象的时候,这个属性就不会被序列化(序列化即是能够解析成字节码)。即不管数组有多大,都不会解析成字节码,不开括空间。
ArrayList里重写了writeObject方法:
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// 防止序列化期间有修改
int expectedModCount = modCount;
// 写出非transient非static属性(会写出size属性)
s.defaultWriteObject();
// 写出元素个数
s.writeInt(size);
for (int i=0; i
查看writeObject()方法可知,先调用s.defaultWriteObject()方法,再把size写入到流中,再把元素一个一个的写入到流中。
一般地,只要实现了Serializable接口即可自动序列化,writeObject()和readObject()是为了自己控制序列化的方式,这两个方法必须声明为private,在java.io.ObjectStreamClass#getPrivateMethod()方法中通过反射获取到writeObject()这个方法。
在ArrayList的writeObject()方法中先调用了s.defaultWriteObject()方法,这个方法是写入非static非transient的属性,在ArrayList中也就是size属性。同样地,在readObject()方法中先调用了s.defaultReadObject()方法解析出了size属性。
elementData定义为transient的优势,自己根据size序列化真实的元素,而不是根据数组的长度序列化元素,减少了空间占用。
四、总结
ArrayList内部使用数组存储元素,当数组长度不够时进行扩容,每次加一半的空间,ArrayList不会进行缩容;ArrayList支持随机访问,通过索引访问元素极快,时间复杂度为O(1);ArrayList添加元素到尾部极快,平均时间复杂度为O(1),添加元素到中间比较慢,因为要搬移元素,平均时间复杂度为O(n)ArrayList从尾部删除元素极快,时间复杂度为O(1);从中间删除元素比较慢,因为要搬移元素,平均时间复杂度为O(n);- 支持求并集、求交集、求单向差集。