前面做了这么多准备,终于开始了哈,ArrayList开淦!ArrayList应该是我们使用最频繁的集合类了吧,我们先来看看文档是怎么介绍它的。
我们可以知道,ArrayList其实就是Vector的翻版,只是去除了线程安全。ArrayList是一个可以动态调整大小的List实现,其数据的顺序与插入顺序始终一致,其余特性与List一致。
一、ArrayList继承结构
从结构图中可以看到,ArrayList是AbstractList的子类,同时实现了List接口。除此之外,它还实现了三个标识型接口,这几个接口都没有任何方法,仅作为标识表示实现类具备某项功能。RandomAccess表示实现类支持快速随机访问,Cloneable表示实现类支持克隆,具体表现为重写了clone方法,java.io.Serializable则表示支持序列化,如果需要对此过程自定义,可以重写writeObject与readObject方法。
但是ArrayList这一部分面试高频问的点主要是在 ArrayList的初始大小是多少? 在初始化ArrayList时,可能都是直接调用无参构造函数,从未了解或者关注过此类问题,就像下面这样:
ArrayList<String> strings = new ArrayList<>();
我们在前几次的内容中也提到过,ArrayList是基于数组的,而且呢,数组的长度也是不可变的,那就有这么一个问题,既然数组是定长的,那为什么ArrayList为什么不需要指定长度而就可以实现既可以插入一条数据,也可以插入几千甚至上万条数据呢?
ArrayList可以动态调整其大小,所以我们才可以无感知的插入多条数据,这也就说明了ArrayList一定有一个默认的大小。而想要扩充其大小,只能通过复制,这样一来,默认大小以及如何动态调整其大小就会对其性能产生非常大的影响,接下俩我们来举一个例子来说明此情况:
比如一个默认大小为10,我们向其中插入10条数据,此时并没有什么影响,如果我们还想在插入20条数据,就需要将ArrayList的大小调整到30,此时就涉及到一次数组的复制,如果还想继续在插入50条数据呢,那就又要通过复制数组,把大小调整到80.也就是说,当容量已用完或者不够用时,我们每向其中插入一条数据,都要涉及到一次数据的拷贝,而且数据越大,需要拷贝的数据就越多,其性能也会迅速下降。
ArrayList仅仅是对数组的一个封装,里面肯定是采取了一些措施来解决以上我们所提到的问题,我们如果不利用这些来提升性能的措施,那和使用数组又有什么区别呢。就让我们一起来看看ArrayList采取了哪些措施,并且怎么去使用它们吧?
我们先从初始化说起。
二、ArrayList构造方法与初始化
ArrayList一共有三个构造方法,用到了以下两个成员变量:
//这是一个用来标记存储容量的数组,也是存放实际数据的数组。
//当ArrayList扩容时,其capacity就是这个数组应有的长度。
//默认时为空,添加进第一个元素后,就会直接扩展到DEFAULT_CAPACITY,也就是10
//这里和size区别在于,ArrayList扩容并不是需要多少就扩展多少的
transient Object[] elementData;
//这里就是实际存储的数据个数了
private int size;
这里说明一下哈:经过transient关键字修饰的字段是不能够被序列化的。
除了以上两个变量,还需要掌握一个变量,它就是:
protected transient int modCount = 0;
这个变量的主要作用
就是防止在进行一些操作时,改变了ArrayList的大小,那将使得结果变得不可预测。
除了这些,还有一些:
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
下面我们来看看构造函数:
1、构造函数
//默认构造方法。文档说明其默认大小为10,但正如elementData定义所言,
//只有插入一条数据后才会扩展为10,而实际上默认是空的
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//带初始大小的构造方法,一旦指定了大小,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);
}
}
//从一个其他的Collection中构造一个具有初始化数据的ArrayList。
//这里可以看到size是表示存储数据的数量
//这也展示了Collection这种抽象的魅力,可以在不同的结构间转换
public ArrayList(Collection<? extends E> c) {
//转换最主要的是toArray(),这在Collection中就定义了
elementData = c.toArray();
if ((size = elementData.length) != 0) {
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
可以看到,在默认的无参构造器中,这个所创建的ArrayList实际上还是
空的,只有当插入一条数据后,容量才会扩展到默认的10.这里要注意到。
二、ArrayList中重写的方法
我们都知道,ArrayList已经是一个具体的实现类了,所以在List接口中定义的所有方法都在其中被实现。ArrayList中有一些已经在AbstractList实现过的方法,但事在这里再次被重写,我们来看一看有什么不同。
先来看一些较简单的方法:
//还记得在AbstractList中的实现吗?那是基于Iterator完成的。
//在这里完全没必要先转成Iterator再进行操作
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;
}
//和indexOf是相同的道理
public int lastIndexOf(Object o) {
//...
}
//一样的道理,已经有了所有元素,不需要再利用Iterator来获取元素了
//注意这里返回时把elementData截断为size大小
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}
//带类型的转换,看到这里a[size] = null;这个用处真不大,除非你确定所有元素都不为空,
//才可以通过null来判断获取了多少有用数据。
public <T> T[] toArray(T[] a) {
if (a.length < size)
// 给定的数据长度不够,复制出一个新的并返回
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
看完这几个简单的,我们再来看看增删改查,而在增删改查中,改和查都不涉及到数组长度的变化,而增删就涉及到动态调整大小的问题,也就与性能挂钩,那我们就来先看看改和查是怎么实现的:
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
//获取对应下标处的元素
E elementData(int index) {
return (E) elementData[index];
}
//只要获取的数据位置在0-size之间即可
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
//改变下对应位置的值
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
增和删是ArrayList最重要的部分,这部分代码需要我们仔细去推敲,搞明白,我们来看看源码是如何实现的
//在最后添加一个元素
public boolean add(E e) {
//先确保elementData数组的长度足够
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
//先确保elementData数组的长度足够
ensureCapacityInternal(size + 1); // Increments modCount!!
//将数据向后移动一位,空出位置之后再插入
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
小伙伴们,应该不难发现,以上两种方法都用到了 ensureExplicitCapacity方法,我们来看看这方法是怎么实现的:
//在定义elementData时就提过,插入第一个数据就直接将其扩充至10
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
//这里把工作又交了出去
ensureExplicitCapacity(minCapacity);
}
//如果elementData的长度不能满足需求,就需要扩充了
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倍扩充的
int newCapacity = oldCapacity + (oldCapacity >> 1);
//扩充完之后,还是没满足,这时候就直接扩充到minCapacity
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);
}
我们来分析分析整个add方法过程:
add(E e),在list末尾添加元素:
这里我们假设当前已有一个stuList,其size为7,。
第一步:
因为是在list末尾添加元素,所以扩容时所需要的最小容量为当前的 size+1;也就是8
第二步:
这里的ensureCapacityInternal方法体里是调用的是ensureExplicitCapacity方法,传入的参数是calculateCapacity方法的返回值minCapacity ,我们先来看看参数部分
第三步:
可以看到,这个方法传进来的参数一个是当前list的数组,也就是当前stuList的容量为8的数组,还有一个呢就是就是第一步里的minCapacity(8)。如果当前数组是调用空参构造器创建ArrayList时的默认空数组,那就返回最小容量和默认容量(10)二者中较大的一个,如果不是的话,就返回 size+1 的最小容量。这里不是默认空数组,所以就返回8.
第四步:
modCount是记录当前集合被修改的次数。这里如果最小容量大于当前数组的长度,8>7,所以到下一步grow,来扩展器容量。
第五步:
将当前的数组未改变的长度赋值给oldCapacity(7),newCapacity的值为旧容量+旧容量÷2(此时新容量10,最小容量为8),然后在比较,如果新容量小于最小容量的话,新容量就在赋值为最小容量(新容量还是为10),在下一步,就是防止溢出了,如果新容量大于最大的数组长度(Integer.MAX_VALUE - 8),就执行hugeCapacity方法:
返回值为:如果最小容量> MAX_ARRAY_SIZE,就返回int的最大上限,否则就返回数组的最大长度。接着上面继续,然后复制数组,将之前的元素复制过去,并且size变为newCapacity。
最后一步:
再在数组的最后将元素添加进去。
其他几个add方法这里就不一 一说明,其实现原理与上面 大同小异。
代码我们看到这里,我猜大家差不多也都明白了。ArrayList的扩容机制,其实就是:首先 创建一个空数组elementData,第一次插入数据时,直接扩展到10,如果elementData 的长度不够,就扩充1.5倍,如果还是不够的话,就使用需要的长度作为elementData的长度。
这样的方式显然比我们例子中好一些,但是在遇到大量数据时还是会频繁的拷贝数据。那么如何缓解这种问题呢,ArrayList为我们提供了两种可行的方案:
1、 使用ArrayList(int initialCapacity)这个有参构造,在创建时就声明一个较大的大小,这样解决了频繁拷贝问题,但是需要我们提前预知数据的数量级,也会一直占有较大的内存。
2、 除了添加数据时可以自动扩容外,我们还可以在插入前先进行一次扩容。只要提前预知数据的数量级,就可以在需要时直接一次扩充到位,与ArrayList(int initialCapacity)相比的好处在于不必一直占有较大内存,同时数据拷贝的次数也大大减少了。这个方法就是ensureCapacity(int minCapacity),其内部就是调用了ensureCapacityInternal(int minCapacity)。
还有一些add方法类似的方法,例如addAll等等方法,其实现原理也大同小异,这里我们就不一 一分析了,这里我把他列举出来,想深入了解可以自己去看看源码
//将elementData的大小设置为和size一样大,释放所有无用内存
public void trimToSize() {
//...
}
//删除指定位置的元素
public E remove(int index) {
//...
}
//根据元素本身删除
public boolean remove(Object o) {
//...
}
//在末尾添加一些元素
public boolean addAll(Collection<? extends E> c) {
//...
}
//从指定位置起,添加一些元素
public boolean addAll(int index, Collection<? extends E> c){
//...
}
接下来我们在看看删 remove方法
先查看下标是否越界,然后计数当前几个被修改的次数,根据下标把要删除的元素先找到,然后再用arrayCopy方法来处理在,这里我们来举一个例子来看一下数组复制这个过程:
一个数组nums{a,b,c,d,e,f,g},我们要删除下标为3的元素,也就是d,源数组elementData,从下标index+1开始复制,目标数组elementData,从目标数组的下标index开始,复制numMoved个元素,System.arrayCopy(nums,4,nums,3,3)的结果就为{a,b,c,e,f,g}.
复制完之后,在把移动之后最后一个地方设为null。
其他几个与remove有关的方法其删除原理与上面大同小异。
ArrayList还对其父级实现的ListIterator以及SubList进行了优化,主要是使用位置访问元素,这里就不一 一说明了,感兴趣的小伙伴可以自己去看下源码。
三、 ArrayList其他的一些实现方法
ArrayList不仅实现了List中定义的所有功能,还实现了equals、hashCode、clone、writeObject与readObject等方法。这些方法都需要与存储的数据配合,否则结果将是错误的或者克隆得到的数据只是浅拷贝,或者数据本身不支持序列化等,这些我们定义数据时注意到即可。我们主要看下其在序列化时自定义了哪些东西。
//这里就能解开我们的迷惑了,elementData被transient修饰,也就是不会参与序列化
//这里我们看到数据是一个个写入的,并且将size也写入了进去
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]);
}
//modCount的作用在此体现,如果序列化时进行了修改操作,就会抛出异常
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
readObject是一个相反的过程,就是把数据正确的恢复回来,并将elementData设置好即可,感兴趣可以自行阅读源码。
四、ArrayList线程不安全
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class NoSafeArrayList {
public static void main(String[] args) {
List<String> list=new ArrayList();
for (int i=0;i<30;i++) {
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(8)); //UUID工具类,取一个八位的随机字符串 ,还有一个常用的取不重复字符串的方法:system.currentTime() 当前时间戳
System.out.println(list);
}).start();
}
}
}
ArrayList类在多线程环境下是线程不安全的,在多线程读写情况下会抛出并发读写异常(ConcurrentModificationException)
ArrayList线程不安全主要体现在两个方面:
一、不是原子操作
elementData[size++] = e;
先赋值,size在+1
但线程执行这一段代码没什么毛病,但是在多线程环境下,问题可就大了。可能其中一个线程会覆盖另一个线程的值。
举个例子:
1、 列表为空 size = 0。
2、 线程 A 执行完 elementData[size] = e;之后挂起。A 把 “a” 放在了下标为 0 的位置。此时 size = 0。
3、 线程 B 执行 elementData[size] = e; 因为此时 size = 0,所以 B 把 “b” 放在了下标为 0 的位置,于是刚好把 A 的数据给覆盖掉了。
4、 线程 B 将 size 的值增加为 1。
5、 线程 A 将 size 的值增加为 2。
这样子,当线程 A 和线程 B 都执行完之后理想情况下应该是 “a” 在下标为 0 的位置,“b” 在标为 1 的位置。而实际情况确是下标为 0 的位置为 “b”,下标为 1 的位置啥也没有。
二、扩容时非原子操作
ArrayList 默认数组大小为 10。假设现在已经添加进去 9 个元素了,size = 9。
1、 线程 A 执行完 add 函数中的ensureCapacityInternal(size + 1)挂起了。
2、 线程 B 开始执行,校验数组容量发现不需要扩容。于是把 “b” 放在了下标为 9 的位置,且 size 自增 1。此时 size = 10。
3、 线程 A 接着执行,尝试把 “a” 放在下标为 10 的位置,因为 size = 10。但因为数组还没有扩容,最大的下标才为 9,所以会抛出数组越界异常ArrayIndexOutOfBoundsException。
五、总结
1、 ArrayList其底层其实用一个elementData数组实现的。
2、 ArrayList与数组不同的点就在于ArrayList的grow方法,可以实现自动扩容。
3、 ArrayList是可以存放null值的哦
4、 ArrayList还是和数组一样,更适合于数据随机访问(查和改),而不太适合于大量的插入与删除,插入和删除还是用LinkedList好一些。
如果补充的不到位,欢迎小伙伴们留言补充!