LinkedList全面解析

1,313 阅读13分钟

LinkedList

上一期文章中我解析了 ArrayList 的源码实现,那么作为与它具备同样存储功能的 LinkedList ,我们还是很有必要了解它的源码实现的。如果你对于 ArrayList 还不熟悉,那么你可以进入 不会吧,都2023年了,你还搞不懂ArrayList吗? 进行学习。

文中的源码基于JDK 11,如有不同请以你的版本为主

概述

ArrayList 底层实现是基于数组,那么 LinkedList 是基于什么实现的?没错,就是链表并且是双向链表,每个节点都指向着上一个节点和下一个节点,类似这样

struct.png

既然 LinkedList 是基于链表,那么它也具有了链表的特点:查询慢,增加和删除快

类结构

类结构图

structure.png

类结构解析

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
}

LinkedList 继承了 AbstractSequentialList,实现了 List 、Deque 、Cloneable 和 Serializable 接口

  • LinkedList 实现了 Deque 接口,表明了 LinkedList 可以像队列一样装卸数据,主要集中在插入头部和插入尾部,通过 poll 和 peek 获取数据等等
  • LinkedList 实现了 Cloneable 接口并重写了 clone 方法,表明了 LinkedList 可以被克隆
  • LinkedList 实现了 Serializable 接口,表明了 LinkedList 支持序列化

类属性

// 元素的个数
transient int size = 0;

// 首节点
/**
 * Pointer to first node.
 */
transient Node<E> first;

// 尾节点
/**
 * Pointer to last node.
 */
transient Node<E> last;

LinkedList 使用 Node 类作为节点,看下 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;
    }
}

构造函数

public LinkedList() {
}

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

LinkedList 只有两个构造函数,无参的构造函数什么都不处理,有参的构造函数会将集合存到 LinkedList 中

插入数据

LinkedList 中提供了两种方式去插入数据

  1. 插入尾部
  2. 插入头部
  3. 按索引位置插入

插入尾部

我们平时通过 add 方法插入元素时,其实就是插入到链表的尾部。当然,你也可以通过 addLast 、offer 和 offerLast 方法(实现了 Deque 接口)插入尾部,他们的效果是一样的,只是 add 、 offer 方法返回值是 boolean

public boolean add(E e) {
    linkLast(e);
    // 返回true
    return true;
}

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

add 方法和 addLast 方法都是通过 linkLast 方法将数据插入到尾部,那么我们来看下 linkLast 的实现

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
        l.next = newNode;
    // 元素个数+1
    size++;
    modCount++;
}

linkLast 源码其实很简单,主要是在判断尾节点是否为 null

  1. linkLast 尾节点为 null 时,那么新节点同时是首节点和尾结点
  2. linkLast 尾节点不为 null 时,那么需要把尾节点的后节点置为新节点

addLast.png

插入头部

LinkedList 提供了 addFirst 、push 和 offerFirst 方法(实现了 Deque 接口)插入头部,offerFirst 和 push 都是通过 addFirst 插入,最终调用的 linkFirst 方法

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

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

// 最终实现
private void linkFirst(E e) {
    // 获取当前的首节点
    final Node<E> f = first;
    // 构造一个新节点,将它的前节点置为null,后节点置为首节点
    final Node<E> newNode = new Node<>(null, e, f);
    // 将首节点置为新节点
    first = newNode;
    // 如果首节点为null时,将尾结点置为新节点,否则将首节点的前节点置为新节点
    if (f == null)
        last = newNode;
    else
        f.prev = newNode;
    // 元素个数+1    
    size++;
    modCount++;
}

linkFirst 源码其实很简单,主要是在判断首节点是否为 null

  1. linkLast 首节点为 null 时,那么新节点同时是首节点和尾结点
  2. linkLast 首节点不为 null 时,那么需要把首节点的前节点置为新节点

addFirst.png

按索引插入

LinkedList 和 ArrayList 一样提供了以索引插入的方式

public void add(int index, E element) {
    // 检测索引是否数组越界
    checkPositionIndex(index);

    // index = size时,直接插入尾部
    if (index == size)
        linkLast(element);
    // 否则调用linkBefore插入
    else
        linkBefore(element, node(index));
}

