基于JDK1.8的LinkedList源码分析

概述

LinkedList本质上是一个基于双向链表实现 的数据结构,因此,LinkedList中保存的各个Item元素也是有序 的,同时也是可以重复 并且可以为null 的。同时因为他是基于双向链表的实现,因此他在存储元素的过程中,不需要像ArrayList那样去动态的扩容。 另外,LinkedList是非线程安全的,因此,在面向多线程并发业务时,需要注意。当然解决方法也是有的,而且很简单,详见 《基于JDK1.8的ArrayList源码分析》一文中"总结"部分。

LinkedList的定义

csharp 复制代码
public class LinkedList<E>extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    ......
}

从源码可以看出,LinkedList实现了List, Cloneable, java.io.Serializable接口,这三个接口的作用在《基于JDK1.8的ArrayList源码分析》一文中已经详细讲解过了,这里不再赘述。另外,他还实现了Deque接口,该接口让LinkedList具备了双向链表的数据结构的能力,并为其提供了基础功能。

但相比于ArrayList,LinkedList因为没有实现RandomAccess接口,因此他不具备数组那样的通过下标直接高效的访问指定位置Item的能力,他只能通过迭代的方式,去访问满足条件的Item元素。

注意:

dart 复制代码
不是LinkedList不具备像ArrayList那样通过下标访问(get(int position))指定位置Item的能力,
而是说通过LinkedList通过下标访问指定位置的Item的效率非常低,因为他必须通过指针一个一个的
移动到指定位置去,速度很慢,效率很低;而通过Iterator迭代器访问的话,则效率很高。

LinkedList的优缺点:

优点:

因其底层数据结构是链表,所以可想而知,它的增删只需要移动指针即可,故时间效率较高不需要批量扩容也不需要预留空间 ,所以空间效率ArrayList

缺点:

就是需要随机访问 元素时,时间很慢,效率很低 ,虽然底层在根据下标查询Node的时候,会根据index判断目标Node在前半段还是后半段,然后决定是顺序还是逆序查询以提升时间效率。不过,总体效率依然很低。

LinkedList 双向链表成员变量

LinkedList的每个节点上有三个字段:

kotlin 复制代码
当前节点的数据字段(data);
指向上一个节点的字段(prev);
和指向下一个节点的字段(next);

而其每个节点的成员变量如下:

kotlin 复制代码
private static class Node<E> {
    E item; // 当前节点
    Node<E> next; // 当前节点的下一个节点
    Node<E> prev; // 当前节点的上一个节点

    Node(Node<E> prev, E element, Node<E> next) {
        // 这里的 this 指的是 当前节点对象 Node
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

因此,每个节点上有三个字段:当前节点的数据字段(item),指向上一个节点的字段(prev),和指向下一个节点的字段(next)。

LinkedList的成员变量

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

    /**
     * Pointer to first node.
     * Invariant: (first == null && last == null) || (first.prev == null && first.item != null)
     */
    transient Node<E> first;

    /**
     * Pointer to last node.
     * Invariant: (first == null && last == null) || (last.next == null && last.item != null)
     */
    transient Node<E> last;
    ......
}

LinkedList的3个成员变量都被transient关键字修饰,表示LinkedList在被序列化时,他们不参与/不被序列化

之所以 LinkedList 要保存链表的第一个节点和最后一个节点是因为,链表数据结构相对于数组数据结构,优点 在于增删缺点 在于查找

保存了LinkedList 的头尾两端(first/last)节点,当以索引index来查找Item元素的时候,就可以根据 indexsize/2 的大小,来决定从头部节点查找还是从尾部节点查找(类似于二分法查找),这也算是一定程度上弥补了链表数据结构不适合直接通过索引index来查找Item元素的缺点。

LinkedList构造函数

LinkedList无论是无参构造函数还是有参构造函数,都非常简单:

scss 复制代码
/**
 * Constructs an empty list.
 */
public LinkedList() {
}

/**
 * Constructs a list containing the elements of the specified collection, 
 * in the order they are returned by the collection's iterator.
 * @param  c the collection whose elements are to be placed into this list
 * @throws NullPointerException if the specified collection is null
 */
public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}

从上面源码看,实现非常简单,LinkedList的无参构造函数,实现直接是空的,啥也没做;有参构造函数内部则是先调用LinkedList的无参构造函数,然后再执行addAll(c)操作,将入参Collection集合c添加到链表里面去;而addAll(c)内部又调用了 addAll(size, c) 方法将Collection集合c添加到指定的位置size上。

因此,这里我们可以这样理解:如果想让集合在LinkedList尾部插入,就调用addAll(c)方法;如果是想让集合在指定位置插入,就调用addAll(size, c) 方法。

addAll(c)方法内部直接调用的是addAll(size, c) 方法,下面我们看看addAll(size, c) 方法的源码实现:

