Java集合源码分析:LinkedList

1,320 阅读7分钟

首先我们知道,LinkedList的底层实现是一个双向链表,链表与数组的差别我们更是清楚,所以LinkedList弥补了ArrayList增删较慢和效率较低的问题了,但是在改和查方面有逊色于ArrayList,所以在使用时要根据业务场景灵活选择。对于这两个使用最频繁的集合类,掌握他们的源码并正确使用,可以让我们的代码更高效。

步入主题,LinkedList既实现了List,又实现了Deque接口,前者使它能够像使用ArrayList一样使用,后者又使它能够承担队列的职责。LinkedList内部结构是一个双向链表,我们在分析ArrayDeque时提到过这个概念,就是扩充单链表的指针域,增加一个指向前一个元素的指针previous。

一、父类AbstractSequentialList

AbstractSequentialList是LinkedList的父级,它继承自AbstractList,并且是一个抽象类,它主要为顺序表的链式实现提供一个骨架:

意思是它的主要作用是提供一个实现List接口的骨架,来减少我们实现基于链式存储的实现类时所需的工作量。AbstractSequentialList并没有做很多特殊的事情,其中最主要的是提供一个方法的默认实现,并将以下方法抽象,以期有更符合场景的实现:

public abstract ListIterator listIterator(int index); 1 其他的一些方法都利用了这个listIterator方法,我们不在一 一查看了。下面我们来分析一下LinkedList。

二、LinkedList的继承接口

image.png

从这个结构图中,我们可以看到。LinkedList也实现了Cloneable、java.io.Serializable等方法,借鉴于ArrayList的经验,我们可以想到它的Clone也是浅克隆,在序列化方法也采用了同样的方式,我们就不再一 一说明了。

三、构造方法与成员变量

在正式你开始之前,我们先说说数据单元Node

在介绍链表结构时提到过,其数据单元分为数据域和指针域,分别存储数据和指向下一个元素的位置,在java中只要定义一个实体类就可以解决了,就像下面这样:

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的成员变量主要有三个,而且其用法清晰可见 ,非常简单。

// 记录当前链表的长度
transient int size = 0;

// 第一个节点
transient Node<E> first;

// 最后一个节点
transient Node<E> last;

构造函数

因为链表不会涉及到长度方面的问题,所以不会涉及到扩容等问题,其构造函数也是非常简单和简洁。

public LinkedList() {
}

public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}

一个默认的构造函数,没有初始化list的大小 ,因为链表不像数组那样,初始化时必须声明其大小或者容量,它通过指针的移动来指向下一个内存地址的分配。 一个有参的构造器,用其他集合初始化,调用了addAll方法。

三、 常用方法

getFirst()和 getLast()

image.png

这两个方法就比较简单了,都是先获取对应的节点,然后判空,非空的话,就取出该节点里的值。

removeFirst() 和 removeLast()

image.png

image.png

这两个方法也是先找到对应的节点,然后将该节点删除,然后在改变前后对应的指针。

addFirst() 和 addLast()

image.png

image.png

这里也是先找到头节点,然后创建一个新的节点,这个新节点的前缀节点指向null,后缀指向之前的头结点,这个新节点就变成了新的头结点。向末尾添加元素也是一样的,修改对应的指针指向就可。

我们再来看看在某一个节点的前面加一个节点

image.png

在succ的节点前面加一个元素e,先定义一个pred来记录succ的前缀节点,然后定义一个新的节点,succ的前缀执行这个前缀,,如果这个之前的pred节点为空,那么这个新节点就位头结点,不是的话,这个pred的后缀就指向这个新的节点。

add() 和 remove()

image.png

普通的add方法就是在链表的最后加一个元素,上面已说过,这里就不再重复了;移除某一个元素用到了这个断开链接方法,

image.png

传进来一个参数,这个参数为一个节点x,将x这个节点里面的值取出来,将该节点的前缀节点和后缀节点都先记录,如果前缀节点为空的话,也就是说删除的节点为首节点,那就直接把x节点设为头结点就行了,如果不是头结点的情况,x的前缀节点指向x节点的后缀节点就行,x的后缀也指向空;如果x节点时尾结点的话,那就将它的前缀节点设置为尾结点就行,x的后缀节点的前缀指向它的前缀节点就行,最后再将值设为空,size-1。

