想聊几个数据结构相关的内容,从List相关的集合入手吧,重点谈一下ArrayList 和 LinkedList。
首先,我们从以下几个方面进行探究:
1、数据结构上,顺序表和链表的差异、优缺点、适用范围;
2、结合源码对两种数据结构进行分析;
3、日常使用如何选择
我们看下解答:
1、二者存储结构的区别
顺序表有以下特征:
- 长度固定,必须在分配内存之前确定数组的长度,可以使用倍增-复制的办法来支持动态扩容;
- 存储空间连续,即允许元素的随机访问。
- 存储密度大,空间利用率高,内存中存储的全部是数据元素。
- 要访问特定元素,可以使用索引访问,时间复杂度为 O(1)。
- 要想在顺序表中插入或删除一个元素,都涉及到之后所有元素的移动,因此时间复杂度为 O(n)。
所以,如果是查询比较多的场景,一般建议使用顺序表存储结构。
链表有以下特征:
- 长度不固定,可以任意增删。
- 存储空间不连续,数据元素之间使用指针相连,每个数据元素只能访问周围的一个元素(根据单链表还是双链表有所不同)。
- 存储密度小,空间利用率不高,因为每个数据元素,都需要额外存储一个指向下一元素的指针(双链表则需要两个指针)。
- 要访问特定元素,只能从链表头开始,遍历到该元素,时间复杂度为O(n) 。
- 在特定的数据元素之后插入或删除元素,不涉及到其他元素的移动,因此时间复杂度为O(1) 。双链表还允许在特定的数据元素之前插入或删除元素。
如果是增删操作比较多的场景,就建议使用链表存储结构。
2、从源码分析
我们先从ArrayList入手吧,首先看下这个类:
这边实现了三个接口,但是点进去看会发现,这三个接口其实都是空接口,就是说它啥方法也没定义,那么实现这些接口是干嘛用的呢,这个要从虚拟机那边入手了,简单来说呢,这三个空接口主要的作用就是三个标记,比如我们加载遍历这个ArrayList,JVM可能并不知道怎么加载,可以通过RandomAccess判断加载方式,这个RandomAccess接口说明实现它的类是支持随机访问的,而且大部分都是通过数组实现的。然后这是个集合类,该类的遍历方式也是以此来判断,RandomAccess表示该类是以for循环进行遍历的(当然还有一种方式是迭代器的循环方式)。
Cloneable接口表示该类支持拷贝,其实我们万物鼻祖Object里面就有定义一个clone(),但是我们在类中重写这个方法,执行的时候也是会报错的,这是因为还需要实现Cloneable接口,也就是贴上这个标签之后才能被识别。方法拷贝的最大好处是当你需要将某个集合的数据复制一份的话,我们平时会定义一个新的集合,然后循环将数据赋值给新的集合,但是这个拷贝直接通过 clone方法复制一份,效率会高很多。讲到拷贝的话,需要补充一些内容,可以看我另一篇文章:Cloneable -- Java中的拷贝。
Serializable接口是表示该类支持序列化,我们平时数据在应用执行结束的时候都会被清空回收,那么如果想对数据进行保存或者传输,就需要用到序列化的技术了。
讲完三个空接口,我们再看看ArrayList的构造函数,有三个,这边截取了两个,可以看出,ArrayList底层是使用数组(顺序表)的存储结构,初始情况下,除非指定长度,否则是没有默认长度(长度为0),
private static final Object[] EMPTY_ELEMENTDATA = {};//这边为什么会定义两个空数组,下面会解释
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
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);
}
}
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;
}
}
如果我们调用的是无参构造函数呢,那么我们就会用比较长的那个空数组,那如果我们调用的是有参构造函数并传进的是0的时候,就会使用那个短的空数组。用不同的数组会对后面的扩容有影响。
然后看看add方法:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;// size表示当前list的长度
return true;
}
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { 如果调用无参构造函数,则此处为true,那么第一次add的时候,容量扩容为10,后续按照1.5倍扩容
return Math.max(DEFAULT_CAPACITY, minCapacity); //DEFAULT_CAPACITY = 10;
}
return 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;
int newCapacity = oldCapacity + (oldCapacity >> 1); //右移一位,表示除二,总的就是1.5倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0) // MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
newCapacity = hugeCapacity(minCapacity); //当需要的长度超过最大的长度,就会报异常
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity); //扩容其实就是创建一个新的长度为1.5倍的数组,然后把数据复制过去
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
从代码中可以看出,如果我们一开始调用的是无参的构造函数呢,其实arraylist的初始长度是0,然后在第一次add操作的时候就进行扩容,第一次扩容长度为10,后续每次添加的时候都会对长度进行判断,若长度不够,就按照原有长度的1.5倍来进行扩容,然后扩容到最大长度(Integer.MAX_VALUE - 8)的时候,就会报出内存溢出了。而且每一次扩容,就是创建了一个新的长度为原来1.5倍的数组,然后把原有的数据拷贝过去。需要说明以下,size表示当前list的长度,但是没有volatile修饰,所以在多线程操作的时候是线程非安全的。(新增的过程中有一个小彩蛋:并没有做非空校验,所以是允许添加null数据)
有一个特殊场景的操作,会爆出Java9以下Ar'ra'yList的一个bug:(在Java9中已经修复)
那如果是在指定的位置插入一个元素呢?
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); //根据元素数量调整集合的容量
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}
/**
* @param src the source array. //原数组
* @param srcPos starting position in the source array. //原数组中的起始位置
* @param dest the destination array. // 目标数组
* @param destPos starting position in the destination data. //目标数组的起始位置
* @param length the number of array elements to be copied.//需要被复制的数组元素数量
*/
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
从源码中我们可以看到,在新插入一个数据的时候,系统首先做了对容量的检查及扩容,然后将插入的数据的需要的下标index开始,将后面的数据挨个后移,然后在index的位置上赋值新插入的数据。
那我们继续看看删除操作:
//删除指定位置
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;
}
//删除指定元素
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;
}
同样是要检查操作的下标是否合法,然后把指定位置后面的所有元素挨个前移,最后把最后一位置为null,这样就完成了指定位置元素的删除。
接下来讲查询,修改和查询原理一样,就这里一并讲了吧
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
查询就是直接返回数组指定下标的元素。那么我们要思考一个问题,为什么说数组的查询效率高呢?那我们就得知道几个知识:首先,数组在内存中是连续的地址片段;再者数组中每个元素的内存大小都是固定相等的(就算数组存的是引用数据,那么也是存储其内存地址)。有了这两个基础,大概都可以推理出来了吧,查询的效率高,是因为我们只要知道首地址add0,那么给我下标i,我就可以马上算出你要找的那个位置的地址:add1 = add0 + i*d 。执行这个运算时间复杂度是O(1),一下子就找到了。
讲完增删改查操作,其实还需要补充扩展讲一下迭代器的内容,这块内容要讲的话,也会扩展出很多知识出来,下次在其他的文章中补充吧,这里简单记几个点:
1、前面我们提到,ArrayList是线程不安全的,那么我们在一个线程进行循环查询,另一个线程中进行删除操作的话,正常来说得等到程序执行到相应位置了才会发现问题。但是ArrayList中作了检测:
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
这样会提前把问题暴露出来,就不会有脏读的问题了。还有一个就是我们的ArrayList中有100万个数据,如何对其求和?如果是单纯的for循环,效率太过低下,这就可以用到Spliterator,将list拆分,然后在多线程中分别对子list迭代求和,最后汇总,这样可以节省很多时间。
最后对ArrayList 几个常用的api做一些注意点提醒,
1、subList获取到的并不是一个全新的list,而是返回原来的list,只不过对新的下标做了保存。
public List<E> subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, 0, fromIndex, toIndex);
}
SubList(AbstractList<E> parent,
int offset, int fromIndex, int toIndex) {
this.parent = parent;
this.parentOffset = fromIndex;
this.offset = offset + fromIndex;
this.size = toIndex - fromIndex;
this.modCount = ArrayList.this.modCount;
}
public E set(int index, E e) {
rangeCheck(index);
checkForComodification();
E oldValue = ArrayList.this.elementData(offset + index);
ArrayList.this.elementData[offset + index] = e; //这里就很明显了,单对新的sublist进行修改的话,还是修改到原来的elementData
return oldValue;
}
2、Array.asList(arg[]);这个方法要注意里面的参数arg是基本数据类型还是引用数据类型,如果是引用数据类型,然后在获取到的size()就是arg数组的长度,如果是基本数据类型的话,获取到的size()是1。具体大家可以通过代码运行看下效果。这里问题的根本原因是Java的自动拆装箱在数组中失效的问题。
由于篇幅太长,我打算把LinkedList放到另一篇文章中进行分享。