现在由大恶人付有杰来从增删改查几个角度轻度解析ArrayList的源码
首先ArrayList的底层数据结构非常简单,就是一个数组。
从源码第115行我们可以得出信息,他的默认数组长度是10。
/**
* Default initial capacity.
*/
private static final int DEFAULT_CAPACITY = 10;
那么我们经常调用的size方法是什么呢?
源码第281行,142行
/**
* Returns the number of elements in this list.
*返回链表中元素的个数
* @return the number of elements in this list
*/
public int size() {
return size;
}
/**
* The size of the ArrayList (the number of elements it contains).
*同上
* @serial
*/
private int size;
另外,还有一个关键的属性:
//modCount 统计当前数组被修改的版本次数,数组结构有变动,就会 +1
protected transient int modCount = 0;
以上表达的意思就是说,ArrayList的默认大小是10,内部记录有自己被修改的次数,和链表中有效的元素。所谓有效的元素就是你自己添加的元素。
1.构造方法
有三种构造方法:
1.指定大小初始化
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);
}
}
清晰明了哈,如果指定的大小是大于0的,那么就用这个数字初始化,否则就初始一个空的数组。如果是非法输入(<0),就会抛出IllegalArgumentException
异常。
2.无参构造函数初始化
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
我们可以看到,无参构造 函数并不是一来就 初始化了10个长的数组,而是初始化了一个空的数组。这样能够省点空间吧。面试官问起来了,初始化的大小是10码?绝对不是哈,是0。
3.指定数据初始化
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
这个代码的意思就是,凡是继承于Collection
的,爷都能初始化。List接口继承自Collection
的。
演示一下2种姿势
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
ArrayList<Integer> arrayList = new ArrayList<>(list);
ArrayList<Integer> integers = new ArrayList<>(Arrays.asList(2, 4, 5, 6, 7, 8));
是不是很方便,如果你不知道这个方法,你还要手动去add 1 2 3 4 5.
2.新增和扩容实现
新增就是往数组中添加元素,主要分成两步:
- 判断是否需要扩容,如果需要执行扩容操作;
- 直接赋值。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
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++;
}
我们常用时第一种,第二种是在指定位置添加,把原来位置 的挤到后面去。后面的所有元素都要让一步,性能消耗会很大。
在添加元素之前,总是有一个:
//确保数组大小是否足够,不够执行扩容,size 为当前数组的大小
ensureCapacityInternal(size + 1);
我们仔细想一想是吧,你添加元素,size就加1,所以就判断size+1是否满足。
在ensureCapacityInternal(size + 1);
做了很多事情。我把相关的代码都复制过来。
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;
//原来老旧的容量除以2,加上老的容量。实锤了!!1.5倍速扩容
int newCapacity = oldCapacity + (oldCapacity >> 1);
//如果扩容了,还是不够用,那么就用你声明的值。
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//如果扩容了,大于Integer.MaxValue 就用Inter.maxValue
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);
}
上面代码,关键地方我都给出了中文解释。希望你能明白。
所以我们可以总结一下:
- 扩容的规则并不是翻倍,是原来容量大小 + 容量大小的一半,直白来说,扩容后的大小是原来容量的 1.5 倍;
int newCapacity = oldCapacity + (oldCapacity >> 1);
- ArrayList 中的数组的最大值是 Integer.MAX_VALUE,超过这个值,JVM 就不会给数组分配内存空间了。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
- 新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的。
- 源码在扩容的时候,有数组大小溢出意识,就是说扩容后数组的大小下界不能小于 0,上界不能大于 Integer
的最大值,这种意识我们可以学习。
private void rangeCheckForAdd(int index) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
扩容的本质:
扩容的本质就是新开了一个扩容的数组,然后把原来数组的元素批量赋值过去,最后修改内部的elementData引用。
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
elementData = Arrays.copyOf(elementData, newCapacity);
}
** Arrays.copyOf是调用的 System.arraycopy,后者是本地方法**
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
3.删除
ArrayList 删除元素有很多种方式,比如根据数组索引删除、根据值删除或批量删除等等,原理和思路都差不多,我们选取根据值删除方式来进行源码说明:
代码532行:
public boolean remove(Object o) {
// 如果要删除的值是 null,找到第一个值是 null 的删除
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
// 如果要删除的值不为 null,找到第一个和要删除的值相等的删除
for (int index = 0; index < size; index++)
// 这里是根据 equals 来判断值相等的,相等后再根据索引位置进行删除
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
我们需要注意的两点是:
- 新增的时候是没有对 null 进行校验的,所以删除的时候也是允许删除 null 值的;
- 找到值在数组中的索引位置,是通过 equals 来判断的,如果数组元素不是基本类型,需要我们关注 equals 的具体实现。(这个和 == 的区别,不懂自己去百度哈)
然后看看里面的fastRemove(源码544行)
private void fastRemove(int index) {
// 记录数组的结构要发生变动了
modCount++;
// numMoved 表示删除 index 位置的元素后,需要从 index 后移动多少个元素到前面去
// 减 1 的原因,是因为 size 从 1 开始算起,index 从 0开始算起
int numMoved = size - index - 1;
if (numMoved > 0)
// 从 index +1 位置开始被拷贝,拷贝的起始位置是 index,长度是 numMoved
System.arraycopy(elementData, index+1, elementData, index, numMoved);
//数组最后一个位置赋值 null,帮助 GC
elementData[--size] = null;
}
从源码中,我们可以看出,某一个元素被删除后,为了维护数组结构,我们都会把数组后面的元素往前移动(所以说,数组的删除性能开销真的很大)
4.迭代
如果要自己实现迭代器,实现 java.util.Iterator 类就好了,ArrayList 也是这样做的(内部类的方式),我们来看下迭代器的几个总要的参数:
int cursor;// 迭代过程中,下一个元素的位置,默认从 0 开始。
int lastRet = -1; // 新增场景:表示上一次迭代过程中,索引的位置;删除场景:为 -1。
int expectedModCount = modCount;// expectedModCount 表示迭代过程中,期望的版本号;modCount 表示数组实际的版本号。
迭代器一般来说有三个方法:
- hasNext 还有没有值可以迭代
- next 如果有值可以迭代,迭代的值是多少
- remove 删除当前迭代的值
public boolean hasNext() {
return cursor != size;//cursor 表示下一个元素的位置,size 表示实际大小,如果两者相等,说明已经没有元素可以迭代了,如果不等,说明还可以迭代
}
public E next() {
//迭代过程中,判断版本号有无被修改,有被修改,抛 ConcurrentModificationException 异常
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();
}
从源码中可以看到,next 方法就干了两件事情,第一是检验能不能继续迭代,第二是找到迭代的值,并为下一次迭代做准备(cursor+1)。
public void remove() {
// 如果上一次操作时,数组的位置已经小于 0 了,说明数组已经被删除完了
if (lastRet < 0)
throw new IllegalStateException();
//迭代过程中,判断版本号有无被修改,有被修改,抛 ConcurrentModificationException 异常
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
// -1 表示元素已经被删除,这里也防止重复删除
lastRet = -1;
// 删除元素时 modCount 的值已经发生变化,在此赋值给 expectedModCount
// 这样下次迭代时,两者的值是一致的了
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
- 删除元素成功,数组当前 modCount 就会发生变化,这里会把 expectedModCount 重新赋值,下次迭代时两者的值就会一致了
其他:
都说数组的添加元素 的时间复杂度是O(1),真的如此吗?
如果我们直接调用Add(x)的方法,且数组容量足够这个数组挂在后面,那么时间复杂度就是1,如果触发了扩容机制,那么就是O(N),在使用add(index,e)的时候,时间复杂度一般来说不是O1,因为要移动索引 后面的元素。
什么是falilFast的机制?
在遍历过程中,如果数据被修改,就会报错。
Iterator<Integer> iterator = arrayList.iterator();
new Thread(new Runnable() {
@Override
public void run() {
arrayList.remove(2);
}
}).start();
while (iterator.hasNext()){
Thread.sleep(50);
System.out.println(iterator.next());
}
//同上
for (Integer integer : arrayList) {
System.out.println(integer);
}
上面提到,遍历的时候会记录modCount的值,如果和自己期望的不一样,就会报错。
fail-fast解决办法
方案一:在遍历过程中所有涉及到改变modCount值得地方全部加上synchronized或者直接使用Collections.synchronizedList,这样就可以解决。但是不推荐,因为增删造成的同步锁可能会阻塞遍历操作。
方案二:使用CopyOnWriteArrayList来替换ArrayList。推荐使用该方案。
数组初始化,被加入一个值后,如果我使用 addAll 方法,一下子加入 15 个值,那么最终数组的大小是多少?
分析:在加入一个元素的时候,数组被初始化成10个,然后一下子加入15个,那么就会触发扩容,在初次扩容后,大小变成了15(1.5倍速度扩容),发现 还是不够用,就会使用1+15这个值作为容量。所以答案是16.
再贴一遍源码
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
//原来老旧的容量除以2,加上老的容量。实锤了!!1.5倍速扩容
int newCapacity = oldCapacity + (oldCapacity >> 1);
//如果扩容了,还是不够用,那么就用你声明的值。
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//如果扩容了,大于Integer.MaxValue 就用Inter.maxValue
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);
}
现在我有一个很大的数组需要拷贝,原数组大小是 5k,请问如何快速拷贝?
因为原数组比较大,如果新建新数组的时候,不指定数组大小的话,就会频繁扩容,频繁扩容就会有大量拷贝的工作,造成拷贝的性能低下,所以回答说新建数组时,指定新数组的大小为 5k 即可。
所以大恶人付有杰建议,平常自己心知肚明的时候,自己手动指定大小。
还有ArrayList删不干净的问题:
List<Integer> list = new ArrayList<>(Arrays.asList(1,2,2,2,2,3));
for(int i = 0;i<list.size();i++){
if(list.get(i).equals(2)){
list.remove(i);
}
}
System.out.println(list.toString());
代码输出:
兄弟们自己去想吧。