吊打面试官之 ArrayList 详解
ArrayList 概述
ArrayList
实现了 List
接口,其实就是一个数组列表,不过作为 Java 的集合框架,它只能存储对象引用类型,也就是说当我们需要装载的数据是诸如 int
、float
等基本数据类型的时候,必须把它们转换成对应的包装类。
ArrayList
的底层实现是一个 Object
数组:
transient Object[] elementData;
既然它是基于数组实现的,数组在内存空间中是连续分配的,那必然查询速率非常快,不过当然也肯定逃不过增删效率低的缺陷。
另外,和 ArrayList
一样同样实现了 List
接口的、我们比较常用的还有 LinkedList
。LinkedList
比较特殊,它不仅实现了 List
接口,还实现了 Queue
接口,所以你可以看见 LinkedList
经常被当作队列使用:
Queue<Integer> queue = new LinkedList<>();
LinkedList
人如其名,它的底层自然是基于链表的,而且还是个双向链表。链表的特性和数组正好是反的,由于没有索引,所以查询效率低,但是增删速度快。
ArrayList 如何指定底层数组大小的
既然咱真正存储数据的地方是数组,那我们初始化 ArrayList
的时候自然要给数组分配一个大小,开辟一个内存空间。我们先来看看 ArrayList
的无参构造函数:
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
可以看到,它为底层的 Object
数组也就是 elementData 赋值了一个默认的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA
。也就是说,使用无参构造函数初始化 ArrayList
后,它当时的数组容量为 0 。
这给咱初始化一个容量为 0 的数组有啥用?啥也存不了啊?别急,如果使用了无参构造函数来初始化ArrayList
, 只有当我们真正对数据进行添加操作 add
时,才会给数组分配一个默认的初始容量DEFAULT_CAPACITY = 10
,源码如下:
public void add(int index, E element) {
rangeCheckForAdd(index);
// 第一步
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}
private void ensureCapacityInternal(int minCapacity) {
// 第二步
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 第三步
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
// 初始容量
private static final int DEFAULT_CAPACITY = 10;
说完了无参构造,ArrayList
的有参构造函数就是中规中矩了,按照用户传入的大小开辟数组空间:
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);
}
}
扩容机制
ArrayList
的底层实现是 Object
数组,我们知道,数组的大小一旦被规定就无法改变。那如果我们不断的往里面添加数据的话,ArrayList
是如何进行扩容的呢?或者说 ArrayList 是如何实现存放任意数量对象的呢?
OK,扩容发生在啥时候?那肯定是我们往数组中新加入一个元素但是发现数组满了的时候。没错,我们去 add
方法中看看 ArrayList
是怎么做扩容的:
public void add(int index, E element) {
rangeCheckForAdd(index);
// 第一步
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}
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);
}
ensureExplicitCapacity
判断是否需要进行扩容,很显然,grow
方法是扩容的关键:
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 关键步骤
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
看上面代码的关键步骤就知道怎么扩容的了:扩容后的数组长度 = 当前数组长度 + 当前数组长度 / 2。最后使用 Arrays.copyOf
方法直接把原数组中的数组 copy 过来,需要注意的是,Arrays.copyOf
方法会创建一个新数组然后再进行拷贝。
添加数据
add
方法我们刚刚讲了一半,添加数据前会先判断一下是否需要扩容,真正的添加数据的操作在下半部分:
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
先讲下 add(int index, E element)
这个方法的含义,就是在指定索引 index 处插入元素 element。比如说 ArrayList.add(0, 3)
,意思就是在头部插入元素 3。
再来看看 add
方法的核心 System.arraycopy
,这个方法有 5 个参数:
- elementData:源数组
- index:从源数组中的哪个位置开始复制
- elementData:目标数组
- index + 1:复制到目标数组中的哪个位置
- size - index:要复制的源数组中数组元素的数量
不用多说,ArrayList 的将数据插入到指定位置的操作性能非常低下,因为要开辟新数组复制元素啊,要是涉及到扩容那就更慢了。另外,ArrayList
还内置了一个直接在末尾添加元素的 add
方法,不用复制数组,直接 size ++ 就好,这个方法应该是我们最常使用的:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
删除元素
删除元素就是 remove 方法,源码如下:
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
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
return oldValue;
}
也很简单,就是将原数组复制一份,然后把原数据中从 index + 1到数组末尾的元素,放到新数组的 index 位置上,将index上的元素覆盖,就给你你被删除了的感觉,效率也是释放低下的。
线程安全问题
ArrayList
和 LinkedList
都不是线程安全的,我们以在末尾添加元素的 add
方法为例,来看看 ArrayList
线程不安全的表现是啥:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
// 关键代码
elementData[size++] = e;
return true;
}
上面的关键代码并不是原子操作,而是由两步操作构成:
elementData[size] = e;
size = size + 1;
在单线程执行这两条代码时,那当然没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程添加的值覆盖另一个线程添加的值。举个例子:
- 假设 size = 0,我们要往这个数组的末尾添加元素
- 线程 A 开始添加一个元素,值为 A。此时它执行第一条操作,将 A 放在了数组 elementData 下标为 0 的位置上
- 接着线程 B 刚好也要开始添加一个值为 B 的元素,且走到了第一步操作。此时线程 B 获取到的 size 值依然为 0,于是它将 B 也放在了 elementData 下标为 0 的位置上
- 线程 A 开始增加 size 的值,size = 1
- 线程 B 开始增加 size 的值,size = 2
这样,线程 A、B 都执行完毕后,理想的情况应该是 size = 2,elementData[0] = A,elementData[1] = B。而实际情况变成了 size = 2,elementData[0] = B(线程 B 覆盖了线程 A 的操作),下标 1 的位置上什么都没有。并且后续除非我们使用 set 方法修改下标为 1 的值,否则这个位置上将一直为 null,因为在末尾添加元素时将会从 size = 2 的位置上开始。
ArrayList
的线程安全版本是 Vector
,它的实现很简单,就是把所有的方法统统加上 synchronized
:
public synchronized void addElement(E obj) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = obj;
}
既然它需要额外的开销来维持同步锁,所以理论上来说它要比 ArrayList
要慢。为什么线程不安全还要用它呢?
因为在大多数场景中,查询的情况居多,不会涉及太频繁的增删。那如果真的涉及频繁的增删,可以使用LinkedList
,底层链表实现,为增删而生。而如果你非得保证线程安全那就使用 Vector
。当然实际开发中使用最多的还是 ArrayList
,虽然线程不安全、增删效率低,但是查询效率高啊。
引用: