Java List浅谈(基于jdk1.7)

List是重要的数据结构之一。最常见也是最重要的3种List实现:ArrayList、Vector、和LinkedList,它们的类图如下所示:

https://i-blog.csdnimg.cn/blog_migrate/e731607dcf5ff4eca21e8c9f45d69768.jpeg

可以看到,3种List均来自AbstractList的实现。而AbstractList直接实现了List的接口,并扩展自AbstractCollection。

在这3种不同的实现中,ArrayList和Vector使用了数组实现,而LinkedList使用了双向链表数据结构。

ArrayList(Vector)与LinkedList对比

ArrayList和Vector几乎使用了相同的算法,它们唯一的区别可以认为是对多线程的支持。ArrayList没有对任何一个方法做线程同步,因此不是线程安全的。Vector中绝大部分方法都做了线程同步,是一种线程安全的实现。因此ArrayList和Vector的性能特性相差无几。从理论上来说,没有实现线程同步的ArrayList要稍好于Vector,但实际表现并不是很明显。

LinkedList使用了双向链表数据结构。与基于数组ArrayList相比,这是两种截然不同的实现技术,这也决定了它们将适用于完全不同的工作场景。

LinkedList链表由一系列表项连接而成。一个表项总是包含3个部分:元素内容,前驱表(prev)和后驱表(next),如图所示:


在下图展示了一个包含3个元素的LinkedList的各个表项间的连接关系。在JDK的实现中,无论LikedList是否为空,链表内部都有一个first和last表项,first表示链表的开始,last表示链表的结尾。


1.增加元素到末尾

在ArrayList中增加元素到队列尾端的代码如下:

    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // 确保内部数组有足够的空间
        elementData[size++] = e;//将元素增加到数组的末尾,size加1
        return true;
    }
ArrayList中add()方法的性能取决于ensureCapacityInternal()方法。ensureCapacityInternal()的实现如下:

    private void ensureCapacityInternal(int minCapacity) {
        if (elementData == EMPTY_ELEMENTDATA) {//如果数组是空的
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);//从默认的10和给定的数值中取个最大的
        }

        ensureExplicitCapacity(minCapacity);
    }

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;//修改标记+1

        // 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/2
        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:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
Arrays.copyOf()方法:

    public static <T> T[] copyOf(T[] original, int newLength) {
        return (T[]) copyOf(original, newLength, original.getClass());
    }
    public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
        T[] copy = ((Object)newType == (Object)Object[].class)
            ? (T[]) new Object[newLength]
            : (T[]) Array.newInstance(newType.getComponentType(), newLength);
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));//本地方法调用复制数组
        return copy;
    }
只要ArrayList的当前容量足够大,add()操作的效率还是比较高的。

只有当ArrayList的容量的需求超过当前数组的大小时,才需要进行扩容。扩容过程中,会进行大量的数组复制操作。而数组复制时,最终将调用System.arrayCopy()方法。

Vector增加元素:

    public synchronized void addElement(E obj) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = obj;
    }
除了在方法上添加了同步锁以外,把修改标记先行加1,其他部分基本相同。

LinkedList增加元素:

    public boolean add(E e) {
        linkLast(e);
        return true;
    }
    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);//创建一个节点
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;//放在末尾
        size++;
        modCount++;
    }
LinkedList由于使用了双向链表的结构,因此不需要维护容量的大小。从这点说,比ArrayList有一定的性能优势,然而,每次的元素增加都需要新建一个Node对象,并进行更多的赋值操作。在频繁的系统调用中,对性能会产生一定的影响。
分别使用ArrayList、Vector和LinkedList运行以下代码:

        Object obj=new Object();
    	for(int i=0;i<900000;i++){
    		list.add(obj);
    	}

结果如下,单位ms:


可见,由于Vector在方法上加了同步锁,时间上比ArrayList要慢一些,而LinkedList不间断的生成对象,还是占用了一定的系统资源。而因为ArrayList和Vector数组的连续性,因此总是在尾端增加元素时,只有在空间不足时才产生数组扩容和数组复制,所以,绝大部分的追加效率非常高。在不加-Xms512的时候,还可以看出,LinkedList对堆内存和GC的要求会更高一些。

2.增加元素到任意位置

除了提供增加元素到list的尾端,List接口还提供了在任意位置插入元素的方法。

由于ArrayList是基于数组实现的,而数组是一块连续的内存空间,如果在数组的任意位置插入元素,必然导致在该位置后的所有元素需要重新排列,因此,其效率相对会比较低。

