【Java】Java集合框架源码和数据结构简要分析——List

前言

        之前一直把集合框架分成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,因为他看起来要好那么一点。

附注:

        本文如有错漏,烦请不吝指正,谢谢!

    原文作者:java集合源码分析
    原文地址: https://blog.csdn.net/reliveIT/article/details/45875349
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