Java集合-List

概念

       在Java中List有两个,一个是java.util下的接口,另一个是java.awt下的类,这里只讨论java.util下的接口List。它表示一个有序的集合,开发人员可以通过该接口精准地控制集合中每个元素的插入位置,同时也可以通过int类型的下标获取对应位置上的元素。总结来说就是它可以表示一个有序的集合,是一些元素的集合序列,可以存储,更新以及查询其中的任何一个元素,是一个容器。

       不同于set集合,List中的元素是可以重复的,也就是常说的列表中的任何两个元素e1和e2,e1.quals(e2)的结果可以为true。同时List中允许存在多个null元素。如果有特殊需要,我们也可以自行实现该接口,用以满足我们自身业务需求,但要尽量遵循List的设计原则,尽量不要加入与List理念相违背的理念,例如:自己实现了一个list,里面不可以插入重复元素,一旦重复,直接报错,这样完全没有必要,直接用Set不是更好。另外,List内部提供了一个特殊的迭代器,叫做ListIterator,它允许元素插入和替换,以及Iterator接口中提供常规操作之外的双向访问。

       这里的双向访问指的是利用ListIterator可以对list进行双向遍历,即:可以正向遍历,也可以反向遍历,例如:

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.add(5);
System.out.println("正向遍历");
ListIterator<Integer> iterator = list.listIterator();
while (iterator.hasNext()) {
    System.out.print(iterator.next() + " ");
}
System.out.println("\n反向遍历");
while (iterator.hasPrevious()) {
    System.out.print(iterator.previous() + " ");
}

最后的输出结果为:

正向遍历
1 2 3 4 5 
反向遍历
5 4 3 2 1 

为何需要List?

业务场景需要

       在Java中,集合的顶级父接口是Collection,其下才分成了List和Set,之所以有这种区分,主要也是因为有具体的应用场景,有些场景中就需要一个有序可重复的集合,有些场景需要一个不重复的集合,其实所有的集合,在概念上也只是将数组的概念扩大了,数组虽然可以存储元素,但是数组有很大的局限性,例如:一旦创建,大小不可改变等。

方便编码

       JDK已经封装了常规用法,开发人员无需关心具体的底部数组的数据处理,有更多的精力关注具体业务代码,同时还简化了代码,并且提高了代码的可读性。

性能要求

       针对List接口,JDK内部实现了一些常用的集合类,基本这些类已经能够完全满足我们的日常开发需求了,JDK的这些实现都是经过了严密的考量才得到的这些API,它们的性能基本都是比较高的,而如果我们自己实现,可能会受限于开发者自身的技术水平,无法进行周密的考量以及设计,所以性能会有所下降。另外:不同的List实现针对不同场景有不一样的表现,ArrayList随机访问速度块,但是插入删除慢,LinkedList插入删除快,但是访问速度慢。此外还有多线程下要保证线程安全的Vector。不同的场景,选用不同的集合实现类,以求达到性能最优的效果。

List的使用场景

List的使用场景其实从它的设计理念其实就可以得出结论:

  • 首先业务场景必须是需要一个存放元素的容器,并且必须是单列存储,而不是Map那种k-v双列存储
  • 其次需要存储的元素是允许重复的,并且是有序的,所谓有序就是存入的顺序和取出的顺序是一致的
  • 集合中是可以存储多null元素的
  • 如果需要快速插入、删除元素,使用LinkedList,如果需要快速随机访问元素,使用ArrayList
  • 对于多线程环境下,可以考虑使用Vector,或者在进行更新集合操作时加入同步逻辑。

List的实现

       在JDK源码中,List接口的实现有很多,除了比较常用的ArrayList、LinkedList这两个常用的集合外,还有其他的一些集合,比如:Vector、Stack、AbstractList等等,这里不再详细将每个实现类都依次介绍,着重介绍一下ArrayList、LinkedList和Vector,因为这三个是面试经常被问起的。

ArrayList

原理

       ArrayList其实就相当于是一个容量可变的数组,它的内部用一个Object数组 elementData 作为存储容器,我们在使用无参构造方法的时候,它的elementData内部是空的,直到我们第一次调用add方法添加元素时,容器大小会扩容为初始大小10。当容器中的元素达到十个后,后续的添加元素的时候,会触发容器扩容,容器的容量扩增为原来的1.5倍,这个扩容的操作是在添加元素时进行判断的,每次添加新元素的时候,都会进行剩余容量的判断,如果达到当前容器的最大值,就进行1.5倍扩容。

       ArrayList的实现其实没有什么比较复杂的地方,都属于常规的基于数组的增删改查操作,ArrayList的优势在于随机访问,随机访问的时间复杂度是一个常量,这个也好理解,它底层本身用的就是数组,数组可以快速定位元素;但是数组的缺点它也会有,它的增删操作的效率都是比较低的,如果容器中元素过多,增删的代价是很大的,因为它会涉及到整个数组空间的移动。

