Collection集合体系全景图(二)

879 阅读10分钟

Collection家族:

image.png

LinkedList详解

image.png

  • LinkedList 继承自 AbstrackSequentialList 并实现了 List 接口以及 Deque 双向队列接口,因此 LinkedList 不但拥有 List 相关的操作方法,也有队列的相关操作方法。
  • LinkedList 和 ArrayList一样实现了序列化接口 SerializableCloneable 接口使其拥有了序列化和克隆的特性。

LinkedList 一些主要特性:

  • LinkedList 集合底层实现的数据结构为双向链表

  • LinkedList 集合中元素允许为 null

  • LinkedList 允许存入重复的数据

  • LinkedList 中元素存放顺序为存入顺序。

  • LinkedList 是非线程安全的,如果想保证线程安全的前提下操作 LinkedList,可以使用 List list = Collections.synchronizedList(new LinkedList(...)); 来生成一个线程安全的

LinkedList 一些常用方法

  • add(E e):在链表末尾添加元素e。
  • add(int index, E e):在指定位置插入元素e。
  • remove():删除链表末尾的元素。
  • remove(int index):删除指定位置的元素。
  • get(int index):获取指定位置的元素。
  • set(int index, E e):将指定位置的元素替换为e。
  • size():返回链表中元素的个数。
  • clear():清空链表中的所有元素。
  • isEmpty():判断链表是否为空。
  • indexOf(Object o):返回元素o在链表中第一次出现的位置,如果不存在则返回-1。
  • lastIndexOf(Object o):返回元素o在链表中最后一次出现的位置,如果不存在则返回-1。 toArray():将链表转换为数组。

认识链表结构

链表是一种常用的数据结构,它的主要作用是用来存储和管理数据。链表的特点是可以动态地添加和删除元素,而且不需要预先分配内存空间。这使得链表在处理大量数据时非常高效。

链表的另一个优点是可以支持快速的插入和删除操作,因为只需要修改指针的指向即可,而不需要像数组一样移动大量的元素。这使得链表在实现一些高级算法时非常有用,比如图论中的最短路径算法和拓扑排序算法等,此外链表还可以用来实现栈和队列等数据结构。

链表的分类

单向链表

单向链表是一种线性数据结构,由一系列节点组成,每个节点包含数据和指向下一个节点的指针。单向链表只能从头节点开始遍历,每个节点只能访问其后继节点,不能访问前驱节点。

优点: 插入和删除操作的时间复杂度为O(1),只需要修改指针即可。 不需要预先分配内存空间,可以动态地增加或删除节点。

缺点: 不能随机访问节点,只能从头节点开始遍历,时间复杂度为O(n)。 需要额外的空间存储指针信息。

双向链表

双向链表是一种线性数据结构,每个节点包含数据和指向前驱节点和后继节点的指针。双向链表可以从头节点或尾节点开始遍历,每个节点可以访问其前驱节点和后继节点。

优点: 可以双向遍历,访问节点的前驱和后继节点,时间复杂度为O(1)。 插入和删除操作的时间复杂度为O(1),只需要修改指针即可。

缺点: 需要额外的空间存储指针信息。

循环链表

循环链表是一种特殊的双链表,其尾节点指向头节点,形成一个环。循环链表可以从任意节点开始遍历,每个节点可以访问其前驱节点和后继节点。

优点: 可以从任意节点开始遍历,时间复杂度为O(n)。 插入和删除操作的时间复杂度为O(1),只需要修改指针即可。

缺点: 需要额外的空间存储指针信息。 需要特殊处理尾节点的指针,容易出现死循环的情况。

为什么会有单、双链表之分?

如果单向链表要删除元素的话,不但要找到删除的节点,还要找到删除节点的上一个节点(通常称之为前驱),因为需要变更上一个节点中 next 的指针,但又因为它是单向链表,所以在删除的节点中并没有存储上一个节点的相关信息,那么我们就需要再查询一遍链表以找到上一个节点,这样就带来了一定的性能问题,所以就有了双向链表。

源码解析

image.png

image.png

