文章目录
1、List简介
List结构在Java集合体系中属于有序集合,List接口扩展了Collection接口并声明了存储元素序列的集合的行为。支持元素的随机访问,可在列表中插入或者删除某个元素,也可根据索引访问元素,是一种线性数据结构。
与集合不同的是,List中允许重复元素。下面我们来说说List接口的两个比较常见的实现类ArrayList和LinkedList
2、ArrayList
ArrayList是一种基于动态数组的容器,这种容器将数组的某些操作(例如插入、删除)封装起来,对外提供方法调用,将细节隐藏在方法内部。由于性能等方面的考量,ArrayList是非线程安全的类,接下来我们就来扒一扒ArrayList的底层是怎么实现的。
2.1 初始化
// 默认列表的初始容量
private static final int DEFAULT_CAPACITY = 10;
// 定义一个空数组,用于一个空的实例
private static final Object[] EMPTY_ELEMENTDATA = {};
// 用于默认大小空实例的共享空数组实例
// 我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// object数组定义
transient Object[] elementData;
// ArrayList所包含的元素的数量
private int size;
//带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
// 如果参数等于0,则创建空数组
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
// 默认大小的构造方法
// DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。
public ArrayList(Collection<? extends E> c) {
//将指定集合转换为数组
elementData = c.toArray();
//如果elementData数组的长度不为0
if ((size = elementData.length) != 0) {
// 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断)
if (elementData.getClass() != Object[].class)
//将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// 其他情况,用空数组代替
this.elementData = EMPTY_ELEMENTDATA;
}
}
通过上面的代码可以看到,ArrayList内部定义了元素的容器的初始容量为10,并且定义了EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA这两个object数组。通过构造方法我们知道,如果调用有参构造方法,可传入一个指定容量的参数。如果参数大于0,内部会new一个指定大小的object数组并赋值给this.elementData,如果等于0则将一个空实例赋值给this.elementData。对于默认的构造方法,是直接将DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个默认空实例赋值给elementData,你可能会问,为什么要定义两个这样的空实例,它们有什么不同。实际上在每次对容器添加元素的时候,会有一个监测容量的逻辑,第一次我们调用默认构造函数时,容器并不会一开始就帮我们创建一个默认大小容量的数组,而是一个空实例,当我们田间元素时,由于容量为0,所以会trigger扩容机制,这时才会真正创建一个有具体容量的数组,其原因在于尽量减少创建数组的系统开销,时一种性能上的考量,在后面我们剖析扩容机制的时候会讲到。
2.2、方法底层剖析
2.2.1 获取容器中元素的数量
public int size() {
return size;
}
我们看到由于容器内部维护了一个size变量,每次添加一个元素都会将这个变量值加1,因此调用这个方法时,直接返回变量值即可。
2.2.2 判断是否包含某元素
// 判断是否包含某元素
public boolean contains(Object 0) {
return indexOf(o) >= 0;
}
// 返回指定元素所在容器的索引
public int indexOf(Object o) {
return indexOfRange(o, 0, size);
}
int indexOfRange(Object o, int start, int end) {
Object[] es = elementData;
// 如果元素为null
if (o == null) {
// 遍历容器,找到为null的元素返回索引
for (int i = start; i < end; i++) {
if (es[i] == null) {
return i;
}
}
} else {
for (int i = start; i < end; i++) {
if (o.equals(es[i])) {
return i;
}
}
}
return -1;
}
可知,在上述代码中,contains方法调用了indexOf()方法,最终通过调用indexOfRange()方法,如果要查询的元素为null,则从头遍历容器并使用 “==” 判断,注意这里是判断是否为同一个对象。如果传入元素不为空,也从头遍历,用equals()方法判断元素是否相同,注意两者的区别。因此ArrayList查找给定元素的时间复杂度为O(n)。
2.2.3 根据索引获取元素
public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
由于ArrayList本身就是基于数组实现的,因此在支持随机访问,直接通过寻址公式可以在O(1)的复杂度找到元素。注意呲方法会有一个索引是否合法的检测方法。
2.2.4 添加元素
public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
modCount++;
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow();
// 数组复制
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
elementData[index] = element;
size = s + 1;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
我们先来看默认的add()方法,直接在list末尾添加元素,最终调用的add(E e, Object[] elementData, int s)这个私有方法。在这个方法中,会首先判断容器中是否还有空间容纳此元素,如果没有则trigger扩容机制,扩容机制我们后面讲解;如果有则直接在size处插入此元素,由于数组索引从0 开始,因此数组中元素数量size就是下一个空位置的索引。
对于在指定位置插入元素的方法,首先会检测所给索引的合法性,如果不合法则直接回抛出异常。如果合法,则判断容器中元素的数量是否等于容器的容量,如果等于则说明容器容量满了,则trigger扩容机制,否则不进行扩容。然后会根据要插入的位置将元素插入进去,我们知道在数组中如果我们要在某个位置插入一个元素,需要将后面的元素依次往后挪动,我们看到这里调用了一个arraycopy的静态方法。我们重点看看这个方法。这个方法实际上是System类提供的一个native静态方法这个方法用来实现数组之间的复制,它的原型如下:
@IntrinsicCandidate
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
src: 源数组;
srcPos: 源数组要复制的起始位置;
dest: 目标数组;
destpos:目标数组要放置的起始位置;
length:复制的长度;
结合调用情况可以知道,源数组就是elementData,起始位置就是我们要插入的位置,而目标数组也是elementData,要复制的位置是从要插入的位置的后一位开始,长度则是后面所有的元素。显然,源数组和目标数组式同一个数组,通过数组后面的复制可以将要插入的位置一次往后挪一个位置,和原来数组的移动是一个道理,以此来实现元素位置的移动。注意(重点来了),这个方法的复制,属于浅复制,也就是说,假如要复制的数组元素是一个基本类型,那么这种复制是值的复制,目标数组元素改变并不会影响源数组,如果数组是一个对象数组,那么复制的仅仅是这个元素对象的引用,因此改变目标数组,源数组的元素对象也会改变。同理,remove()方法也是一样,这个方法内部会调用一个fastRemove()方法,在这个方法内部,也会使用arraycopy()方法实现数组元素的移动,这里就不再赘述。
2.3、ArrayList扩容机制
刚才我们说到往容器中添加元素的时候,需要进行容量检测,若容量不足则需要扩容。我们知道数组之所以能够随机访问是由于预先分配了一块连续的内存区域,我们只需要通过起始地址配合offset 和对象的字节大小就能够通过寻址公式找到指定索引的元素对象。那么既然是预先分配就一定会存在分配量用完的情况,那么当预分配的内存用完,就必须要进行扩容,接下来我们就来看看ArrayList底层的扩容机制。我们看到在add()方法中当容量不足的时候调用了一个grow()方法,这个方法就是用来扩容的,老套路,上源码
private Object[] grow() {
return grow(size + 1);
}
private Object[] grow(int minCapacity) {
// 原数组长度
int oldCapacity = elementData.length;
// 如果数组长度大于0或者数组实例不是默认创建的数组实例
// 进行扩容
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
// 否则直接new一个默认为10长度的数组或者指定大小容量的数组对象
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
在这个方法内部,首先将原数组的长度赋值给一个变量,如果这个数组的长度大于0,或者此数组实例不是使用默认构造方法创建的空实例,那么我们就trigger扩容。否则创建一个默认10或者指定大小的实例。乍一看好像这个逻辑很奇怪,但是结合前面我们讲到的初始化的剖析,我们知道当我们使用默认构造方法创建一个ArrayList容器的时候,容器内部并非一开始就为我们创建一个容量的数组,而是首先创建了一个名为DEFAULTCAPACITY_EMPTY_ELEMENTDATA的空数组实例,在调用add方法时,发现容量为0,触发扩容机制调用grow()方法,因此实际上创建一个真正有容量的实例是在这一步,它为我们创建了一个默认为10的实例数组。那么这个minCapacity的变量是什么呢?这是因为ArrayList允许我们手动扩容,并指定容量,通过public void ensureCapacity(int minCapacity)这个方法可以实现,这下就清晰了。
OK,那么假如这个数组实例不是一个空数组实例,那么是怎么扩容的呢?我们继续看上面的代码,首先它会计算扩容的薪容量,并将这个容量赋值给newCapacity这个变量,重点来看计算容量的方法—ArraysSupport.newLength 这个方法,它的原型如下:
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
// assert oldLength >= 0
// assert minGrowth > 0
int newLength = Math.max(minGrowth, prefGrowth) + oldLength;
if (newLength - MAX_ARRAY_LENGTH <= 0) {
return newLength;
}
return hugeLength(oldLength, minGrowth);
}
在计算的时候,oldlength参数传入了原数组的容量;minGrowth参数为最小增长数,传入了一个minCapacity - oldcapacity;preGrowth期望增长的数量,传入了oldcapacity>>1也就是原来长度的1/2;通过代码中可以看到,实际上计算新容量就是数组原来的容量加上一个值,这个值在preGrowth和minGrowth两个变量中的最大值。因此我们可以得出结论:如果我们调用ensureCapacity(int capacity)传入一个我们指定容量大小的参数,扩容机制会将这个变量减去原数组长度后和原数组长度的1/2比较取较大的一个然后加上原长度作为数组新长度返回。
也就是说,如果我们不手动扩容,每次使用容器内部自动扩容机制,每次扩容后的新容量是原来数组容量的1.5倍。可以看到这个开销还是很大的,所以如果我们能够预知容器中的大小最好还是指定大小或者手动扩容。
扩容完了后,我们将原数组复制到扩容后的新数组,在grow()方法中,调用了Arrays.copyOf()这个方法,和前面的System.arraycopy()方法不同的是Arrays的copyOf()方法传回的数组是新的数组对象,所以您改变传回数组中的元素值,也不会影响原来的数组。
从这里我们可以看出,假如在调用add(int index, Object o)这个方法时,如果容量不够,则需要进行两次数组元素的复制,第一次是扩容机制中返回的新数组对象,而另一次则是在指定位置插入元素后搬移元素的时候进行数组的复制,实际上是一个开销很大的操作。remove方法同理。
3、总结
ArrayList是一种基于动态数组的容器,它能够非常快的通过索引寻址,支持随机访问。但是由于底层的数组机制,在进行插入或者删除时都需要进行数组的复制操作,对元素进行搬移(此处注意扩容机制中的数组复制和元素搬移的数组复制的不同)。
容器内部使用自动扩容机制,当使用自动扩容时,每次扩容后的新容量是原来容量的1.5倍。为避免频繁扩容带来的额外开销,我们应尽量根据需求指定容器的容量。
在初始化时,如果我们没有指定初始容器容量的大小,也就是使用默认构造方法时,容器会为我们创建一个空实例数组,只有当调用第一次调用add()方法并且没有手动扩容时,才会为我们创建一个默认容量为10的真正实例。
容器允许我们使用ensureCapacity(int minCapacity)方法手动扩容,但是为了避免调用者不正确使用这个方法带来的低效扩容,容器内部帮我们做了一定优化,假如指定的新的容量减去原数组长度所得到的值为L1,原来长度的1/2值为L2,如果L1<L2,那么新容量依然会是原来的1.5倍,只有当L1>L2,新容量才会是原长度加上L1。
当使用查找方法查找指定元素对象时,只能挨个儿遍历,因此时间复杂度为O(n)。