存在的问题

内存浪费

       针对上面的原理分析,可以发现,ArrayList是存在浪费存储空间的情况的,因为它内部的elementData数组经常是有空余的,而数组的大小一旦创建,都是固定的,不可动态改变。

线程不安全

       很明显,ArrayList内部的方法没有加锁,所以它是线程不安全,如果多线程环境下使用可变的数组容器集合,可以考虑使用Vector。

       也可以考虑使用Collections工具类构造一个线程安全的ArrayList:

Collections.synchronizedList(new ArrayList<String>());

       但是一般也不建议这么做,它的粒度过于粗糙,性能比较差。

       也可以在使用ArrayList时,在业务代码上针对某段代码加锁,这样粒度更小,效率更高一点,但是会影响代码书写逻辑,同时也影响代码阅读。

       也可以考虑使用CopyOnWriteArrayList,它是线程安全的,实际上它是采用了一种叫做写时复制的方式添加元素,也就是说当我们需要往List中添加元素的时候,它会将原有的数组复制一份,然后将新元素加入到复制后的数组中,然后将原数组引用指向新数组从而完成添加操作,另外为了防止出现线程安全问题,它在add操作时,会锁住整个List,以防止并发环境下复制出了多个数组。但是它的读结果是分情况的:

  • 如果写操作未完成,那么直接读取原数组的数据;
  • 如果写操作完成,但是引用还未指向新数组,那么也是读取原数组数据;
  • 如果写操作完成,并且引用已经指向了新的数组,那么直接从新数组中读取数据。

       可以发现,它对内存的要求很高,因为需要复制整个数组内容,另外它的读虽然可以很快返回数据,但是数据并不一定是最新的数据,所以不能用于实时读的场景,但是可以做到最终一致性。

插入和删除速度慢

       如果业务场景中需要频繁插入和删除元素,可以考虑使用LinkedList,如果查询占据大多数,才考虑使用ArrayList,注意,这种性能上的差距只有在高频度的操作中才能拉开明显的差距,否则性能上基本相差不大。这里可以写一个简单的测试用例,例如:进行一百万次的ArrayList插入操作,也就是调用100万次的add方法,计算消耗的时间;然后比较同样次数下LinkedList的add操作需要的时间,就能发现差异了。

其他

       这里稍微提一下ArrayList的序列化操作,通过ArrayList的JDK源码,可以发现,它内部的elementData修饰符是transient,所以在序列化的时候,这个字段的内容是忽略的,那么它是如何实现将内容序列化的呢?这是因为ArrayList内部重写了writeObject方法,在进行序列化的时候,如果待序列化对象所属的类没有定义writeObject方法时,会执行默认的序列化写出,如果定义了该方法,就会调用writeObject方法执行自定义的序列化逻辑,也就是说:我们可以通过定义writeObject方法来自定义序列化数据内容,这里ArrayList就采用了这种重写的方式,而且序列化时,它将数组内已经写入的内容读取并加入序列化写出的流中,但是会忽略那些多余空位上的null,从而避免了序列化多个null的情况。

LinkedList

原理

       LinkedList的底层实现是利用静态内部类Node来实现的,它内部有三个元素,分别存储当前元素、前面一个节点的引用以及后面一个节点的引用。

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的结构可以看到,链表中的节点是双向的,所以就不难理解它的优势为何在增删方面,因为任何的增删操作只会影响相邻的前后两个节点,不会波及到整个链表空间。

       对于链表中的下标查找,这里JDK内部做了一个小小的优化,在遍历的时候,传入index,在LinkedList进行下标查找时,会将整个链表分成基本相等的两部分,即:size >> 1,然后与index比较,如果超过这个 size >> 1 的值,就从后往前遍历查询,否则从前向后遍历查询,这种查找方式叫做折半查找,访问的复杂度为 O(N/2)

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

特性

       在查阅LinkedList源码时,发现它同时实现了Deque接口,该接口是 Queue 队列的子接口,它表示一个双端队列,故 LinkedList 自动具备双端队列的特性。

存在的问题

空间需求

       因为每个节点都需要保存它相邻的前后节点引用,所以需要一份额外的空间来存储这些引用,在链表特别长的时候,维护这段引用所需要的空间也是比较大的。但是相比与ArrayList以及Vector,免去了数组多余空位的问题,因为是链表,所以也就没有了扩容这个概念了,每个添加的元素作为节点直接加在链表上就行。