从上面的源码实现逻辑,可以知道LinkedList 批量添加节点实现大体分下面几个步骤:

perl 复制代码
1、检查索引值index是否在LinkedList长度范围内,不在的话,就抛出下标越界异常
2、保存 当前节点index 位置 和 上一个节点index-1 位置。
3、将入参Collection集合c转化为数组,通过for循环将数组中的元素封装为节点Node添加到LinkedList链表中。
4、更新链表长度;更新modCount表示LinkedList链表发生了结构性的变化;
5、返回 true 表示添加成功。

6、链表批量增加,是靠for循环遍历原数组,依次执行插入操作。ArrayList是通过System.arraycopy()方法完成批量插入的。
7、通过下标index获取某个node节点的时候,会根据index处于前半段还是后半段,进行一个折半,以提升查询效率(类似文章开头提到的二分法查找)

LinkedList的增删查改

LinkedList添加单个节点

LinkedList添加单个节点有三种种场景:

scss 复制代码
1、执行添加add(E e),不指定添加方式,这时默认是添加在链表的末尾;
2、添加在LinkedList链表的链头addFirst(E e);
3、添加在LinkedList链表的指定的index下标的位置add(int index, E element);
1、执行添加add(E e)
java 复制代码
// 添加在链表的末尾
public boolean add(E e) {
    linkLast(e);
    return true;
}

// 添加单个元素在链表的链尾节点位置
void linkLast(E e) {
    // 暂存之前的末节点
    final Node<E> l = last;
    // 以添加的元素e为节点,构建新的末节点newNode,并将新节点prev指针指向之前的末节点last
    final Node<E> newNode = new Node<>(l, e, null);
    // 更新尾部节点, 将last索引指向末节点last
    last = newNode;
    // 如果添加之前链表为空,则新节点newNode也作为头节点first
    if (l == null) {
        first = newNode;
    } else { // 否则将之前的未节点first 的next指针指向新节点newNode
        l.next = newNode;
    }
    size++; // 更新LinkedList链表的长度
    modCount++; // 更新 modCount,表示LinkedList链表发生了结构性变化
}
2、添加在链头addFirst(E e)
java 复制代码
// 添加单个元素在链表的头节点位置
public void addFirst(E e) {
    linkFirst(e);
}
// 添加在链表的末尾
private void linkFirst(E e) {
    // 添加Item元素之前的头节点
    final Node<E> f = first;
    // 以添加的元素e为节点,构建新的头节点newNode,并将 下一个节点next 指针指向 之前的头节点
    final Node<E> newNode = new Node<>(null, e, f);
    // first节点指向将新的节点newNode
    first = newNode;
    // 如果添加之前链表空,则新的节点newNode也就是末节点last
    if (f == null) {
        last = newNode;
    } else { // 否则之前头节点的 prev 指针指向新的节点newNode
        f.prev = newNode;
    }
    size++; // 更新LinkedList链表的长度
    modCount++; // 更新 modCount,表示LinkedList链表发生了结构性变化
}
3、添加在指定位置add(int index, E element)
ini 复制代码
// 添加在指定的下标索引index位置
public void add(int index, E element) {
    // 这里检查给定的下标索引index是否是在[0, size]范围内,如果不是,则抛出下标索引index越界异常
    checkPositionIndex(index);
    // 如果指定的下标索引index等于 size,说明是将新的元素添加在链表的末尾。
    if (index == size) {
        linkLast(element);// 将新的元素添加在链表的末尾
    } else {
        linkBefore(element, node(index));
    }
}

// 根据index,获取非空节点Node,这个非空节点位于 index 位置
Node<E> node(int index) {
    //通过下标获取某个node 的时候,会根据index处于前半段还是后半段 进行一个折半,以提升查询效率。类似二分法查找。
    if (index < (size >> 1)) { // 如果 index > size/2 则从size/2到size区间内开始寻找指定角标的节点
        Node<E> x = first;
        for (int i = 0; i < index; i++) {
            x = x.next;
        }
        return x;
    } else { // 否则在 0到size/2的区间内查找
        Node<E> x = last;
        for (int i = size - 1; i > index; i--) {
            x = x.prev;
        }
        return x;
    }
}

// 在指定的 succ 节点 前,插入新的元素节点 e
void linkBefore(E e, Node<E> succ) {
    // 由于 succ 一定不为空,所以可以直接获取 prev 节点
    final Node<E> pred = succ.prev;
    // 新节点 prev 节点为 pred,next 节点为 succ
    final Node<E> newNode = new Node<>(pred, e, succ);
    // 原节点的 prev 指向新节点
    succ.prev = newNode;
    // 如果 pred 为空即头节点出插入了一个节点,则将新的节点赋值给 first 索引
    if (pred == null) {
        first = newNode;
    } else { // 否则 pred 的下一个节点改为新节点
        pred.next = newNode;
    }
    size++; // 更新LinkedList链表的长度
    modCount++; // 更新 modCount,表示LinkedList链表发生了结构性变化
}

