Java基础系列:了解LinkedList

571 阅读9分钟

来,进来的小伙伴们,我们认识一下。

我是俗世游子,在外流浪多年的Java程序猿

这两天看到太多小伙伴在秀公司“10.24程序员节”的福利,我承认我酸了o(╥﹏╥)o

上一节我们聊过了ArrayList,对其底层结构和源码实现进行了了解,那这节我们来聊一聊关于它的“兄弟集合”:LinkedList

集合之LinkedList

特性

同样属于List的子类,那么也就同样拥有了其特点:

  • 有序
  • 不重复

LinkedList结构

可以看到,LinkedList除了实现List接口外,还实现了Queue接口,在Java中,该接口定义的是队列的,向我们之后要聊到的

等等的都是属于该类的实现

基于这种方式,那么我们的LinkedList也适合做队列的处理场景,比如:

  • FIFO
  • 堆,栈等

链表的介绍

LinkedList底层是基于双向链表的方式来存储的,那肯定有人在想,什么是链表呢?我们这就来聊一聊

什么是链表

链表是一种在逻辑上连续,但是物理存储上非连续的存储结构,其保证逻辑连续是通过指针指向来确定顺序的。

链表结构

上面看到的是单向链表,可以看到:

  • 在链表中,每个节点称为Node,其中包含两个部分
    • data:具体存储数据
    • next:是指针的指向,指向下一个Node

还有一种双向链表的形式

双向链表

看上图:

  • 节点中,额外多了个一个prev的指向,双向链表的节点是两两互相指向

LinkedList就是采用的双向链表的形式,下面我们来看具体的代码

链表的操作

背景:这里已双向链表为例

对链表操作,实际上就是修改指针的指向,比如

  • 插入元素

头尾的插入非常简单,直接指向 nextprev 就可以了,这里我们看插入到中间

双向链表元素插入到中间

  • 移除元素

移除元素和插入元素很类似,无非就是将指定元素删除掉,然后将指针指向下一个节点

移除元素

前面的链表介绍都是为之后做铺垫,我们继续来看今天的主角:LinkedList

LinkedList详细介绍

LinkedList底层是采用双向链表的结构来进行数据存储的,可以说,LinkedList所有的操作都是针对引用指向来进行操作的。下面来看具体的方法

我们是这么操作LinkedList

LinkedList<String> linkedList = new LinkedList<>();
// Arrays.asList("item1", "item2"):为了方便演示
LinkedList<String> linkedList2 = new LinkedList<>(Arrays.asList("item1", "item2"));

同样,我们还是通过构造方法来看:

public LinkedList() {
}

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

ArrayList不同,这里只有两个构造方法。

  • LinkedList中,初始长度是不需要设置的,而且也不需要扩容操作。

  • 理论情况下,只要内存足够大,那么LinkedList就可以一直存储下去

不需要设置初始长度和底层存储结构有关,如果想不明白可以先去上一节看一看数组的介绍

不过也有说,LinkedList是存在最大容量的:不能超过Integer.MAX_VALUE

大家可以查找下资料,好好验证下该说法(本人没有在源码中发现对应的验证)

同时,我们来看一看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;
    }
}

双向链表节点:在代码中对应的具体实现。

这里我需要让大家考虑一个问题:

ArrayList和LinkedList同样存储了100W的数据,哪种集合占用的空间更大?

具体的操作方法

插入元素的方法

前面也说到了,LinkedList除了实现List接口外,还实现了Queue接口,自然也重写其对应的方法,下面我们一一来看看:

linkedList.add("item2");
linkedList.add(1, "item3");
linkedList.addFirst("item0");
linkedList.addLast("item9");
我们对比add(e)addLast(e)的源代码
public boolean add(E e) {
    linkLast(e);
    return true;
}

public void addLast(E e) {
    linkLast(e);
}

