Java集合:LinkedList (JDK1.8 源码解读)

Java基础

浏览数:103

2019-9-11

AD:资源代下载服务

LinkedList介绍

还是和ArrayList同样的套路,顾名思义,linked,那必然是基于链表实现的,链表是一种线性的储存结构,将储存的数据存放在一个存储单元里面,并且这个存储单元里面还维护了下一个存储单元的地址。在LinkedList的链表储存单元中,不仅存放了下一个存储单元的地址,还存放了上一个单元的储存地址,因为Linked是双向链表,双向链表就是可以通过链表中任意一个存储单元可以获取到上一个存储单元和下一个存储单元。

先看一下这个神秘的储存单元,在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;
     }
}

Node就是LinkedList的储存单元,在JDK1.6中叫Entry,不过结构还是一样的,里面有三个变量一个带这三个参数的构造方法,这三个变量分别是

1.item:存储在存储单元Node中的元素

2.next:下一个存储单元

3.prev:下一个存储单元

LinkedList的关注点

1.是否允许为空:是

2.是否允许重复数据:是

3.是否有序:是

4.是否线程安全:否

和之前讲的ArrayList的四个关注点一模一样

LinkedList的声明:

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    transient int size = 0;

    transient Node<E> first;

    transient Node<E> last;

    ...
}

LinkedList除了实现List、Cloneable和Serializable接口外还实现了Deque接口,说明LinkedList具有队列的特性,可以当做队列使用

举个简单的小栗子:

1 List<String> list= new LinkedList<>();
2 list.add("11");
3 list.add("22");
4 list.add("33");

LinkedList提供了两种构造方法,例子使用的是第一种也是最常用的无参构造器:

public LinkedList() {
}

这个构造方法没有执行其他任何的操作,这与jdk1.6有所不同,jdk1.6中声明了一个header节点,然后执行了 header.next = header.previous = header,jdk1.8中声明了first和last两个节点,但是构造的时候没有操作这两个节点。

第二种构造器和ArrayList一样提供了一个传入集合的构造方法public LinkedList(Collection<? extends E> c) 

添加元素

接着看第二行list.add(“11”)做了什么:

 1 public boolean add(E e) {
 2      linkLast(e);
 3      return true;
 4 }
 5 
 6 void linkLast(E e) {
 7      final Node<E> l = last;
 8      final Node<E> newNode = new Node<>(l, e, null);
 9      last = newNode;
10      if (l == null)
11          first = newNode;
12      else
13          l.next = newNode;
14      size++;
15      modCount++;
16 }

LinkedList的add方法执行的是linkLast方法,linkLast方法就是把元素e链接成列表的最后一个元素,

1.首先声明一个变量l,将这个l指向last也就是列表最后一个节点

2.new一个新的Node节点newNode,这个newNode就是新增元素的储存单元,根据之前给的Node的构造方法,它创建了一个上一个节点为l节点,储存元素为你新增的元素e,下一个节点为null的储存单元

3.将代表列表最后一个节点的last变量指向新的节点newNode,也就是将列表的最后一个储存单元变成了新的newNode节点

4.如果l==null,也就是列表最后一个节点是null,那么列表第一个节点也是newNode,也就是说如果列表是空,newNode就是第一个也是目前唯一一个储存单元,它既是头也是尾。如果之前的最后一个储存单元不是null,就将之前的储存单元的下一个节点地址改为新增的储存单元

可能说的比较绕比较抽象,画图表示一下:

假设null的内存地址是0x0000,新增元素“11”的存储单元地址是0x0001,元素“22”储存单元地址是0x0002,元素“33”储存单元地址是0x0003,每一步修改的地方我都用红色标记出来

 

查找元素

LinkedList查找元素也是使用的get方法,当然也有别的方法,先看一下get方法怎么写的:

 1 public E get(int index) {
 2      checkElementIndex(index); //index >= 0 && index < size
 3      return node(index).item;
 4 }
 5 
 6 Node<E> node(int index) {
 7      if (index < (size >> 1)) {
 8         Node<E> x = first;
 9         for (int i = 0; i < index; i++)
10             x = x.next;
11         return x;
12      } else {
13          Node<E> x = last;
14          for (int i = size - 1; i > index; i--)
15              x = x.prev;
16          return x;
17      }
18 }

