美文网首页
系统优化专题4——集合

系统优化专题4——集合

作者: 我菠菜今天就是要为所欲为 | 来源:发表于2020-10-13 00:53 被阅读0次

    集合作为一种存储数据的容器,是日常开发中使用最频繁的对象类型之一。JDK为开发者提供了一系列的集合类型,这些集合类型使用不同的数据结构来实现。因此,不同的集合类型,使用场景也不同。

    List接口

    先通过这张图,看一下List集合类的接口和类的实现关系:


    image.png

    我们可以看到ArrayList、Vector,LinkedList集合类继承了AbstractList抽象类,而AbstractList实现了List接口,同时也继承了AbstractCollection抽象类。ArrayList、VectorsLinkedList又根据自我定位,分别实现了各自的功能。

    ArrayList和Vector使用了数组实现,两者的实现原理差不多,LinkedList使用了双向链表实现。

    ArrayList是如何实现的?

    如下几个问题:

    1. 我们在查看ArrayList的实现类源码时,会发现对象数组elementData使用了transient修饰,我们知道transient关键字修饰该属性,则表示该属性不会被序列化,然而我们并没看到文档中说明ArrayList不詣被序列化,这是为什么?
    2. 使用ArrayList做新删除作会影响效率,那是不是ArrayList在大量新增元素的场景下效率就一定会降低呢?
    3. 使用for循环以及迭代循环遍历一个ArrayList,你会使用哪种方式呢?原因是什么?

    ArrayList实现类

    ArrayList实现了List口,继承了AbstractList抽象类,底层是数组实现的,并且实现了自增扩容数组大小。

    ArrayList还实现了Cloneable接口和Serializable接口,所以他可以实现克隆和序列化。

    ArrayList还实现了RandomAccess接口。通过代码我们可以发现,这个接口其实是一个空接口,什么也没实现,那ArrayList为什么要去实现它?

    其实RandomAccess接口是一个标志口,他标志着"只要实现该口的List类,都能实现快速随机访问"。

    public class ArrayList<E> extends AbstractList<E> implements List<E>,RandomAccess,Cloneable,Serializable
    

    ArrayList属性

    ArrayList属性主要由数组长size、对象数组elementData、初始化容量default_capacity等组成,其中初始化容量默认大小为10。

    //默认初始化容量
    private static final int DEFAULT_CAPACITY = 10;
    //对象数组
    transient Object[] elementData;
    //数组长度
    private int size;
    

    从ArrayList属性来看,它没被任何的多线程关键字修饰,但elementData被关键字transient修饰了。这就是上面提到的第一道测试题。其实,这还得从"ArrayList是基于数组实现开始说起,由于ArrayList的数组是基于动态扩增的,所以并不是所有被分配的内存空间都存储了数据。

    如果采总外部序列化法实现数组的序列化,会序列化整个数组。ArrayList为了避免这些没有存放数据的内存空间被序列化,内部提供了两个私有方法writeObject以及readObject来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间。

    因此使用transient修饰数组,是防止对象数组被其他外部方法序列化。

    ArrayList构造函数

    ArrayList类实现了三个构造丞数,第一个是创建ArrayList对象时,传入一个初始化值;第二个是默认创建一个空数组对象;第三个是传入一个集合类型进行初始化。

    当ArrayList新增元素时,如果所存储的元素已经超过其已有大小,它会计算元素大小后再进行动态扩容,数组的扩容会导致整个数组进行一次内存复制。因此,我们在初始化ArrayList时,可以通过第一个构造函数合理指定数组初始大小,这样有助于减少数组的扩容次数,从而提高系统性能。

    public ArrayList(int initialCapacity){
        if(initialCapacity > 0){
    //初始化容量不为0时,将根据初始化值创建数组大小
            this.elementData = new Object[initialCapacity];
        }else if(initialCapacity == 0){
    //初始化容量为0时,使用默认的空数组
            this.elementData = EMPTY_ELEMENTDATA;
        }else{
            throw new IllegalArgumentException("Illegal Capacity:"+initialCapacity);
        }
    }
    
    public ArrayList(){
        //初始化默认为空数组
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
    

    ArrayList新增元素

    ArrayList新增元素的方法有两种,一种是直接将元素加到数组的末尾,另外一种是添加元素到任意位置。

    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);
        System.arraycopy(elementData,index,elementData,index+1,size - index);
        elementData[index] = element;
        size++;
    }
    

    两个方法的相同之处是在添加元素前,会先确认容量大小,如果容量够大,就不进行扩容;如果容量不够大,就会按照原来数组的1.5倍大小进行扩容,在扩容后需要将数组复制到新分配的内存地址。

    private void ensureExplicitCapacity(int minCapacity){
        modCount++;
        //overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
    
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    
    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:
        elementData = Arrays.copyOf(elementData,newCapacity);
    }
    

    两个方法也有不同之处,添加元素到任意位置,会导致在该位置后的所有元素都需要重新排列,而将元素添加到末尾,在没有发生扩容的前提下,不会有元素复制排序的过程。

    这里就可以找到第二道题的答案了。如果我们在初始化的时候就比较清楚存储数据的大小,就可以在ArrayList初始化时指定数组容量大小,并且在添加元素时,只在数组末尾添加元素,那么在大量新增元素的场景下,性能并不会变差,反而比其他List集合性能要好。

    ArrayList删除元素

    ArrayList的删除方法和添加任意位置元素的方法是有些相同的。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; //chear to let GC do its work
        return oldValue;
    }
    

    ArrayList遍历元素

    由于ArrayList是基于数组实现的,所以在获取元素的时候是非常快捷的。

    public E get(int index){
        rangeCheck(index);
        return elementData(index);
    }
    
    E elementData(int index){
        return (E) elementData[index];
    }
    

    LinkedList

    虽然LinkedList与ArrayList都是List类型的集合,但LinkedList的实现原理却和ArrayList大相径庭,使用场景也不太一样。

    LinkedList是基于双向链表数据结构实现的,LinkedList定义了一个Node结构,Node结构中包含了3个部分:元素内容item、前指针prev以及后指针next,代码如下:

    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;
        }
    }
    

    LinkedList就是由Node结构对象连接而成的一个双向链表。在JDK1.7之前,LinkedList中只包含了一个Entry结构的header属性,并在初始化的时候默认创建了一个空的Entry,用来做header,前后指针指向自己,形成一个循环双向链表。

    在JDK1.7之后,LinkedList做了很大的改动,对链表进行了优化。链表的Entry结构换成了Node,内部组成基本没有改变,但LinkedList里面的header属性去掉了,新增了一个Node结构的first属性和一个Node结构的last属性。这样做有以下几点好处:

    1. first/last属性能更清晰地表达链表的表头和表尾概念;
    2. first/last方式可以在初始化LinkedList的时候节省new一个Entry;
    3. first/last方式最重要的性能优化是表头和表尾的插入操作更快捷了。

    LinkedList实现类

    LinkedList类实现了List接口、Deque接口,同时继承了AbstractSequentialList抽象类,LinkedList既实现了List类型又有Queue类型的特点;

    LinkedList也实现了Cloneable和Serializable接口,同ArrayList一样,可以实现克隆和序列化。

    由于LinkedList存储数据的内存地址是不连续的,而是通过指针来定位不连续地址,因此,LinkedList不支持随机快速访问,LinkedList也就不能实现RandomAccess接口。

    public class LinkedList<E> extends AbstractSequentialList<E> implement List<E>,Deque<E>,Cloneable,java.io.Serializable
    

    LinkedList属性

    我们可以看到,LinkedList的first/last/size属性都被transient修饰了,因为我们在序列化的时候不会只对头尾进行序列化,所以LinkedList也是自行实现readObject和writeObject进行序列化与反序列化。

    transient int size = 0;
    transient Node<E> first;
    transient Node<E> last;
    

    LinkedList新增元素

    LinkedList添加元素的实现很简洁,添加的方式有很多种。默认的add(E e)方法是将添加的元素加到队尾,首先是将last元素置换到临时变量中,生成一个新的Node节点对象,然后将last引用指向新节点对象,之前的last对象的前指针指向新节点对象

    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的添加操作来说,LinkedList的性能优势明显。

    public void add(int index,E element){
        checkPositionIndex(index);
        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;
        final Node<E> newNode = new Node<>(pred,e,succ);
        succ.prev = newNode;
        last = newNode;
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        size++;
        modCount++;
    }
    

    LinkedList删除元素

    在LinkedList删除元素的操作中,首先需要通过循环,根据要删除元素所处的位置,从前到后或从后到前找到要删除的元素。无论要删除的元素靠前或靠后都是非常高效的,但如果List拥有大量元素,要移除的元素处于中间位置的话,效率相对来说会很低。

    LinkedList遍历元素

    LinkedList的获取元素操作实现跟LinkedList的删除元素操作基本类似,通过分前后半段来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的,特别是在for循环遍历下,每一个循环都会去遍历半个List。

    所以,在使用LinkedList循环遍历时,我们可以使用iterator方式迭代循环,直接拿到我们的元素,不需要通过循环查找List。

    总结

    现在我们看几组测试结果

    添加

    测试条件 测试结果(耗时)
    从集合头部位置新增元素 ArrayList>LinkedList
    从集合中间位置新增元素 ArrayList<LinkedList
    从集合尾部位置新增元素 ArrayList<LinkedList

    由于ArrayList是数组实现的,而数组是一块连续的内存空间,在添加元素到数组头部的时候,需要对头部以后的数据进行复制重排,所以效率很低。而LinkedList是基于链表实现,在添加元素的时候,首先会通过二分法循环查找到添加元素的位置,所以LinkedList添加元素到头部是非常高效的。

    同上,ArrayList在添加元素到数组中间时,同样有部分数据需要复制重排,效率也不是很高;LinkedList将元素添加到中间位置,是添加元素中效率最低的,因为靠近中间位置,在添加元素之前的循环是遍历元素最多的操作。

    而在添加元素到尾部的操作中,在没有扩容的前提下,ArrayList的效率要高于LinkedList,因为LinkedList中多了new对象以及变换指针的过程,所以效率要低于ArrayList。

    删除

    测试条件 测试结果(耗时)
    从集合头部位置删除元素 ArrayList>LinkedList
    从集合中间位置删除元素 ArrayList<LinkedList
    从集合尾部位置删除元素 ArrayList<LinkedList

    ArrayList和LinkedList删除元素操作的测试结果和添加元素操作测试的结果很接近,原理也相同。

    遍历

    测试条件 测试结果(耗时)
    for( ; ; ) ArrayList<LinkedList
    迭代器 ArrayList=LinkedList

    我们可以看到,因为LinkedList基于链表实现,在使用for循环的时候,每一次循环都会遍历半个List,严重影响了效率;ArrayList基于数组实现的,并且实现了快速随机访问,所以for循环效率非常高。

    注意:
    for(:)循环[这里指的不是for( ; ; )]是一个语法糖,这里会被解释为迭代器,在使用迭代器遍历时,ArrayList内部创建了一个内部迭代器iterator,在使用next()方法来取下一个元素时,会使用ArrayList里保存的一个用来记录List修改次数的变量modCount,与iterator保存了一个expectedModCount来表示期望的修改次数进行比较,如果不相等则会抛出异常;

    而在在foreach循环中调用list中的remove()方法,会走到fastRemove()方法,该方法不是iterator中的方法,而是ArrayList中的方法,在该方法只做了modCount++,而没有同步到expectedModCount。

    当再次遍历时,会先调用内部类iteator中的hasNext(),再调用next(),在调用next()方法时,会对modCount和expectedModCount进行比较,此时两者不一致,就抛出了ConcurrentModificationException异常。

    所以关键是用ArrayList的remove还是iterator中的remove。

    相关文章

      网友评论

          本文标题:系统优化专题4——集合

          本文链接:https://www.haomeiwen.com/subject/slrwuktx.html