前言
之前一直把集合框架分成Collection和Map来对待,主要是基于储存内容是单列和双列,实际上这样来区分不太正确,set实际上是双列的结构。
现在回顾集合框架,看到很多当初看不到的东西。
现在来看集合框架,一部分是List,一部分是Set和Map,Set和Map几乎就是一回事。
一、数据结构
不讲太深入的东西,实际上我也讲不了多深入。
数据结构,就是一堆数据的关系。
逻辑结构——数据逻辑上的关系,其实就是数据结构,而数据的逻辑结构几乎可以分成四种:线性结构、集合结构、树形结构和图结构。
物理结构——上述四种逻辑结构,无论哪一种,最终都是要保存到物理内存中,也就是逻辑结构的基础是物理结构,而数据的物理结构无外乎两种:顺序存储结构和链式存储结构。顺序存储结构,常见的就是数组,需要一片连续的内存;链式存储结构,不需要连续内存,但是前一个数据对象需要关联下一个数据对象的内存地址。
二、List
List,翻译过来就是“链表”,链表的逻辑结构是线性结构,物理结构可以是顺序也可以是链式。
在Java集合框架中,List的实现有采用顺序存储结构,也有采用链式存储结构。常见的ArrayList和Vector是基于顺序存储结构(数组)的顺序链表,LInkedList则是基于链式存储结构的链式链表。
1. ArrayList和Vector
ArrayList和Vector之间的异同已经有很多讨论了,这里就不再延续(参看之前写的博文《集合框架和Map基础》,写的较早,比较粗浅),直接以ArrayList源码做讨论。
ArrayList中存放数据的结构是数组(后面的HashSet、HashMap也是基于数组的顺序存储结构),需要一片连续的内存,查找和修改的效率极高,时间复杂度都是O(1)。既然是基于数组,数据具有确定的数据类型,初始化之后大小不可更改的特性,那么他面临的问题有哪些?
a. 当存放数据的数组不够用,如何实现动态括动?
b. 数据增加、删除时候,实现方式以及性能是怎样的?
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/
transient Object[] elementData; // non-private to simplify nested class access
这里为什么数组中的元素数据类型是Object?实际上无论你是否使用泛型,集合框架中存放对象的数据类型都是Object,这一点不仅仅从源码中可以看到,通过反射也可以看到。泛型作用域编译期,到运行时就已经被擦除了,而反射是获取一个对象的运行时数据信息,有兴趣的同学可以自行了解,这里不再详细介绍。另外,记住这个数组对象elementData。无需担心elementData会导致空指针异常,如果你在构造函数中传入initSize,那么他将会在在构造函数中被初始化,如果你没有传入,那么他会是一个length==0的空数组,但是在你add数据的时候会有判断逻辑重新分配空间,这部分代码琐碎,不再展示,请自行观赏源码。
1.1 动态扩容
既然ArrayList是基于数组结构的集合框架,而数组一旦初始化完成其数据类型和长度都不会在改变,那么ArrayList是如何做到动态扩容的?当存入的数据元素个数大于数组长度的时候,如果不给更大的新的数组,那么就会发生数组下标越界的异常。ArrayList动态扩容的思想是创建一个更大的数组,然后将原数组的数据copy过去,无论是从时间复杂度O(n)还是空间复杂度S(n)来看,效率和性能都是及其底下的,相比较其超快速的查询和修改来说,简直不忍直视。下面贴出其扩容的核心代码。
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:
//上面的内容都是确定数组的初始化大小
//下面一句代码,意味着创建新的数组对象,并将老的数组对象中的数据copy过去
elementData = Arrays.copyOf(elementData, newCapacity);
}
Arrays.copy(源码如下),实现了创建新数据并拷贝数据,但是这个方法还没有到探究的落点,探究的落点是数据拷贝,但是不要忘了Arrays.copyOf(elementData, newCapacity);通过反射新建数组的功劳,真正实现数据拷贝的是一个native的方法System.arraycopy,该方法高效的实现了数据拷贝(如果src数组和dest数组是同一个,则借助于一个temp数组来进行拷贝,如果src和dest不同,则直接进行拷贝,C++源码,此处不贴),这个方法将伴随着Java集合框架中所有顺序存储结构的存在而被使用,他在顺序存储中大放异彩(请记住他,并且使用他)!
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
//上面通过反射新建数组
//下面通过System的native static方法进行数据拷贝
System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
return copy;
}
ArrayList是基于连续内存的数组实现,可以看看他的三个构造方法,鉴于其物理存储特性和实现代码,建议使用这个数据结构的时候,如果一开始就确定存放数据对象的数量,则在构造的时候就传入,实际上使用ArrayList更希望他能作为一个只用于查找的静态数据结构,千万不要让他总是频繁的变化,这样太低效了,如果你是一个好人,那么就请选择其他结构吧。
1.2 数据增删
当elementData不够用的时候,他会自己动态的扩容,那么当elementData够用的时候,他的数据增加、删除和修改是肿么样的呢?当然,在每一次增删之前,都会要判断数组是否够用,不够用扩容,够用那么就是接下来要讨论的内容,数据的增删改。
ArrayList的add,一个是相对,直接添加到最后(实现是通过一个成员变量size,记录当前数组中存放的对象数量),时间复杂度O(1),一个是绝对,将数据添加到指定的位置,最差的时间负再度O(n),因为需要将指定位置之后的数据全部往后挪动一下,再把插入数据放进来。
//相对:添加到已有数据的最后一个
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!!
//看这里,是不是和Arrays.copy的实现很像
//没错,这就是在Java顺序存储结构中大放异彩的native方法System.arraycopy
//如果数组大小还够用,那么就将指定位置之后的数据往后挪动一个位置,然后再将数据放进来
//相比较链式存储结构,这个效率也太低了
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}
在指定位置增加数据的效率已经很低了,那删除呢?删除的效率也很低···删除也有两种,一个是删除指定位置的数据,一个是直接删除数据对象。指定位置,只需要将后面的数据全部往前挪一下,覆盖掉就可以了,最坏时间复杂度O(n),记住返回删除的value,这一点很有用,而且只有list才有;指定对象,需要先遍历找出对象存放的位置,然后再将后面的数据全部往前挪动一下,时间复杂度,妥妥滴,最好O(n),最坏就撸两次,虽然也是O(n),但实际上是O(2n),所以如果知道在哪一个位置,优先指定位置删除吧。当然,不要忘了Java顺序存储中大放异彩的System.arraycopy。
//指定位置删除
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;
}
//指定对象删除的真正实现方法
private void fastRemove(int index) {
modCount++;
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
}
2. LinkedList
ArrayList是基于顺序存储结构,LinkedList是基于链式存储结构,而且是一个双向链表,前者需要连续的内存空间,后者不需要连续的内存空间,但是每个元素都需要引用下一个元素的内存地址,当然除了最后一个。
那List我们需要讨论什么呢?LinkedList绝对是一颗奇葩,他是一根链表,是一个先进先出的队列,还是一个后进先出的栈,这里只讨论他作为链表,不然就太多了。
a. 如何实现数据内存地址的引用。
b. 增删改查
2.1 地址引用的数据结构
Java中一个类,就是一个自定义数据类型,一个类的每一个对象,都是一个引用。最重要的是,实例化完成一个对象,那么这个对象就已经分配好了内存,那么他在本次虚拟机中的内存地址也就固定好了。这么来描述,将一个对象赋值给另一个,实际上是把栈内存中存放的对内存地址copy了一份过去,他们两个都指向相同的对内存地址和空间,内存地址引用就这么顺当的实现了。
当然,集合中只能存放对象,但是存放对象的value是堆内存,也就是我们真正需要的value,肿么能轻易将他赋值出去呢?所以,存放进来的每一个value都要被包装一下,包装成data区内容,而链表中上一给点和下一个点的地址引用,还需要借助其他。直接看源码吧,Node,LinkedList中定义的内部类。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
就是辣么简单。传进来的数据都被包装成了一个Node对象,实际上是被赋值给了Node的item,而前后数据的内存地址引用则保存在了next和prev中。
2.2 增删改查
为了效率,LinkedList有两个成员变量,first和last。实际上,作为一个链式存储结构,LinkedList的增加或插入还有删除性能是相当强悍的,比起顺序存储结构来说,好的太多了。
增删改的逻辑差不多,时间复杂度O(1),只要了解链表原理,基本上理解起来无难度,阅读源码逻辑也是秒懂,甚至不看都能写出来的节奏,这里就以add来进行讨论吧。add也是两种,指定element加入,在指定位置加入element。
//add一个元素
void linkLast(E e) {
final Node<E> l = last;
//传入的数据都要被包装成一个Node
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
//指定位置add元素
public void add(int index, E element) {
checkPositionIndex(index);
//如果是插入最后一个,那么和直接调用add(E e)的逻辑是一样
if (index == size)
linkLast(element);
//如果指定的位置不是最后一个,那么就需要断链接,其实就是重新赋值几下
else
linkBefore(element, node(index));
}
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
//在这里,通过构造方法,将newNode的next指向succ
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
//在这里,将原来指向succ改成指向newNode
pred.next = newNode;
size++;
modCount++;
}
查找就没那么好的性能了。查找需要从头节点一直往下遍历,直到遍历到相等的元素位置,时间复杂度O(n)。
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
断断续续,到这里基本关于List就结束了。
三、比较
比较什么,其实比较的是顺序存储结构和链式存储结构的优劣。
增:ArrayList,相对O(1),绝对O(n),需要Sysem.arraycopy;LinkedList,add(E e),因为存在Node last的缘故,O(1),add(int, E),需要先遍历int的深度,实际上增加就是重新赋值的过程,不算遍历的过程的话,也是O(1),但实际上一轮 下来,也沦落成了O(n);
删:ArrayList,O(n),需要System.arraycopy;LinkedList,只看删除的话,O(1),但是无论如何不能忽略查找消耗的性能,最终也沦落成了O(n);
改:ArrayList,指定位置O(1),指定元素O(n),因为需要遍历比较;LinkedList,无论是指定位置还是指定元素,都需要遍历,虽然修改只是O(1),但是加上遍历的消耗,沦落成O(n);
查:ArrayList,指定位置O(1),指定元素O(n);LinkedList,O(n);
最终落点是拷贝数组开销大,还是遍历node开销大。这一点的讨论,还是根据业务数据的特点来选择结构,系统稳定以后,需要频繁查找,较少增删,那么选用ArrayList,反之则选用LinkedList。如果兼而有之,几乎无所谓谁多谁少,还是建议ArrayList,因为他看起来要好那么一点。
附注:
本文如有错漏,烦请不吝指正,谢谢!