【JAVA基础】集合类源码分析_LinkedList

LinkedList是实现List接口的另一个重要的集合类。

特性介绍

LinkedList类实现了List接口和Deque接口,所以LinkedList具备了列表及双端队列的所有特点,LinkedList可以存储所有类型的元素,包括null值。为什么可以存储null值?后边解释。此外,与ArrayList一样,LinkedList是非线程安全的,如果有多个线程同时访问LinkedList实例,并且至少一个线程从结构上修改了list,那么必须显式地同步此list,这通常通过同步某个封装此list的对象来完成。如果没有这种对象,可以通过Collections.synchronizedList方法来实现list的同步。同时,如果针对LinkedList实例生成一个迭代器,那么此迭代器具备fast-fail机制,此机制与ArrayList的fast-fail机制相同。

源码分析

首先,LinkedList有三个属性如下:

// List中存储元素的个数
transient int size = 0;

// 集合中第一个元素
transient Node<E> first;

// 集合中最后一个元素
transient Node<E> last;

size不解释了,first和last为list中的首/尾元素,之所以有这两个属性,是因为LinkedList实现了Deque这个双端队列的接口,所以first和last的存在使得LinkedList可以分别在头和尾部进行操作,同时应注意到,first及last的类型为Node类型,这是一个封装在LinkedList里的一个静态内部类,代码如下:

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

这是一个泛型类,存储类型为E的元素,它的里面有三个属性,分别代表:

  • item:当前位置存储的元素引用
  • next:代表下一个元素的Node引用
  • prev:代表前一个元素的Node引用

Node是实现双向链表的根本所在,它使得我们在增加/修改/删除元素的时候,不必担心扩容等问题,只需知道当前元素的前一个及后一个元素即可,细节我们后边讨论。

回过头来我们看LinkedList的构造方法,LinkedList有两个构造方法,一个是无参的构造方法,构建一个空的list。一个是传入一个集合,并以此集合初始化LinkedList的构造方法,我们主要看这个方法:

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

方法首先构造一个空的list,然后调用addAll将参数集合中的所有元素加入到此list中,addAll方法代码如下:

public boolean addAll(Collection<? extends E> c) {
    return addAll(size, c);
}

又调用了另一个addAll方法,同时将当前的size也传入方法,那么这个addAll方法的代码如下(由于方法语句较多,所以我将代码分析以注释的形式展现):

public boolean addAll(int index, Collection<? extends E> c) {

    // 这个是检查传入的index是否合法,不可小于零或大于当前的size,构造方法中传的是size,所以检查OK
    // 其它很多方法中都调用了这个方法,后面不再赘述
    checkPositionIndex(index);

    // 先将集合c转换为数组
    Object[] a = c.toArray();

    // 获取集合的长度,即要向list中添加元素的个数

    // 如果集合是空的,返回false
    if (numNew == 0)
        return false;

    // 定义两个节点,pred代表前一个元素,succ代表后一个元素
    Node<E> pred, succ;

    // 传入的index等于size,则把pred赋值为last,succ设为空
    if (index == size) {
        succ = null;
        pred = last;
    } else { // 不等于,则表示是在list中间某个位置插入集合
        // 找到插入位置的元素,将它赋值给succ节点
        succ = node(index);
        // 将succ节点的前一个node赋值给pred节点,这两个操作相当于把插入位置的prev跟next属性都给找到并赋值
        pred = succ.prev;
    }

    // 遍历集合
    for (Object o : a) {
        @SuppressWarnings("unchecked") E e = (E) o;
        // 调用Node构造方法,将当前元素e封装在一个Node对象中,此元素的前一个元素为pred
        // 后一个元素为null,如果是构造方法中调用的此方法,则添加的第一个元素,它的pred即为last,此时也是空
        Node<E> newNode = new Node<>(pred, e, null);

        // 如果是添加的第一个元素,则此时pred为空,那么将Node对象赋值给first属性
        if (pred == null)
            first = newNode;
        else
        // 若pred不为空,则表示list中已有元素,那么就把当前元素(即e)赋值给前一个元素的next属性
            pred.next = newNode;

        // 将当前Node对象赋值给pred,相当于迭代器当中把位置指向下一个
        pred = newNode;
    }

    // 集合添加完之后的操作
    // 若是在list末尾插入集合,则succ此时为空,那么将集合中最后一个元素赋值给last
    if (succ == null) {
        last = pred;
    } else {
        // 因为插入集合的最后一个元素赋值给pred,插入完毕后,将插入集合最后一个元素的next设为原插入位置的node
        pred.next = succ;
        // 并将原插入位置的prev属性设置为集合最后一个元素的node引用
        succ.prev = pred;
    }

    // 将当前list的size加上集合的元素个数
    size += numNew;
    // 所有对LinkedList结构上的操作都会修改modCount,此与迭代器的fast-fail机制有关,后面介绍
    modCount++;
    // 返回true值
    return true;
}

OK,看完addAll代码之后,总结一下其流程:

首先,addAll用于插入一个集合到LinkedList当中,它先会找到插入点,插入点分两种情况:
     ***第一种情况***,在末尾插入,此时index == size,那么会把当前链表中最后一个元素last赋值给一个pred变量,这时再循环插入集合元素,构造第一个元素Node时,就将pred变量赋值给Node的prev属性,于是集合的第一个元素就接到了链表的最后一个元素后面,同时把封装集合第一个元素的Node对象赋值给pred变量,然后再循环,以此类推,直至全部插入完毕,此时pred变量存储的是封装集合最后一个元素的Node对象,把此Node对象赋值给last属性;
     ***第二种情况***,在LinkedList中间插入,这时会将插入点的Node对象赋值给succ变量,把插入点Node对象的前一个Node对象赋值给pred变量,然后循环插入,插入的起始依旧是接着pred,插入完毕之后,把succ赋值给插入集合的最后一个Node的next属性,这样就串联起了整个list

这是源码分析遇到的第一个重要方法,它也体现了LinkedList插入操作的基本思路,由于Node的存在,在进行插入/修改/删除等操作的时候LinkedList的效率是远高于ArrayList的,但是LinkedList的查询效率是很低的,因为链表的查询方式只能是遍历,或者叫做“二分遍历”。我们来看代码:

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

代码首先同样是检查要获取的位置是否合法,然后调用了一个node(index)的方法,node方法代码如下:

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的查询效率很低了,因为无论传入的index是多少,LinkedList都需要进行遍历查找,如果index小于元素个数的一半,那么就从first元素开始往后查找,否则从last元素开始往前查找,可以想象,如果LinkedList中的元素非常多,而查询的又是一个靠近中间位置的元素,那么需要遍历近一半的元素之后才能找到,效率当然可怕。

好了,看完插入和查找的思路之后,我们来看看删除元素的代码:

public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}

同样,先是检查要删除的元素位置是否合法,然后调用unlink方法,代码如下,我依旧在代码中注释解释:

E unlink(Node<E> x) {
    // assert x != null;
    // x为要remove的Node对象,获取它的各个属性
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    // prev等于空,表示待删除的Node为LinkedList中的第一个元素,即first,那么此时,将待删除元素的下一个元素赋值给first
    if (prev == null) {
        first = next;
    } else {
        // prev不等于空,则把待删除元素的前一个元素的后一个元素赋值为待删除元素的后一个元素
        prev.next = next;
        // 然后将待删除元素的前一个元素引用设置为空
        x.prev = null;
    }

    // next等于空,则表示待删除的元素是LinkedList中的最后一个元素,此时将待删除元素的前一个元素赋值给last
    if (next == null) {
        last = prev;
    } else {
        // 不为空则表示要删除的不是最后一个元素,那么此时将待删除元素的前一个元素赋值给待删除元素的后一个元素的前一个元素
        next.prev = prev;
        // 待删除元素的后一个元素引用设置为空
        x.next = null;
    }

    // 上边两个if语句块比较绕,总结起来就是:获取到待删除节点的前节点和后节点,然后把前/后节点相连,把待删除节点跟前/后节点解除联系

    // 待删除元素设置为空
    x.item = null;
    // 将元素个数减1
    size--;
    modCount++;
    return element;
}

好了,以上介绍了基于双向链表属性的插入/查询/删除操作思路,还有其它add/remove等方法,其实现的原理都与上面介绍的类似,或更简单,所以此处不赘述了。可以看到,若是在链表两端进行这些操作,效率还是很高的,但若是在中间部分进行这些操作,都依赖于一个node(index)方法,这个node方法做的事情就是遍历(至多遍历一半,这样更准确)这个链表,直至找到对应的元素,然后返回,从这点上来看,LinkedList的效率又不是很高,不过这些操作不涉及扩容及拷贝等问题,所以相对于ArrayList来说,LinkedList插入和删除操作的效率还是更高的。

一开始说到,LinkedList还实现了Deque接口,那么它就能实现队列的相关操作,我们来看相关方法:

1.peek()&poll()方法:此两个方法都是获取队列中的第一个元素(注意,是元素本身,不是封装元素的Node对象),不同的是poll()方法会删除第一个元素,peek()不会删除。
2.offer(E e)方法:向队列末端添加一个元素e。
3.push(E e)方法:向队列头插入一个元素e,此操作类比为压栈操作。
4.pop()方法:从队列头获取一个元素,并删除之,此操作类比为弹栈操作。

需要说明的是,LinkedList提供的以上这些操作,其根本都是依赖于first和last这两个属性,addFirst/addLast/linkFirst/linkLast等方法就是为上述这些模拟队列,栈操作方法所依赖的底层实现,逻辑比较简单,看过addAll方法及node方法的源码分析之后这些方法都比较容易理解。

总结

由代码可以看出,LinkedList有如下特点:

  • 按需分配空间,不像ArrayList一样需要事先分配很多空间
  • 不能够随机访问,以索引方式访问的效率很低,因为必须从头或从尾遍历链表,时间复杂度为O(N/2)
  • 按照内容查找元素也是需要遍历链表,效率为O(N)
  • 在链表头尾添加删除元素的效率很高,因为有专门维护的属性first/last,效率为O(1)
  • 在中间插入删除元素的效率也是需要先从头或从尾遍历链表,效率很低,为O(N/2),但是修改本身的效率很高,为O(1)

OK,LinkedList源码分析就是这样,没有逐个分析方法,而是以注释的方法分析了几个较为难理解的方法,其它方法原理类似,LinkedList本身的实现也比较容易理解,下一篇分析HashMap。

    原文作者:java集合源码分析
    原文地址: https://blog.csdn.net/tianma0314/article/details/78155304
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