我们再来看看在指定的地方增加和删除元素

add(int index, E element) 和 remove(int index)

image.png

image.png

image.png

首先判断index下标是否越界,是否正确,如果下标为size大小,则增加的节点为尾结点,直接linkLast方法,上面讲过,这里就不说明了。如果不是的话,就调用linkBefore方法:

image.png

先把要插入的这个位置之前的节点找到,也就是succ,在找到他的前缀节点,定义一个新的节点,succ的前缀就指向这个新节点,这里和上面所讲的都差不多,就不做多的说明了

移除的话,也是先判断index是否合法,然后用unlink方法移除。

可以看到,LinkedList提供了一系列方法用来插入和删除,但是却没有在实现一个方法来进行查询,因为对链表的查询是比较慢的,所以他是通过其他的方法来实现的,我们来一起看一看:

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

//可以说尽力了
Node<E> node(int index) {
    // assert isElementIndex(index);
    
    //size>>1就是取一半的意思
    //折半,将遍历次数减少一半
    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;
    }
}

最后,我们看下它如何对应那些继承来的方法:

//引用了node方法,需要遍历
public E set(int index, E element) {
    checkElementIndex(index);
    Node<E> x = node(index);
    E oldVal = x.item;
    x.item = element;
    return oldVal;
}

//也可能需要遍历
public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
            linkLast(element);
    else
        linkBefore(element, node(index));
}

//也要遍历
public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}

public E peek() {
    final Node<E> f = first;
    return (f == null) ? null : f.item;
}

public E element() {
    return getFirst();
}

public E poll() {
    final Node<E> f = first;
    return (f == null) ? null : unlinkFirst(f);
}

public E remove() {
    return removeFirst();
}

public boolean offer(E e) {
    return add(e);
}

public boolean offerFirst(E e) {
    addFirst(e);
    return true;
}

//...

四、 关于遍历LinkedList

1、for循环

package collectionTest;

import java.util.LinkedList;

/**
 * @program: juc
 * @description
 * @author: 不会编程的派大星
 * @create: 2021-05-17 09:14
 **/
public class LinkedListTest {

    public static void main(String[] args) {

        LinkedList<Object> linkedList = new LinkedList<>();

        linkedList.add("a");
        linkedList.add("b");
        linkedList.add("c");
        linkedList.add("d");
        linkedList.add("e");


        for (int i = 0; i < linkedList.size(); i++) {
            System.out.println(linkedList.get(i));
        }
    }
}

这里每找一个元素,都要从第一个元素a开始找,所以找第n个元素,相当于要遍历N次,所以提供了node方法 ,折半查找。但效率是真的低,这里不推荐使用for循环。

2、Iterator迭代器

public class LinkedListTest {

    public static void main(String[] args) {

        LinkedList<Object> linkedList = new LinkedList<>();

        linkedList.add("a");
        linkedList.add("b");
        linkedList.add("c");
        linkedList.add("d");
        linkedList.add("e");


        /**
          for (int i = 0; i < linkedList.size(); i++) {
            System.out.println(linkedList.get(i));
        }
         */

        Iterator<Object> iterator = linkedList.descendingIterator();
        while(iterator.hasNext()){
            System.out.println(iterator.next());
        }
    }
}

这里用Iterator的next()则是顺着链表节点顺序读取数据的效率就很高,而不是像for那样每次都要从头开始找。

五、LinkedList总结

1、 LinkedList非常适合大量数据的插入与删除,但其对处于中间位置的元素,无论是增删还是改查都需要折半遍历,这在数据量大时会十分影响性能。在使用时,尽量不要涉及查询与在中间插入数据,另外如果要遍历,也最好使用foreach,也就是Iterator提供的方式。

2、 LinkedList 集合实现了一个 Deque 接口,这是一个双向队列接口,双向队列就是两端都可以进行增加和删除操作,可以模拟队列操作。

3、 LinkedList 实现了 Cloneable 接口和 Serializable 接口,分别用来支持克隆以及支持序列化。