ArrayList作为平常使用最多的集合之一,今天就彻彻底底地搞懂它,话不多说,开干!博主拒绝做标题党,相信我,看完你一定有所收获~
博主的另一篇文章:深入LinkedList底层(一文搞定):
全面了解ArrayList
别急,整体把控继承实现关系
-
实现了RandomAccess接口,可以随机访问
-
实现了Cloneable接口,可以克隆
-
实现了Serializable接口,可以序列化、反序列化
-
实现了List接口,是List的实现类之一
-
实现了Iterable接口,可以使用for-each迭代
三大接口分析
首先三大接口都是标志接口,点开源码可以发现接口中什么代码也没有,只是起到一个标志作用,所以叫标志接口
在这里三大接口详细展开篇幅过长,如果想深入了解相关的特性,博主精挑细选,看完相信会对你有所帮助:
Serializable接口:Serializable详解
Cloneable接口:Cloneable实现深拷贝和浅拷贝
RandomAccess接口:RandomAccess接口实现随机访问
看看属性
//序列化ID
private static final long serialVersionUID = 8683452581122892189L;
//默认的容器初始容量
private static final int DEFAULT_CAPACITY = 10;
//用于空实例的共享空数组实例,容量为0的时候给数组变量赋值
//new ArrayList(0);
private static final Object[] EMPTY_ELEMENTDATA = {
};
//用于提供默认大小的实例的共享空数组实例
//new ArrayList()
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {
};
//底层的数组存储结构
transient Object[] elementData;
//当前集合中元素的个数
private int size;
这里你肯定会有许多疑问
为什么同样是空数组,非要用两个来区分呢?
先说结论,再结合后文中构造方法,扩容机制深入理解
数组为EMPTY_ELEMENTDATA
就走基于用户设置大小值进行1.5倍扩容,数组为默认空DEFAULTCAPACITY_EMPTY_ELEMENTDATA
就会走基于默认值的大小10扩容进行1.5倍扩容
为什么elementData要用transient修饰?
elementData之所以用transient修饰,是因为JDK不想将整个elementData都序列化或者反序列化,而只是将size和实际存储的元素序列化或反序列化,从而节省空间和时间。
构造方法
空参构造器
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
指定容量构造器
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,就使用 EMPTY_ELEMENTDATA
- 如果指定初始容量小于0,就抛出异常
指定集合构造器
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;
}
}
这里可以发现elementData.getClass() != Object[].class
这个判断条件到底有什么用呢?
ArrayList
底层中elementData[]
是Object
类型,为了防止没有返回指定类型,那么问题来了,什么情况下c.toArray()会不返回Object[]呢?
详情可以点击:什么情况下c.toArray()会不返回Object[]
插入方法
在列表尾部插入元素
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
这里调用ensureCapacityInternal()
方法确保容器的容量足够容纳新的元素,然后再size++的位置插入新元素,接下来看看ensureCapacityInternal()
方法
1.来看calculateCapacity()
方法,从这里也可以看出,当使用默认构造器创建容器时即new ArrayList()
时,并不是一开始就创建了容量为10的数组,而是在第一次添加元素时为容量赋值
若是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
则以默认为10的容量和赋值的容量的最大值作为数组的最小容量,否则以赋值的容量为最小容量
2.接下来调用ensureExplicitCapacity()
方法
首先
modCount
是父类AbstractList
的属性
用于记录对象的修改次数,可以类比为并发中乐观锁的实现机制中version字段,在特定的操作下需要对version进行检查,适用于Fail-Fast机制,后文讲到
接下来如果最小容量大于了数组的长度说明需要扩容,这里调用了grow()方法
grow()
方法会在稍后详细说明扩容机制,这里就是将数组扩容,执行反之后,已经确保了容器有足够的容量能够装下新加入的元素,就返回到add()
方法中,接着执行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++;
}
private void rangeCheckForAdd(int index) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
这里的逻辑十分明了,首先确保index是合法范围,接着确保容量,然后调用System.arraycopy()
方法,就是将index后的元素全部后移一位,然后再index位置插入元素,最后size++
这里缺点很明显
这里的插入算法最坏的情况向头元素插入节点时间复杂度达到o(n),所以对于频繁插入删除的情况,并不适合使用ArrayList,以链表尾底层结构的LinkedList更能胜任
扩容方法(核心)
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
//扩容1.5倍,位运算快于除法
int newCapacity = oldCapacity + (oldCapacity >> 1);
//假设构造方式new ArrayList(0),oldC为0,newC一直为0,
//那么永远无法扩容,所以这里判断一下,扩容到minCapacity
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//大型数组
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 这里的copyOf是浅复制
elementData = Arrays.copyOf(elementData, newCapacity);
}
-
通常情况新容量是原来容量的1.5倍
-
如果原容量的1.5倍比minCapacity小,那么就扩容到minCapacity
-
特殊情况扩容到Integer.MAX_VALUE
到这里相信你也明白为什么要用两个不同的空数组了
- 当你使用new ArrayList()默认方式构造时,使用
EFAULTCAPACITY_EMPTY_ELEMENTDATA
在第一次添加元素时,会扩容到10,然后基于10进行1.5倍扩容 - 当你使用new ArrayList(0)构造容量为0的容器时,使用
EMPTY_ELEMENTDATA
在第一次添加元素时,基于自定义容量1.5倍扩容
其他的一些常用方法
移除指定下标元素
/**
* 移除列表中指定下标位置的元素
* 将所有的后续元素,向左移动
*
* @param 要移除的指定下标
* @return 返回被移除的元素
* @throws 下标越界会抛出IndexOutOfBoundsException
*/
public E remove(int index) {
//范围检测
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
//将index后的所有元素向前复制
System.arraycopy(elementData,
index+1, elementData, index, numMoved);
// 将引用置空,让GC回收
elementData[--size] = null;
return oldValue;
}
删除指定元素
/**
* 移除第一个在列表中出现的指定元素
* 如果存在,移除返回true
* 否则,返回false
*
* @param o 指定元素
*/
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;
}
//这里的fastRemove()方法,不进行边界检测,也不返回删除的值,效率较高
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 将引用置空,让GC回收
elementData[--size] = null;
}
查找指定元素的位置
/**
* 返回指定元素第一次出现的下标
* 如果不存在该元素,返回 -1
* 如果 o ==null 会特殊处理
*/
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
查找指定位置的元素
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
序列化方法
记得开头提到的,ArrayList()方法实现了Serial able接口吗?这里就是具体的序列化操作,将ArrayLisy实例的状态保存到一个流里面
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
反序列方法,根据一个流(参数)重新生成一个ArrayList
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;
// Read in size, and any hidden stuff
s.defaultReadObject();
// Read in capacity
s.readInt();
if (size > 0) {
// be like clone(), allocate array based upon size not capacity
ensureCapacityInternal(size);
Object[] a = elementData;
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
序列化反序列化,开头中的elementData被transient修饰,意义就在此,并没有直接把整个数组序列化,而是序列化size和具体的存储元素,加快了效率
fail-fast(快速失败机制)
在前文讲述modCount变量时埋下了一个伏笔,这里就来解释
无论在单线程还是多线程的工作前提下,都是有可能发生fail-fast机制
以单线程为栗子,十分简单,具体做法就是在iterator遍历时,对集合内容发生了改变,多线程同理,当一个线程正在使用itr遍历,另一个线程修改了集合内容,就会抛出以下异常
public static void main(String[] args) throws CloneNotSupportedException {
ArrayList<Object> objects = new ArrayList<>();
for(int i = 0;i<3;i++){
objects.add(i);
}
Iterator<Object> iterator = objects.iterator();
while(iterator.hasNext()){
objects.add(3);
System.out.println(iterator.next());
}
}
}
运行一下就会发现,程序抛出了ConcurrentModificationException
异常
具体的实现原理?
原理很简单,就是在ArrayList源码中你可以发现,crud操作都会更新modCount的值,当迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。
每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedModCount 值,是的话就返回遍历;否则抛出异常,终止遍历。
如何解决?
1.在单线程中,我们调用remove方法的时候可以换成迭代器的remove方法而不是集合类的remove方法。
2.使用并发包中的容器,涉及到安全失败(fail-safe)
机制
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历,待修改完毕后再指向旧数组指向新数组
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException 异常
CopyOnWriterArrayList
代替 ArrayList, CopyOnWriterArrayList在使用上跟 ArrayList几乎一样,底层原理就用到了上述的安全失败的机制
ArrayList是线程不安全的
举一个简单的栗子你就明白了,再add()方法中我们可以看到,添加操作并不是原子操作,而是分为了两步,首先检测容量并扩容,其次设置元素
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
现在有多条线程来操作这个集合:
1.假设此时容器元素数目size=9,两个线程同时来操作集合
2.线程A调用add方法,这时它获取到size的值为9,调用ensureCapacityInternal
方法进行容量判断。
3.线程B同时也来调用了add方法,同样它获取到size的值为9,调用ensureCapacityInternal
方法进行容量判断。
4.此时线程A发现集合的容量为10,刚刚好够自己放下,所以设置元素
5.线程B也来了,也发现集合容量为10,以为够自己放下,其实位置已经被占用了,这时,线程B再添加元素时,就会抛出ArrayIndexOutOfBoundsException.
异常
如何解决?
1.使用线程安全的集合:Vector(底层就是将全部的方法都加上sync,所以效率低下也是难免的)
2.Collections.synchronizedList(new ArrayList<>());
将集合构造成线程安全的,其实原理一样,将ArrayList中各个方法都加上sync锁
不存在一个集合工具是查询效率又高,增删效率也高的,还线程安全的,至于为啥大家看代码就知道了,因为数据结构的特性就是优劣共存的,想找个平衡点很难,牺牲了性能,那就安全,牺牲了安全那就快速。
最后来一个小bug看看你是否明白了ArrayList机制
public static void main(String[] args) throws CloneNotSupportedException {
ArrayList<Object> list = new ArrayList<>(10);
System.out.println(list.size());
list.set(5,1);
}
运行以下你会发现什么?如果没有看过源码你一定会疑惑?为什么我明明声明了一个容量为10的容器,为什么设置下标为5的位置会抛出IndexOutOfBoundsException
的异常
原因很简单,无论是有参还是无参构造,初始化的过程并不会赋予初始容量,而是赋值了两个空数组,再第一次添加元素之后才会设置容量,所以这就是问题所在
这篇文章全面且深入的带你了解了ArrayList的方方面面,如果你觉得不错,点个小小的赞,就是对作者最大的鼓励