增删位置不在首末位置

       在进行插入或者删除的时候,乃至于替换的时候,如果是在链表的两端,速度非常快,甚至于在靠近两端的位置都算比较快的;但是如果链表长度很长,恰巧需要插入或删除的位置在中间,势必会必须进行一次定位查找工作,而LinkedList的弱点就在定位查找上,虽然JDK进行了优化,采用了折半查找,时间复杂度仍然是O(N/2),实际上也就是 O(N) ,仍然不敌ArrayList的O(1)。

       相应的ArrayList中,如果我们插入和删除的位置在两端,速度是非常快的,但是如果我们增删的位置恰巧在中间,那效率肯定就会明显下降,因此不能一味地就说LinkedList定位慢而删除快,ArrayList定位快而删除慢,它同样与集合中元素的数量有很大关系。

       考虑得更细一点,如果是尾部增加的情况,LinkedList需要重新向JVM申请内存空间,而ArrayList不需要,因为它一开始就会申请较多的空间,并且可以通过初始化capacity来避免动态膨胀的开销;

线程安全问题

       同样LinkedList也不是线程安全的,所以在多线程环境下如果不加锁,会出现问题,而且当多个线程同时获取到相同的节点的时候,如果多个线程同时在此节点后面插入数据的时候会出现数据覆盖的问题。

       可以考虑使用Collections工具类创建一个线程安全的LinkedList,例如:

List<String> list = Collections.synchronizedList(new LinkedList<String>());

       也可以是考虑使用ConcurrentLinkedQueue。比较推荐ConcurrentLinkedQueue,第一种方案效率太低,因为99%的情况下都不是并发情况,所以进行加锁解锁操作很耗费资源和性能。

       当然也可以针对具体的业务逻辑,对代码段进行加锁操作。这样控制并发安全的代码就是在具体业务代码中的。

Vector

原理

       它的定义与ArrayList一模一样(实现的接口,继承的类),所以说它的使用方式基本与ArrayList没有什么太大的区别,都是作为一种容器大小可变的数组结构来存储数据,它的内部也是一样通过Object数据组来保存数据,Vector的源码注释上说如果在不需要保证线程安全的环境中使用时,可以使用ArrayList来代替Vector。

       但是与ArrayList相比,它又有一些不同,首先最明显的就是线程安全问题,ArrayList是非线程安全的,我们在查看ArrayList和Vector源码时可以看到,前者的add 以及 remove 方法上都没有加锁,但是后者的add 和 remove方法上都有加锁操作,但是在Java中synchronized锁是非常重的锁,开销很大,尤其是在方法上的锁,会严重降低程序的处理性能,如果没有特殊必要,尽量避免使用这种方式,所以Vector的缺点也很明显,效率很慢,基本很少使用。

       另一方面,它的扩容机制也与ArrayList有所不同,ArrayList的扩容是每次扩大为原来的1.5倍,初始容量是10,但是对Vector来说,初始容量一致,但是每次到达最大容量扩容时,默认情况会增大为原来的2倍,也就是10-->20-->40这种速度进行扩张。同时Vector构建对象时,也支持额外传入步长capacityIncrement参数,默认情况为0,如果指定了,后续扩增的时候,容量就会扩大为:oldCapacity + capacityIncrement。

private int newCapacity(int minCapacity) {
    int oldCapacity = elementData.length;
    //capacityIncrement默认为0
    //如果指定数值,每次增加就会按照传入的步长增加,否则直接加上oldCapacity
    int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                     capacityIncrement : oldCapacity);
    //minCapacity是当前元素个数+1
    if (newCapacity - minCapacity <= 0) {//除非数字溢出,否则该条件不会成立
        if (minCapacity < 0)
            throw new OutOfMemoryError();
        return minCapacity;
    }
    return (newCapacity - MAX_ARRAY_SIZE <= 0)
        ? newCapacity
        : hugeCapacity(minCapacity);
}

存在的问题

浪费存储空间

       这里的浪费存储空间是相比于ArrayList来说的,它比ArrayList更加浪费空间,因为ArrayList的扩容每次扩增为原来的1.5倍,而它默认情况下被扩增为原来的2倍,所以可以考虑使用capacityIncrement参数来调节扩容的比例。

性能问题

       相比于其他两种List来说,它是线程安全的,所以并发环境中可以保证结果的准确性,但是付出的代价也是昂贵的,因为在正常业务场景中,可能99%的情况都是不需要并发的,这就造成了严重的性能浪费,而且它的锁还是加在方法上的,这种加锁的粒度非常粗糙,非常影响程序的执行效率。如果可以的话,尽量考虑不使用Vector,采取前面ArrayList的所说的并发安全的使用方式即可。