private void checkPositionIndex(int index) {
    if (!isPositionIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

// 索引必须在[0,size]区间内
private boolean isPositionIndex(int index) {
    return index >= 0 && index <= size;
}

// 根据索引获取节点
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;
    }
}
  1. 首先会判断索引是否数组越界,索引必须在[0,size]区间内
  2. 根据索引判断是否是尾部插入,如果是尾部插入直接调用 linkLast 插入,否则调用 linkBefore 插入
  3. 根据索引获取该位置上的节点,如果索引在链表前半段内那么从头部开始遍历,否则从尾部开始倒序遍历。这样做的好处是减少遍历的次数,减少遍历所需的时间

linkLast 上面已经分析了,那我们来看下 linkBefore 吧

// succ就是原索引位置的节点
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    // 获取原索引位置上的前节点
    final Node<E> pred = succ.prev;
    // 构造一个新节点,将他的前节点置为原索引位置上的前节点,后节点置为原索引位置的节点
    final Node<E> newNode = new Node<>(pred, e, succ);
    // 将原索引位置的节点的前节点置为新节点
    succ.prev = newNode;
    // 如果原索引位置上的前节点为null,那么需要将新节点置为首节点,否则将原索引位置上的前节点的后节点置为新节点
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    // 元素个数+1     
    size++;
    modCount++;
}

看完上面的描述是不是觉得很混乱?其实我个人也觉得没表述清楚,其实总结为一句话:非尾部插入时,该位置的前节点和该位置的后节点指向都需要改变

为了更清晰的了解过程,下面我就用图片演示下

linkBefore.png

查找数据

LinkedList 实现了 Deque 接口,所以 LinkedList 在查找数据上有 peek 和 poll 操作

我这里把查找数据归结为三类:

  1. 按索引查找

  2. 头节点、尾节点查找

  3. peek 、 poll 查找

按索引查找

通过索引查找时,索引必须在区间[0,size)范围内,否则会抛出数组越界异常

public E get(int index) {
    // 检查索引是否有效
    checkElementIndex(index);
    return node(index).item;
}

private void checkElementIndex(int index) {
    if (!isElementIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

// 索引必须在区间[0,size)内
private boolean isElementIndex(int index) {
    return index >= 0 && index < size;
}

// 根据索引获取节点
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;
    }
}

通过索引查找还是很简单的逻辑,总结就是两点

  1. 首先会判断索引是否数组越界,索引必须在[0,size)区间内
  2. 根据索引获取该位置上的节点,如果索引在链表前半段内那么从头部开始遍历,否则从尾部开始倒序遍历,最终返回节点的值

头节点、尾节点查找

LinkedList 实现了 Deque 接口,重写了 getFirst 和 getLast 方法,我们可以通过 getFirst 获取 LinkedList 的首节点数据,通过 getLast 获取 LinkedList 的尾节点数据

public E getFirst() {
    final Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return f.item;
}

public E getLast() {
    final Node<E> l = last;
    if (l == null)
        throw new NoSuchElementException();
    return l.item;
}    

其实大家应该都能想到,既然 LinkedList 中定义了首节点和尾节点,那么肯定是通过首节点和尾节点获取首节点和尾节点数据的。如果首节点(尾节点)为 null 时,抛出 NoSuchElementException 异常。

peek 和 poll

peek 和 poll 是 Queue 接口中声明的方法,Deque 接口继承了 Queue,并且声明了自己的方法,主要包括 pollFirst 、pollLast 、peekFirst 和 peekLast 等。

先说下 peek 和 poll 的区别,peek 只获取数据,poll 不仅会获取数据并且还会将这个数据从 LinkedList 中移除,表现像下图所示

peek.png

poll.png

先来看下 peek 的几个方法

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

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

 public E peekLast() {
    final Node<E> l = last;
    return (l == null) ? null : l.item;
}    
  1. peek 会返回首节点数据,如果首节点为 null 时返回 null
  2. peekFirst 会返回首节点数据,如果首节点为 null 时返回 null
  3. peekLast 会返回尾节点数据,如果尾节点为 null 时返回 null

peek 与 getXXX 不同的时,如果节点为 null ,peek 会返回 null ,getXXX 会抛出 NoSuchElementException 异常

再来看下 poll 的几个方法