//LinkedLis 节点个数
transient int size = 0;  
//LinkedLis首个节点
transient Node<E> first;  
//LinkedLis尾节点
transient Node<E> last;
//空参构造由于生成一个空链表 first = last = null
public LinkedList() {  
}  
带参构造传入一个集合类,来构造一个具有一定元素的 LinkedList 集合
public LinkedList(Collection<? extends E> c) {  
this();  
addAll(c);  
}
//这个方法
public boolean addAll(Collection<? extends E> c) {  
return addAll(size, c);  
}

该方法将指定集合中的所有元素添加到列表的末尾。它返回一个布尔值,表示是否成功添加了所有元素。该方法使用另一个重载方法 addAll(int index, Collection<? extends E> c),将指定集合中的所有元素插入到列表中的指定位置。

    public boolean addAll(int index, Collection<? extends E> c) {  
    //1.  检查索引是否满足要求,即 index >= 0 && index <= size。
    checkPositionIndex(index);  
    //将集合变成数组
    Object[] a = c.toArray();  
    int numNew = a.length;  
    //当数组长度为0时返回false
    if (numNew == 0)  
    return false;  
    //找到该索引值对应的节点的前驱节点和后继节点
    Node<E> pred, succ;  
    //index 等于当前链表的大小 size则说明要添加的元素应该放在链表的末尾,此时后继节点为空,前驱节点为当前链表的最后一个节点
    if (index == size) {  
    succ = null;  
    pred = last;  
    } else {  
    //node(index)方法找到索引值为index的节点,将该节点作为后继节点
    succ = node(index);  
    //该节点的前驱节点作为前驱节点
    pred = succ.prev;  
    }  
    // 遍历数组中的元素,将每个元素封装成一个新的节点,并插入到链表中。
    for (Object o : a) {  
    //将一个Object类型的变量o强制转换为泛型类型E的变量e
    @SuppressWarnings("unchecked") E e = (E) o;  
    Node<E> newNode = new Node<>(pred, e, null);  
    //如果前驱节点为空,则说明链表为空,将新节点设置为链表的第一个节点。
    if (pred == null)  
    first = newNode;  
    else  
    //则将前驱节点的后继节点设置为新节点
    pred.next = newNode;  
    //将前驱节点更新为新节点,以便下一次插入节点时使用。
    pred = newNode;  
    }  
    //说明插入的位置是链表的末尾,需要更新`last`的值为`pred
    if (succ == null) {  
    last = pred;  
    } else {  
      //说明插入的位置在链表中间,需要将pred的`next`指针指向succ,同时将succ的prev指针指向pred
    pred.next = succ;  
    succ.prev = pred;  
    }  
    //更新链表的大小和修改计数器
    size += numNew;  
    modCount++;  
    return true;  
    }

其他添加方法可以自行总结

  1. 检查索引是否满足要求,即 index >= 0 && index <= size。
  2. 将集合转换为数组。
  3. 如果数组长度为0,则返回false。
  4. 找到要插入的位置的前驱节点和后继节点。
  5. 遍历数组中的元素,将每个元素封装成一个新的节点,并插入到链表中。
  6. 更新链表的头尾节点。
  7. 更新列表的大小和修改计数器。
  8. 返回true表示添加成功。

删除节点

    /** * 删除头节点 
    * @return 删除的节点的值 即 节点的 element
     * @throws NoSuchElementException 如果链表为空则抛出异常
     */ 
    public E removeFirst() { 
    final Node<E> f = first; if (f == null) 
    throw new NoSuchElementException(); 
    return unlinkFirst(f); 
    } 
    /** * 删除尾节点 
    * @return 删除的节点的值 即 节点的 element 
    * @throws NoSuchElementException 如果链表为空则抛出异常 */ 
    public E removeLast() {
     final Node<E> l = last; if (l == null) t
     hrow new NoSuchElementException();
     return unlinkLast(l); 
    }


    //删除头节点 
    private E unlinkFirst(Node<E> f) {  
    final E element = f.item;  
    final Node<E> next = f.next;  
    f.item = null;  
    f.next = null; 
    first = next;  
    if (next == null)  
    last = null;  
    else  
    next.prev = null;  
    size--;  
    modCount++;  
    return element;  
    }
    //删除尾部节点 
    private E unlinkLast(Node<E> l) {  
    final E element = l.item;  
    final Node<E> prev = l.prev;  
    l.item = null;  
    l.prev = null; 
    last = prev;  
    if (prev == null)  
    first = null;  
    else  
    prev.next = null;  
    size--;  
    modCount++;  
    return element;  
    }
    //删除任意节点
    unlink(Node<E> x) {  
    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;  
    }