fail-fast

什么是fail-fast

       fail-fast它是Java集合中的一种错误机制,字面意思就是快速失败,当多个线程对同一集合进行操作时,就可能会产生fail-fast事件。它是一种在并发环境中,检测数据一致性的一种手段,在遍历时如果发现其他线程有修改操作,直接抛出异常,导致遍历失败。

       fail-fast只是一种错误检测机制,它只能用来检测错误,JDK并不保证fail-fast一定会发生,如果在多线程环境下,建议使用concurrent包下的类去取代util包下的类。

原理

       假设:当我们现在有两个线程A和B,同时对同一个ArrayList集合通过iterator进行操作,在实际运行时会抛出ConcurrentModificationException,这个异常是在Iterator遍历时抛出的,ArrayList的Iterator是在父类AbstractList中定义的,可以查看其源码,这里我就不贴出源码了,可以自行查阅源码,找到一个叫 Itr 的内部类,它实现了Iterator接口,这里直接给出结论:

       在Iterator进行操作时,它的内部会维护一个modCount值,这个值会在每次增加或删除操作时加1,也就说它类似于一个更新计数器,只要有更新操作,就会变化,在遍历时,有一个expectedModCount值与当前的modCount值进行比较,如果不一致,就认为当前的集合已经被修改过了,存在数据版本不一致的问题,抛出异常。结合上面的例子就是:此时A线程将假设对集合进行了N次修改,此时modCount值就是N,此时如果B也对其进行了一次修改,那么modCount就会变为N+1,然后A对集合进行遍历时,它的expectedModCount是N,但是此时的modCount已经变成了N+1,此时就会出现不一致,抛出异常。

发生的时机

       根据原理可以得出结论:当多个线程对同一个集合进行操作的时候,某线程访问集合的过程中,该集合的内容被其他线程所改变(即其它线程通过add、remove、clear等方法,改变了modCount的值);这时,就会抛出ConcurrentModificationException异常,产生fail-fast事件。

如何解决fail-fast

       首先可以考虑避免多线程环境中,在没有采取任何线程安全措施的情况下对同一集合进行并发操作,从根本上杜绝fail-fast发生的情况。

       也可以考虑使用CopyOnWrite容器,就是前面说过的CopyOnWriteArrayList,其实还有一个CopyOnWriteSet,它只是一种优化策略,原理会在后面的fail-safe部分会提到。

fail-safe

什么是fail-safe

       其实上面在介绍如何解决fail-fast的时候,提出的CopyOnWrite就是fail-safe的实现,相比于fail-fast快速失败,fail-safe叫安全失败,结合fail-fast对比,当多线程同时修改同一个容器时,快速失败就是保证在遍历时,如果发现数据被其他线程修改,则快速给出失败响应,所以它会及时抛出ConcurrentModificationException异常,而fail-safe表示的是如果多线程同时操作同一个集合,集合可以正常安全的遍历,但是遍历的结果并不会保证实时性。

       所以说fail-safe遍历时不是直接在集合内容上访问,而是先复制原有集合内容,更新操作都是在复制后的集合内容上进行的,遍历时仍然遍历的是原集合内容,这就是安全失败机制。

原理

fail-safe的原理是:开始时大家都共享一个内容,当某个线程需要修改内容的时候,把原内容copy一份,在新的copy上做修改,这是一种叫做延时懒惰的策略。

在写时加锁,读的时候不加锁,因为写的时候针对新copy的部分进行修改,读的时候还是针对老的数据进行读取,读写分离,虽然不能保证数据的实时一致性,但是可以保证最终一致性。Java的concurrent包下就有CopyOnWrite容器(简称COW)的实现:CopyOnWriteArrayList和CopyOnWriteSet。

使用场景

CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景等等。只要对与数据的实时一致性要求不是那么苛刻的多线程场景都可以考虑使用它,这个数据不一致的持续时间一般都很短,只要在复制并更新成功后,数据就会恢复一致性。

存在的问题

  • 当迭代器遍历开始的那一刻开始,它所拿到的集合内容是不会发生变化的,在遍历期间发生的更新改变,迭代器是不知道的,所以它并不能访问到在这期间发生变化的内容。
  • 内存占用较大,如果过大,有可能频繁触发MinorGC或者FullGC,影响系统的处理性能。

其他

其实List的实现类还有很多,这里篇幅有限,就不做逐个介绍了,可以结合JDK源码进行阅读,了解不同List的实现类,有哪些应用场景,其实这些实现一般也不太常用,可以作为一个拓宽知识来了解,在需要用的时候,查阅一下JDK文档。

推荐阅读更多精彩内容