void linkLast(E e) {
    // 得到临时变量尾结点
    final Node<E> l = last;
    // 新节点的上一个节点是之前的尾结点,下一个节点是null
    final Node<E> newNode = new Node<>(l, e, null);
    // 新的节点成为尾结点,
    last = newNode;
    // 如果尾结点是null,这个链表是空的,那么头结点也是新增的节点
    if (l == null)
        first = newNode;
    else
        // 之前尾结点的next是新节点
        l.next = newNode;
    size++;
    modCount++;
}
  • 两者实现方式都是linkLast(e)方法,从字面和具体的实现方法上来看,默认的add(e)方法是将元素添加到了链表的尾部,这里专业名词叫:尾插法

  • addLast(e)就更不用说了,直接将元素添加到尾部

  • 关于linkLast(e)方法注释都已经有了,其实就是在调整指针的指向,整体描述如下图:

尾部插入

addFirst(e)
public void addFirst(E e) {
    linkFirst(e);
}

private void linkFirst(E e) {
    // 得到临时变量头结点
    final Node<E> f = first;
    // 新节点 插入到头部,所以新节点的next指向f
    final Node<E> newNode = new Node<>(null, e, f);
    // 同样,新节点称为新的头
    first = newNode;
    // 如果头结点是null,这个链表是空的,那么尾结点也是新增的节点
    if (f == null)
        last = newNode;
    else
        // 否则的话,新节点的prev指向新节点
        f.prev = newNode;
    size++;
    modCount++;
}
  • 其实,addFirst(e)addLast(e)方法正好相反

这里就不给图了,就是上面插入尾部改成插入头部

add(index, e)

这里我们要看一下指定位置的插入:

public void add(int index, E element) {
    checkPositionIndex(index);

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

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

linkBefore()方法就不贴出来了,也就是改变prev和next的指向,下面我们看一下node(index)方法

  • 我们可以看到,指定位置插入节点,需要先遍历将当前索引上的节点找到,然后在改变指向
  • 同样,这里在遍历的时候采用简单二分法,判断当前索引是在链表的前半段还是在后半段,减少循环遍历的次数,提高了性能

这里我们举个例子

比如:LinkedList中存储了500条,我们需要往250个位置上添加,那么node(index)对应的遍历就是在后半段:

Node<E> x = last;
for (int i = 500 - 1; i > 250; i--)
    x = x.prev;
return x;

// 比如这里是 400
Node<E> x = last;
for (int i = 500 - 1; i > 400; i--)
    x = x.prev;
return x;
  • 这样的情况下,需要循环最少(499-250)次才能找到对应的节点,而如果索引越大,那么循环次数就越小:
  • 基于这种方式我们得出结论:

虽然遍历采用简单二分法提升了整体遍历的性能,但是如果遍历的节点越靠近中间位置,检索的效率也就越低

这里给大家留一个试验:ArrayList的插入和LinkedList的插入,性能相比如何?需要考虑一下几个方面:

  • ArrayList的扩容问题
  • 插入到头部,中间,尾部的性能

获取元素

直接get(index)的方式
linkedList.get(0);
// 在LinkedList中已经记录了头节点和尾结点,这里就是得到当前的数据就行了
linkedList.getFirst();
linkedList.getLast();
  • 其实这里的get(index)我们上面已经介绍到了,就是通过node(index)来得到指定索引的数据的
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}
Iterator的方式
Iterator<String> iterator = linkedList.iterator();

这种模式我们就不介绍了,iterator()实现其实是采用这种方式来做的:

public ListIterator<E> listIterator() {
    return listIterator(0);
}

public ListIterator<E> listIterator(final int index) {
    rangeCheckForAdd(index);

    return new ListItr(index);
}

private class ListItr implements ListIterator<E> {
    private Node<E> lastReturned;
    private Node<E> next;
    private int nextIndex;
    private int expectedModCount = modCount;

    ListItr(int index) {
        // assert isPositionIndex(index);
        next = (index == size) ? null : node(index);
        nextIndex = index;
    }
}