总结:具体实现过程是将要删除的节点的前驱节点和后继节点连接起来,然后将要删除的节点的前驱节点和后继节点的指针指向,最后将要删除的节点的元素值置为,同时更新链表的大小和修改计数器

CopyOnWriteArrayList线程安全的 List 集合

它的作用是在读多写少的场景下提高性能。它的实现方式是在写操作时,先将原有的数据复制一份,然后在新的数据上进行写操作,最后再将原有数据的引用指向新的数据。这样做的好处是读操作不需要加锁,因为读取的是原有数据,不会受到写操作的影响,而写操作也不会影响到读操作,因为写操作是在新的数据上进行的。这样就可以避免读写操作之间的竞争,提高了并发性能

缺点

由于每次写操作都需要创建一个新的数组,因此写操作的性能较差,尤其是在数据量较大时。此外,由于 CopyOnWriteArrayList 在写操作时需要复制整个数组,因此它的内存占用较大,不适合存储大量数据。因此,CopyOnWriteArrayList 在实际开发中使用较少。

排除心中疑问

线程安全和不安全的区别主要在于多线程并发访问时是否会出现数据不一致或者异常情况。 线程安全的集合类在多线程并发访问时,能够保证数据的一致性和正确性,不会出现数据不一致或者异常情况。

ArrayList线程不安全为什么还经常使用

  1. ArrayList的性能比较好,因为它是基于数组实现的,可以快速地访问和修改元素。
  2. 在单线程环境下,ArrayList的使用是非常安全的,因为只有一个线程在对其进行操作。
  3. 在多线程环境下,如果只有一个线程对其进行写操作,而其他线程只进行读操作,那么也是安全的。
  4. 在某些情况下,我们可以通过使用同步机制来保证ArrayList的线程安全性,比如使用Collections.synchronizedList()方法或者使用ConcurrentModificationException异常来避免多线程同时修改ArrayList的情况。

因此,尽管ArrayList是非线程安全的,但是由于其性能和易用性等方面的优点,仍然经常被使用。但是,在多线程环境下,我们应该尽量避免使用非线程安全的集合,而是使用线程安全的集合,比如Vector、CopyOnWriteArrayList等。

CopyOnWriteArrayList源码解读

    public boolean add(E e) {  
    final ReentrantLock lock = this.lock;  
    lock.lock();  
    try {  
    Object[] elements = getArray();  
    int len = elements.length;  
    //创建一个新的数组newElements,它的长度比原数组elements多1,
    //然后将原数组elements中的所有元素复制到新数组newElements中。
    //这个操作可以用来在数组末尾添加一个新元素。
    Object[] newElements = Arrays.copyOf(elements, len + 1);  
    newElements[len] = e;  
    setArray(newElements);  
    return true;  
    } finally {  
    lock.unlock();  
    }  
    }
    //transient表示该成员变量不会被序列化,即在对象被序列化时,该成员变量的值不会被保存;
    //volatile表示该成员变量是多线程共享的,任何线程在修改该变量时,都会立即将修改后的值刷新到主内存中,其他线程在读取该变量时,都会从主内存中读取最新的值
    //Object[] 表示该成员变量是一个数组类型,其中每个元素都是一个 Object 类型的对象
    private transient volatile Object[] array;
    final Object[] getArray() {  
        return array;  
}

首先获取了当前对象的锁,然后获取当前数组的元素将其复制到一个新的数组中,并在新数组的最后添加新元素。最后将新数组设置为当前数组,并返回true表示添加成功。最后释放锁。

以上就是关于List下3种常用实现进行详细讲解,内赠送一张总结图

38e7b7942d6529e3c4fe909c34e9357.png