public E poll() {
    final Node<E> f = first;
    // 首节点为null时返回null,否则返回unlinkFirst
    return (f == null) ? null : unlinkFirst(f);
}

public E pollFirst() {
    final Node<E> f = first;
    // 首节点为null时返回null,否则返回unlinkFirst
    return (f == null) ? null : unlinkFirst(f);
}

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

poll 与 getXXX 不同的时,如果节点为 null ,poll 会返回 null ,getXXX 会抛出 NoSuchElementException 异常

poll 和 pollFirst 完全一致,都是获取并删除首节点,pollLast 是获取并删除尾节点

private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    // 获取首节点的值
    final E element = f.item;
    // 获取首节点的后节点
    final Node<E> next = f.next;
    // 将首节点的前后节点都置为null
    f.item = null;
    f.next = null; // help GC
    // 移除了首节点,那么后节点就是首节点了
    first = next;
    // 首节点的后节点为null时,移除了首节点之后 尾节点就是null,否则将后节点的前节点置为null
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;
    return element;
}

private E unlinkLast(Node<E> l) {
    // assert l == last && l != null;
    // 获取尾节点的值    
    final E element = l.item;
    // 获取尾节点的前节点
    final Node<E> prev = l.prev;
    // 将尾节点的前后节点都置为null
    l.item = null;
    l.prev = null; // help GC
    // 移除了尾节点,那么前节点就是尾节点了
    last = prev;
    // 尾节点的前节点为null时,移除了尾节点之后 首节点就是null,否则将前节点的后节点置为null
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;
    return element;
}    

unlinkFirst 和 unlinkLast 的源码并不难,中心还是在于前后节点的指向

删除数据

LinkedList 实现了 Deque 接口,所以 LinkedList 在删除元素上有 removeFirst 等操作,别忘了 poll 也是可以删除元素的

我这里把查找数据归结为三类:

  1. 删除首节点、尾节点
  2. 按索引删除
  3. 按对象删除

删除首节点、尾节点

先来看一下 removexxx方法

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

public E removeFirst() {
    final Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return unlinkFirst(f);
}

public E removeLast() {
    final Node<E> l = last;
    if (l == null)
        throw new NoSuchElementException();
    return unlinkLast(l);
}

unlinkFirst和unlinkLast 上面已经分析过了,如果忘记了请往前看(peek和poll)

可以看到 remove 方法默认删除的首节点,删除成功时返回该节点的值。删除首、尾节点时,如果该节点为 null 会抛出 NoSuchElementException 异常,这与我们的 poll 方法略有不同:poll 节点为 null 时返回 null。

按索引删除

删除成功时会返回该节点的值

public E remove(int index) {
    checkElementIndex(index);
    return unlink(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;
    }
}
  1. 索引必须在[0,size)区间内,否则会抛出数组越界异常
  2. 通过 node 方法获取该位置的元素,然后调用 unlink 删除该节点
E unlink(Node<E> x) {
    // assert x != null;
    // 获取该节点的值
    final E element = x.item;
    // 获取该节点的前后节点
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    // 如果前节点为null时,那么删除的是首节点,需要把后节点置为首节点,否则需要将前节点指向后节点
    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }

    // 如果后节点为null时,那么删除的是尾节点,需要把前节点置为尾节点,否则需要将后节点指向前节点
    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }

    // 置空,以便gc
    x.item = null;
    // 元素-1
    size--;
    modCount++;
    return element;
}

unlink 总结为三句话:

  1. 如果删除的是首节点,那么需要将首节点置为该节点的后节点
  2. 如果删除的是中间节点,那么需要将该节点的前节点指向它的后节点,它的后节点也指向它的前节点
  3. 如果删除的是尾节点,那么需要将尾节点置为该节点的前节点

按对象删除

按对象操作包括了三个方法,remove(Object) 、removeFirstOccurrence 和 removeLastOccurrence,removeFirstOccurrence 其实也是通过 remove(Object) 删除,删除成功返回 true

public boolean removeFirstOccurrence(Object o) {
    return remove(o);
}

先说下为什么会有 removeFirstOccurrence 和 removeLastOccurrence?

因为 LinkedList 不是 set ,它存的数据是可以重复,那么如果按对象来删除的话,就很有必要搞清楚是从哪个方向查找后删除