LinkedList查找元素也是先检查下标,检查的判断已经写在第二行的备注中,然后通过node()方法去寻找元素

查询对应下标节点的时候首先用了一个判断,这个判断很有意思,它判断指定下标index是否小于元素个数的一半,如果小于一半也就是你指定的下标位于列表前半段它就从列表第一个节点开始遍历,通过循环获取每个节点的next节点,如果大于一半位于列表后半段就从列表最后一个节点开始遍历,循环获取每个节点的prev节点,这样做的好处就是假如列表有1000个元素,get(999)就得遍历999次才能找到元素,这就是双向链表的好处,通过维护前置节点,虽然增加了编程复杂度,也消耗了更多的空间,却能提升查找效率。

注:即便LinkedList使用了这种巧妙的思想,但是查找速度还是不及ArrayList的随机访问模式,毕竟需要遍历

删除元素

LinkedList删除元素和ArrayList一样支持按照下标删除和按照元素删除,举个例子:

1 List<String> list = new LinkedList<>();
2 list.add("11");
3 list.add("22");
4 list.add("33");
5 list.remove(1);

看看remove方法代码:

 1 public E remove(int index) {
 2     checkElementIndex(index);
 3     return unlink(node(index));
 4 }
 5 
 6 E unlink(Node<E> x) {
 7      // assert x != null;
 8      final E element = x.item;
 9      final Node<E> next = x.next;
10      final Node<E> prev = x.prev;
11 
12      if (prev == null) {
13          first = next;
14      } else {
15          prev.next = next;
16          x.prev = null;
17      }
18 
19      if (next == null) {
20          last = prev;
21      } else {
22          next.prev = prev;
23          x.next = null;
24      }
25 
26      x.item = null;
27      size--;
28      modCount++;
29      return element;
30 }

先检查下标,然后通过node方法找到指定下标的元素,交给unlink方法去执行删除的操作,说一下unlink的步骤

1.先获取当前节点x的元素,当前节点x的下一个储存单元节点next和上一个储存单元的节点prev

2.如果上一个储存单元为null,那说明当前节点是链表的第一个节点,所以需要把代表第一个节点的first变量指向当前x节点的next节点,如果下一个储存单元为null,当前节点为链表的最后一个节点,同样的需要把last节点指向x节点的prev节点

3.如果既不是第一个节点也不是最后一个节点这种特殊情况,也就是执行两个else里面的操作,先把x节点的上一个节点的next指向x节点的下一个节点,并且把x.prev赋值null,然后把x节点的下一个节点的prev指向x的上一个节点,并且把x.next赋值null

4.最后把当前x节点的元素赋值null,元素个数减一

经过步骤2和步骤3,成功的切断了x节点这个储存单元与上下节点的联系,经过步骤234把x节点的prev、element、next全部变成了null,让GC去回收它,这个步骤有点绕画图了解一下remove方法的执行:

假设元素‘11’的储存单元的地址是0x0001,元素‘22’的储存单元的地址是0x0002,元素‘33’的储存单元的地址是0x0003,现在first指向0x0001,last指向0x0003,执行list.remove(1)操作,修改的地方我用红色字体标记

插入元素

与ArrayList一样的,Linked同样提供了在指定下标插入元素的方法,之所以放在删除之后讲是因为插入元素和删除执行的操作有些类似,举个例子:

1 1 List<String> list = new LinkedList<>();
2 2 list.add("11");
3 3 list.add("33");
4 5 list.add(1,"22");

 直接看第四行list.add(1,”22″)执行了什么:

1     public void add(int index, E element) {
2         checkPositionIndex(index);
3 
4         if (index == size)
5             linkLast(element);
6         else
7             linkBefore(element, node(index));
8     }

还是先检查如果插入的下标(index >= 0 && index <= size),如果下标正好是元素的个数就相当于往链表最后插入一个元素,执行之前介绍过的linkLast方法,大部分情况插入都是往链表中间插入元素执行linkBefore方法:

 1     void linkBefore(E e, Node<E> succ) {
 2         // assert succ != null;
 3         final Node<E> pred = succ.prev;
 4         final Node<E> newNode = new Node<>(pred, e, succ);
 5         succ.prev = newNode;
 6         if (pred == null)
 7             first = newNode;
 8         else
 9             pred.next = newNode;
10         size++;
11         modCount++;
12     }