这里默认传入的参数是:0,LinkedList为我们开放了该方法:

ListIterator<String> listIterator = linkedList.listIterator(0);	// ==  linkedList.iterator();

ListIterator和Iterator两者的对比我们上节也介绍过了

那么,问题来了:在迭代LinkedList的时候我们该采用那种方式?

我们来做个实验进行验证:1W的数据,我们来进行验证

int len = 1_0000;
LinkedList<String> linkedList = new LinkedList<String>() {{
    for (int i = 0; i < len; i++) {
        add("item" + i);
    }
}};

long start = System.currentTimeMillis();
for (int i = 0; i < len; i++) {
    linkedList.get(i);
}
System.out.println("for i 耗时:" + (System.currentTimeMillis() - start));

start = System.currentTimeMillis();
Iterator<String> iterator = linkedList.iterator();
while (iterator.hasNext()) {
    iterator.next();
}
System.out.println("iterator 耗时:" + (System.currentTimeMillis() - start));

猜一猜最终的结果如何?

迭代对比

我们来想一想为什么:

  • 上面介绍过,get(index)底层实现是:node(index),最外层每循环一次,node(index)每次都会进行一次二分查找,然后循环迭代索引取出对应的值
  • 如果采用iterator()的话,只会在构造方法中进行一次node(0)的迭代取出第一个节点,因为双向链表的形式,所以通过item.next来就可以取出对应的元素
public E next() {
    checkForComodification();
    if (!hasNext())
    	throw new NoSuchElementException();

    lastReturned = next;
    next = next.next;
    nextIndex++;
    return lastReturned.item;
}

所以我们在LinkedList的迭代的时候,最好采用iterator()的方式

移除元素

我们都是通过remove()来移除元素,那么对应其中的实现:

E unlink(Node<E> x) {
    // assert x != null;
    // 得到当前元素,当前next和prev的指向对象
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    // 如果prev是null,说明是第一个节点
    if (prev == null) {
        first = next;
    } else {
        // 否则就将当前节点的prev的下一个节点指向当前节点的next
        prev.next = next;
        x.prev = null;
    }

    // 如果next是null,说明是最后一个节点
    if (next == null) {
        last = prev;
    } else {
        // 否则就将当前节点的prev的下一个节点指向当前节点的prev
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}

这里其实就是修改引用的过程,这里就不展示图了,大家感兴趣的话我在后面给出一个站点,大家可以在那个站点上查看对应具体的过程

这里我们就过了,下面基于LinkedList简单聊一点队列的东西

队列

队列也是我们常用的一种数据结构:而且只允许在一端进行插入操作,另一端进行删除操作。

这不就是排排站,先插入进来的先被处理掉。

这种结构可以称为先进先出的方式,也就是我们所说的:FIFO,具体如下:

image-20201024165735272

那么,LinkedList又是怎么做的呢?

简单来两个方法来实现一下:

  • push(e)

将元素推送到由此列表表示的堆栈上

底层实现也非常简单,就是调用之前的addFirst(e)来操作的,都是上面介绍过的

public void push(E e) {
    addFirst(e);
}
  • pollLast(e)

检索并删除此列表的最后一个元素

public E pollLast() {
    final Node<E> l = last;
    return (l == null) ? null : unlinkLast(l);
}

private E unlinkLast(Node<E> l) {
    // assert l == last && l != null;
    final E element = l.item;
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null; // help GC
    last = prev;
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;
    return element;
}

根据LinkedList提供的方法就可以有很多中实现方式,只要满足先进先出的方式

线程安全性

ArrayList是一样的,如果只是当做局部变量来使用的话,是不存在线程问题的;但是如果当做共享资源来使用,那么必然是线程不安全的,针对解决方式:

  • 自己加锁
  • 使用Collections#synchronizedList方法的返回值来进行数据操作

文档

更多关于LinkedList使用方法推荐查看其文档:

LinkedListAPI文档

数据结构必备站点