same_node.png

看下 remove(Object) 是怎么删除的

public boolean remove(Object o) {
    // 移除的对象为null时
    if (o == null) {
        // 从头到尾遍历节点
        for (Node<E> x = first; x != null; x = x.next) {
            // 假如某个节点的值为null时,通过unlink移除
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        // 从头到尾遍历节点
        for (Node<E> x = first; x != null; x = x.next) {
            // 假如某个节点的值与移除的对象相等时,通过unlink移除
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}

总得来说,remove(Object) 会从头到尾遍历节点,找到应该删除的对象(如果删除的对象不为 null时,通过 equals 判断对象相等,所有自定义类重写 equals 很有必要),最后通过 unlink 删除。

unlink 上面 按索引删除 的小节中已经讲过

  1. 如果删除的是首节点,那么需要将首节点置为该节点的后节点
  2. 如果删除的是中间节点,那么需要将该节点的前节点指向它的后节点,它的后节点也指向它的前节点
  3. 如果删除的是尾节点,那么需要将尾节点置为该节点的前节点

大家想下,既然 remove(Object) 是从头到尾遍历,那么 removeLastOccurrence 应该怎么遍历呢?没错,就是从尾到头遍历

public boolean removeLastOccurrence(Object o) {
    // 移除的对象为null时
    if (o == null) {
        // 从尾到头遍历节点
        for (Node<E> x = last; x != null; x = x.prev) {
            // 假如某个节点的值为null时,通过unlink移除
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        // 从尾到头遍历节点
        for (Node<E> x = last; x != null; x = x.prev) {
            // 假如某个节点的值与移除的对象相等时,通过unlink移除
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}

removeLastOccurrence 与 remove(Object) 、 removeFirstOccurrence 的整体流程都一致,这里就不再多说了。

更改数据

LinkedList 也提供了 set 方法给我们去修改数据

public E set(int index, E element) {
    // 索引必须在[0,size)区间内
    checkElementIndex(index);
    // 通过node获取该节点
    Node<E> x = node(index);
    // 获取该节点的旧值
    E oldVal = x.item;
    // 设置该节点的新值
    x.item = element;
    return oldVal;
}

set 方法只是修改了索引位置上节点的值,并不需要创建节点再指向

其他方法

clear

清除 LinkedList 的所有节点

public void clear() {
    // Clearing all of the links between nodes is "unnecessary", but:
    // - helps a generational GC if the discarded nodes inhabit
    //   more than one generation
    // - is sure to free memory even if there is a reachable Iterator
    for (Node<E> x = first; x != null; ) {
        Node<E> next = x.next;
        x.item = null;
        x.next = null;
        x.prev = null;
        x = next;
    }
    first = last = null;
    size = 0;
    modCount++;
}

遍历链表中的所有节点,将所有节点的值、前节点和后节点都置为 null (GC回收),最后将首尾节点都置为 null,将 size 置为 0

toArray

将 LinkedList 的数据转为类型数组

public Object[] toArray() {
    Object[] result = new Object[size];
    int i = 0;
    // 从头到尾遍历链表,将每个节点的值都添加到数组中
    for (Node<E> x = first; x != null; x = x.next)
        result[i++] = x.item;
    return result;
}

ArrayList 与 LinkedList的区别

  1. 存储的数据结构不同,ArrayList 基于 数组,LinkedList 基于双向链表
  2. 随机查找数据效率不同,ArrayList 通过元素索引就能获取数据,而 LinkedList 需要移动指针遍历链表,ArrayList 效率比 LinkedList 好
  3. 所需容量不同,ArrayList 默认数组大小是16,而 LinkedList 基于链表不需要初始化容量大小
  4. ArrayList 删除数据比 LinkedList 慢,因为 ArrayList 删除数据时需要将后面的元素往前移,而 LinkedList 只需要重新指向前后节点即可

总结

  1. LinkedList 基于双向链表
  2. LinkedList 实现的 Deque,所以具有队列相关的操作
  3. LinkedList 并不是线程安全的,如果考虑多线程开发可以使用 CopyOnWriteArrayList 或者通过 Collections.synchronizedList 将 LinkedList 转换成线程安全的 List

打完收工!如果觉得本篇文章对你有作用,请不要吝啬你的赞赞哦~