标签:java 集合框架 list arraylist linkedlist
之前一直把集合框架分成Collection和Map来对待,主要是基于储存内容是单列和双列,实际上这样来区分不太正确,set实际上是双列的结构。
现在回顾集合框架,看到很多当初看不到的东西。
现在来看集合框架,一部分是List,一部分是Set和Map,Set和Map几乎就是一回事。
不讲太深入的东西,实际上我也讲不了多深入。
数据结构,就是一堆数据的关系。
逻辑结构——数据逻辑上的关系,其实就是数据结构,而数据的逻辑结构几乎可以分成四种:线性结构、集合结构、树形结构和图结构。
物理结构——上述四种逻辑结构,无论哪一种,最终都是要保存到物理内存中,也就是逻辑结构的基础是物理结构,而数据的物理结构无外乎两种:顺序存储结构和链式存储结构。顺序存储结构,常见的就是数组,需要一片连续的内存;链式存储结构,不需要连续内存,但是前一个数据对象需要关联下一个数据对象的内存地址。
List,翻译过来就是“链表”,链表的逻辑结构是线性结构,物理结构可以是顺序也可以是链式。
在Java集合框架中,List的实现有采用顺序存储结构,也有采用链式存储结构。常见的ArrayList和Vector是基于顺序存储结构(数组)的顺序链表,LInkedList则是基于链式存储结构的链式链表。
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数据的时候会有判断逻辑重新分配空间,这部分代码琐碎,不再展示,请自行观赏源码。
既然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; }
当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 }
ArrayList是基于顺序存储结构,LinkedList是基于链式存储结构,而且是一个双向链表,前者需要连续的内存空间,后者不需要连续的内存空间,但是每个元素都需要引用下一个元素的内存地址,当然除了最后一个。
那List我们需要讨论什么呢?LinkedList绝对是一颗奇葩,他是一根链表,是一个先进先出的队列,还是一个后进先出的栈,这里只讨论他作为链表,不然就太多了。
a. 如何实现数据内存地址的引用。
b. 增删改查
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中。
为了效率,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,因为他看起来要好那么一点。
附注:
本文如有错漏,烦请不吝指正,谢谢!
【Java】Java集合框架源码和数据结构简要分析——List
标签:java 集合框架 list arraylist linkedlist
原文地址:http://blog.csdn.net/reliveit/article/details/45875349