在node(int index)方法中,我们提到根据index,获取非空节点Node,这个非空节点位于 index 位置,这里之所以确定node(int index)方法返回的节点为非null,是因为:

scss 复制代码
1、如果索引index不在[0,size]范围内,那么在checkPositionIndex(index)方法进行index的合法性检测时就会抛出异常;
2、如果一开始链表中没有元素,表示LinkedList链表为空(Empty,非null),此时size = 0,如果这时向索引 `index = 0` 的位置添加元素,那么if判断:
        if (index == size) {
            linkLast(element);
        } else {
            linkBefore(element, node(index));
        }
if条件成立,这时直接调用 `linkLast(element);` 方法去执行添加元素的逻辑。不会走到linkBefore()-->Node<E> node(int index)里面去。

LinkedList.addAll()

LinkedList的批量添加方法addAll(Collection<? extends E> c),内部调用的是addAll(int index, Collection<? extends E> c)方法,这跟有参构造时的执行逻辑是一样的。这里就不再赘述。

LinkedList的修改set()

perl 复制代码
public E set(int index, E element) {
    checkElementIndex(index); // 判断下标索引index是否越界
    Node<E> x = node(index); // 通过下标索引,调用node(int index)方法获取指定位置的节点信息
    E oldVal = x.item; // 暂存指定位置的节点信息
    x.item = element; // 用新的节点信息替代下标索引位置的节点
    return oldVal; // 返回原下标索引位置的节点信息
}

LinkedList的查询

LinkedList链表的查询分以下几种场景:

scss 复制代码
1、查询指定索引index处的节点信息:E get(int index);
2、查询LinkedList链表头的节点信息:E getFirst();
3、查询LinkedList链表尾的节点信息:E getLast();
4、查询某个节点信息在LinkedList链表中的位置:indexOf(Object o);
5、查询LinkedList链表中是否包含某个节点信息:boolean contains(Object o);
1、查询指定索引index节点get(int index)
perl 复制代码
public E get(int index) {
    checkElementIndex(index); // 判断下标索引index是否越界
    // 通过下标索引,调用node(int index)方法获取指定位置的节点信息,然后返回该节点信息的Item元素
    return node(index).item;
}

node()方法前面已经详细分析过了,这里不再赘述,后面也不再提示;

2、查询链表头节点getFirst()
csharp 复制代码
public E getFirst() {
    final Node<E> f = first; // 直接获取LinkedList链表的成员变量first作为链表的头结点
    if (f == null) { // 头结点为null,抛出异常
        throw new NoSuchElementException();
    }
    return f.item;
}
3、查询链表尾节点getLast()
csharp 复制代码
public E getLast() {
    final Node<E> l = last; // 直接获取LinkedList链表的成员变量last作为链表的头结点
    if (l == null)
        throw new NoSuchElementException(); // 尾结点为null,抛出异常
    return l.item;
}
4、查询某个节点的位置indexOf()

查询某个节点信息在LinkedList链表中的位置,有两种查询方式,一是从头结开始往尾节点查询;另一种是从尾结点开始向头结点查询;

从头结点开始往尾节点查询的实现:

从尾结点开始向头结点查询的实现:

这两个方法有一个缺陷:

sql 复制代码
如果LinkedList链表中,有多个null或者Empty节点对象时,该方法只能查找到第一个。查找不到其他的。
5、查询链表是否包含某个节点contains(Object o)
typescript 复制代码
public boolean contains(Object o) {
    return indexOf(o) != -1;
}

该方法实现很简单,直接调用indexOf(o)方法怕 从头结点开始向尾结点,通过 for循环注意查询链表中是否有指定节点的信息。

LinkedList的删除

LinkedList链表的删除,分以下几种场景:

scss 复制代码
1、删除默认操作:remove();
2、删除链表的第一个节点:removeFirst()/pop();
3、删除指定的元素:remove(Object o)/removeFirstOccurrence(Object o);
4、删除指定的下标索引index对应的节点元素:remove(int index);
5、删除链表的最后一个节点:removeLast();
移除第一个remove()

对于场景1和场景2,其实最后执行的都是removeFirst()方法,下面我们看看remove()方法内部的执行情况:

csharp 复制代码
public E remove() {
    return removeFirst();
}

remove(o)方法内部调用的是removeFirst()方法,我们再看看pop()方法内部的执行情况:

csharp 复制代码
public E pop() {
    return removeFirst();
}

pop()方法内部调用的也是removeFirst()方法。因此我们来看看removeFirst()方法的实现:

ini 复制代码
public E removeFirst() {
    final Node<E> f = first; // 获取链表的第一个节点 first,他是链表的成员变量
    if (f == null) {
        throw new NoSuchElementException();
    }
    return unlinkFirst(f); // 调用unlinkFirst()方法执行remove移除操作
}

private E unlinkFirst(Node<E> f) {
    final E element = f.item; // 暂存 f 节点的 元素信息
    final Node<E> next = f.next; // 暂存 f 节点的 下一个节点的信息
    // TODO:下面的 f 清空及后面的if...else...操作完成后,GC将会回收节点内存。
    f.item = null; // 清空 f 节点的的元素信息
    f.next = null; //  // 清空 f 节点的的下一个节点的信息
    first = next; // 重新将 原本的next 下一个节点的信息赋值给first,使其成为新的头结点
    if (next == null) { // 如果 next 节点信息为空,表示后面first节点后面没有其他的节点了,清空 last 节点
        last = null;
    } else { // next 节点信息不为空,那么就将原本的next节点的上一个节点清空(s上一个节点就是被删除的那个节点)
        next.prev = null;
    }
    size--; // 更新链表的长度
    modCount++; // 更新modCount,表示链表发生了结构性改变。
    return element;
}

unlinkFirst()方法主要干了以下几件事情:

1、暂存要移除的节点元素item及要移除的节点的下一个节点next;

2、清空要移除的节点的元素信息item、下个节点的信息next,将要移除的节点的下一个节点next赋值给链表的第一个节点first;

3、清空next节点及对应的上一个节点;

4、更新 size和modCount;

删除指定元素remove(Object o)

删除指定元素有两个实现方法,分别是removeFirstOccurrence(Object o)方法和remove(Object o)方法,但其实removeFirstOccurrence(Object o)方法内部调用的就是remove(Object o)方法:

typescript 复制代码
public boolean removeFirstOccurrence(Object o) {
    return remove(o);
}

因此我们看看remove(Object o)方法的内部实现:

删除链表所有元素clear()
转化为数组toArray()

其内部就是通过for循环将LinkedList的所有节点都保存到一个长度为size的Object数组中:

ini 复制代码
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;
}

总结:

1、LinkedList 是双向列表的数据结构实现。因此,LinkedList中保存的各个Item元素是有序 的,可以重复 的,并且可以为null的。同时因为他是基于双向链表的实现,因此他在存储元素的过程中,不需要像ArrayList那样去动态的扩容。

2、链表批量增加,是通过for循环遍历原Collection数组,依次执行插入节点操作,增加一定会修改modCount,表示LinkedList链表发生了结构性改变。而ArrayList则是通过调用Native层的System.arraycopy()方法完成批量插入的。

3、通过下标index获取某个指定位置的node的时候,会采取类似二分法查找的方式,根据index处于前半段还是后半段进行查询,以提升查询效率

4、通过下标index删除指定节点,会先根据index索引在node()方法里面通过for循环遍历LinkedList链表,找到对应的Node节点信息,然后去链表上unlink掉这个Node。

5、通过元素Object o删除,会先去通过for循环遍历LinkedList链表,确认链表中是否包含该Node节点,如果有,去链表上unlink掉这个Node。

6、但凡对LinkedList链表执行增加、插入、删除操作,就一定会更新modCount参数,表示LinkedList链表发生了结构性变化。

7、修改set()操作,也是先通过node()方法,根据下标index索引通过for循环找到Node,然后替换值。

8、查找get()操作,相比于修改set()操作,少了然后替换值的操作,其他操作一样。

9、修改set()操作和查找get()操作,都不会更新modCount。

相关推荐
Ricon组态薄荷糖9 分钟前
Ricon组态系统:工业组件开发指南与实践
前端·后端·物联网
传奇开心果编程10 分钟前
【Jetpack Compose基础语法学与练】第9课 SideEffect副作用 + LaunchedEffect,处理异步任务和生命周期
android·学习·ui·kotlin·android jetpack
flash俊杰14 分钟前
Electron 桌面应用的进程模型:为什么 fork Next.js standalone
前端
沙蒿同学25 分钟前
我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战
前端·后端·github
cpolar技术支持27 分钟前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright
wordbaby27 分钟前
企业级后台管理系统路由设计与最佳实践指南
前端
胡志辉的博客37 分钟前
【完全开源】IP 纯净度检测 可一键部署到自己的CF
前端·javascript·chrome·ip·chromium
Hilaku1 小时前
作为面试官,我最怕遇到什么样的候选人?
前端·javascript·程序员
TiDi1 小时前
Pinia优化重复请求
前端
web3d5202 小时前
01-用 Leafletjs 10 分钟搭一张水利一张图(Vue3 + Vite 实战)
前端·javascript