likes
comments
collection
share

LinkedList全面解析

作者站长头像
站长
· 阅读数 18

LinkedList

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

概述

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

LinkedList全面解析

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

类结构

类结构图

LinkedList全面解析

类结构解析

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

LinkedList 继承了 AbstractSequentialList,实现了 ListDequeCloneableSerializable 接口

  • LinkedList 实现了 Deque 接口,表明了 LinkedList 可以像队列一样装卸数据,主要集中在插入头部和插入尾部,通过 pollpeek 获取数据等等
  • 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 方法插入元素时,其实就是插入到链表的尾部。当然,你也可以通过 addLastofferofferLast 方法(实现了 Deque 接口)插入尾部,他们的效果是一样的,只是 addoffer 方法返回值是 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 时,那么需要把尾节点的后节点置为新节点

LinkedList全面解析

插入头部

LinkedList 提供了 addFirstpushofferFirst 方法(实现了 Deque 接口)插入头部,offerFirstpush 都是通过 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 时,那么需要把首节点的前节点置为新节点

LinkedList全面解析

按索引插入

LinkedListArrayList 一样提供了以索引插入的方式

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

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

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

LinkedList全面解析

查找数据

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

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

  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 接口,重写了 getFirstgetLast 方法,我们可以通过 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 pollQueue 接口中声明的方法,Deque 接口继承了 Queue,并且声明了自己的方法,主要包括 pollFirstpollLastpeekFirstpeekLast 等。

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

LinkedList全面解析

LinkedList全面解析

先来看下 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

peekgetXXX 不同的时,如果节点为 nullpeek 会返回 nullgetXXX 会抛出 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);
}    

pollgetXXX 不同的时,如果节点为 nullpoll 会返回 nullgetXXX 会抛出 NoSuchElementException 异常

pollpollFirst 完全一致,都是获取并删除首节点,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;
}    

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

删除数据

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)removeFirstOccurrenceremoveLastOccurrenceremoveFirstOccurrence 其实也是通过 remove(Object) 删除,删除成功返回 true

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

先说下为什么会有 removeFirstOccurrenceremoveLastOccurrence

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

LinkedList全面解析

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

removeLastOccurrenceremove(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.synchronizedListLinkedList 转换成线程安全的 List

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