往列表中间插入元素的话会先使用node方法查到对应下标的储存单元节点,记录此节点的前置节点prev,然后新建一个前置节点为index下标原储存单元前置节点、元素为e、后置节点为index下标原储存单元的储存单元节点,如果前置节点为nulll,就将first节点指向新建的节点,否则将index下标原储存单元的前置节点改为新增的节点newNode,如图:

假设元素‘11’的储存单元的地址是0x0001,元素‘33’的储存单元的地址是0x0002,新插入的元素‘22’的储存单元的地址是0x0002,现在first指向0x0001,last指向0x0002,执行list.add(1,”22″)操作,修改的地方我用红色字体标记

图上半部分是插入前,图下半部分是插入后,抛开插入和删除的储存单元不谈,这两个操作都是改变上一个储存单元节点的next地址和下一个储存单元的prev地址,是不是很相似?所以只要理解了LinkedList的这种结构,LinkedList这些操作也是很好理解的。

拿ArrayList对比总结一下LinkedList

1.查找元素的速度,如果是按照下标查找那么ArrayList效率肯定是高于LinkedList的,ArrayList支持随机访问,而LinkedList需要遍历

2.顺序添加元素,虽然LinkedList顺序添加也比较方便,但是需要new对象并且需要修改一些储存单元的引用,而ArrayList的数组是事先new好的,只需要往指定位置插入元素就行,所以大部分情况下ArrayList优于LinkedList,特殊情况ArrayList添加元素需要扩容了,随着元素数量的增加,ArrayList扩容会越来越慢

3.删除插入的速度,LinkedList删除插入操作效率几乎是固定的,先寻址,后修改前后Node的next、prev引用地址,而ArrayList寻址较快,慢在复制数组元素,所以如果插入、删除的元素是在数据结构的前半段尤其是非常靠前的位置的时候,LinkedList的效率将大大快过ArrayList;越往后,ArrayList要批量copy的元素越来越少,速度会越来越快,所以删除插入操作并不能说谁一定快

4.遍历元素,前面说过ArrayList是实现了RandomAccess接口的支持随机访问,而LinkedList是没有实现这个接口的,所以ArrayList在使用普通for循环会更快,而LinkedList使用foreach循环会更快,那么它们使用各自最快的遍历方式谁更快呢?

我们做个测试:

public class TestList {

    private static int size = 100000;
    public static void loop(List<Integer> list){
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            list.get(i);
        }
        System.out.println(list.getClass().getSimpleName() + "for循环遍历时间:" + (System.currentTimeMillis() - startTime) + "ms");

        startTime = System.currentTimeMillis();
        for (Integer i: list) {

        }
        System.out.println(list.getClass().getSimpleName() + "foreach循环遍历时间:" + (System.currentTimeMillis() - startTime) + "ms");
    }

    public static void main(String[] args) {
        List<Integer> arrayList = new ArrayList<>();
        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < size; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }

        loop(arrayList);
        loop(linkedList);
    }
}

执行多次的结果:

ArrayListfor循环遍历时间:3ms
ArrayListforeach循环遍历时间:4ms
LinkedListfor循环遍历时间:4638ms
LinkedListforeach循环遍历时间:3ms

ArrayListfor循环遍历时间:2ms
ArrayListforeach循环遍历时间:4ms
LinkedListfor循环遍历时间:4753ms
LinkedListforeach循环遍历时间:2ms

ArrayListfor循环遍历时间:3ms
ArrayListforeach循环遍历时间:4ms
LinkedListfor循环遍历时间:4520ms
LinkedListforeach循环遍历时间:2ms

从执行测试程序的时候就可以看出来,LinkedList使用普通for循环遍历出奇的慢,而在使用foreach遍历的时候LinkedList遍历明显更快,甚至略优于ArrayList的foreach遍历,所以在数据量比较大的情况下,千万不要使用普通for循环遍历LinkedList

 

作者:全村的希望QAQ