以下是ArrayList中的实现:

    public void add(int index, E element) {
        rangeCheckForAdd(index);//检查位置是否合法,具体方法在下面

        ensureCapacityInternal(size + 1);  // Increments modCount!!   保证容量大小,同在末尾增加
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);//本地方法复制
        elementData[index] = element;
        size++;
    }
    /**
     * A version of rangeCheck used by add and addAll.
     */
    private void rangeCheckForAdd(int index) {
        if (index > size || index < 0)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
可以看到,每次插入操作,都会将插入位置以后的元素往后复制一位,而这个操作在增加到末尾的时候是不存在的。并且,插入的元素在数组中的位置越靠前,数组重组的开销也就越大。

而LinkedList此时就显示出了优势:

    public void add(int index, E element) {
        checkPositionIndex(index);//检查位置是否合法

        if (index == size)
            linkLast(element);//同上面在末尾添加元素
        else
            linkBefore(element, node(index));
    }
    private void checkPositionIndex(int index) {
        if (!isPositionIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
    void linkBefore(E e, Node<E> succ) {
        // assert succ != null;
        final Node<E> pred = succ.prev;
        final Node<E> newNode = new Node<>(pred, e, succ);
        succ.prev = newNode;
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        size++;
        modCount++;
    }
可见,对LinkedList来说,在List尾端添加元素和在任意位置插入元素是一样的,并不会因为插入的位置而导致性能降低。

下面来做个测试:

		Object object=new Object();
		for(int i=0;i<100;i++)//先预加入100个元素
			list.add(object);
		//在第5个位置添加元素9W次
		for(int i=0;i<90000;i++)
			list.add(4, object);
结果如下:


可见两者在性能上有着本质的差异。所以,如果在系统应用中,List对象需要经常在任意位置插入元素,则可以考虑用LinkedList替代ArrayList。

3.删除任意位置元素

对于ArrayList来说,remove()方法和add()方法是雷同的。在任意位置移除元素后,都要进行数组的重组。ArrayList的实现如下:

    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  最后一个位置设置为null

        return oldValue;
    }
可以看到,在ArrayList的每一次有效的元素删除操作后,都要进行数组的重组。并且删除的元素位置越靠前,数组重组时的开销也越大。

下面看LinkedList的实现:

    public E remove(int index) {
        checkElementIndex(index);
        return unlink(node(index));
    }
    Node<E> node(int index) {//根据位置返回要删除的节点node
        // 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;
        }
    }
    E unlink(Node<E> x) {//将节点解除连接(删除)
        // assert x != null;
        final E element = x.item;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;

        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.item = null;
        size--;
        modCount++;
        return element;
    }

在LinkedList的实现中,首先要通过循环找到要删除的元素。如果要删除的位置位于List的前半段,则从前往后找;反之则从后往前找。因此无论要删除较为靠前或者靠后的元素都是非常高效的;但要移除List中间的元素却几乎要遍历完半个List,在List拥有大量元素的情况下,效率很低。

4.容量参数

容量参数是ArrayList和Vector和HashMap等基于数组的List的特有性能参数,它表示初始化的数组大小。从上面的分析可以知道,当ArrayList要存储的元素数量超过其已有大小时,它便会进行扩容,数组的扩容会导致整个数组进行一次内存复制。因此合理的数组大小有助于减少数组扩容的次数,从而提高系统性能。

默认情况下,ArrayList数组的初始值大小为10,每次扩容将新的数组大小设置为原来的1.5倍。

下面做个测试,存入1000W元素,当不指定初始大小的时候,运行时间为25ms;当使用new ArrayList(10000000)初始化大小的时候,耗时仅为7ms。

因此,在能有效地评估ArrayList数组大小初始值的情况下,指定容量大小能对其性能有较大的提升。

5.遍历列表

遍历列表也是常用的列表操作之一。在jdk1.5之后,至少有3种常用的列表遍历方式:ForEach操作、迭代器Iterator和for循环。

构造一个1000W数组的ArrayList和等价的LinkedList,分别用以上三种方式进行遍历,代码如下:

	public static void main(String[] args) {
		ArrayList<String> list=new ArrayList<>(10000000);
		for(int i=0;i<10000000;i++)//先预加入10000000个元素
			list.add("ss");
		String temp;
		long start=System.currentTimeMillis();
		for(String s:list)
			temp=s;
		System.out.println("foreach spend:"+(System.currentTimeMillis()-start));
		start=System.currentTimeMillis();
		for(Iterator<String> it=list.iterator();it.hasNext();)
			temp=it.next();
		System.out.println("iterator spend:"+(System.currentTimeMillis()-start));
		start=System.currentTimeMillis();
		int size=list.size();
		for (int i = 0; i < size; i++) {
			temp=list.get(i);
		}
		System.out.println("for spend:"+(System.currentTimeMillis()-start));
	}

List遍历测试结果
List类型ForEach操作Iteratorfor循环
ArrayList938013
LinkedList134127等不到

可以看到,最简单的ForEach循环并每一很好的表现,不如普通的迭代器。而使用for循环通过随机访问遍历列表时,ArrayList表现很好,但是LinkedList的表现却无法让人接受,目前距离刚才的测试已经有了近20分钟还没有结果,因为,对LinkedList进行随机访问时,总是要进行一次遍历查找,虽然通过双向循环链表的特性将平均查找次数减半,但依然会消耗大量cpu时间,就像上面的测试,LinkedList需要进行1000W次遍历,而每次遍历平均有500W/2个数据,当然会很慢了。

而对于ForEach和Iterator两种方式,对下面的一段代码

分别反汇编一下字节码并对比如下:


左边是用Iterator方式遍历,右边是用ForEach遍历,可以发现两者是等价的,且在ForEach遍历的过程中,多存储了一次变量。


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值