九张图,探究 LinkedList 集合源码

简介: List 集合体系下的一个常用实现类,底层为双向链表结构,通过创建节点改变节点的前驱指针和后驱指针实现集合元素的动态改变。


1、LinkedList 简介


一个由 Node 节点组成的双向链表集合,可以存放任意元素包括 null 。


看其内部重要源代码(存储元素的结构):

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;
    // 节点结构
    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;
        }
    }
    // ...
}


存储元素时,内部效果如图:


image.png


都说,LinkedList 集合增删快、查询慢,那这是什么依据,其内部又是如何增删元素的,这些就都是我所要分析的点,往下看吧!


2、属性分析


代码就不贴了,上面已经贴过其所有的属性了,属性不多。


  • size:链表集合中元素个数。
  • first:头结点,头插法时指向刚添加进来的元素,初始值为 null。
  • last:尾节点,头插法时指向第一个插入的元素,尾插法时指向刚插入的元素,初始值为 null。
  • Node:内部类,定义节点结构的,其内部包括三个属性:值、前驱指针、后驱指针。


注意的是,属性都被 transient 关键字修饰,说明 LinkedList 内部实现了自己的一套序列化逻辑,提高内存利用率,减少 null 值的空间占用。


3、构造器


源码:

/**
 * Constructs an empty list.
 */
public LinkedList() {
}


空构造器,说明没有默认长度,当然也不需要有默认长度,有元素就创建节点,没有就不创建。


4、头插法


顾名思义就是每次添加元素的时候都在其头部添加元素,源码如下:

private void linkFirst(E e) {
    // 头节点
    final Node<E> f = first;
    // 创建节点
    final Node<E> newNode = new Node<>(null, e, f);
    // 头节点指向新创建节点
    first = newNode;
    // 是否是第一个头节点
    if (f == null)
        // 是第一个头节点,那头节点和尾节点都指向新创建节点
        last = newNode;
    else
        // 不是第一个节点,那将开始头节点指向的节点per引用指向新创建的节点
        f.prev = newNode;
    // 元素个数加一
    size++;
    // 修改次数加一
    modCount++;
}


第一次看代码可能是有点晕,毕竟是关于地址指向的问题,下面我就来分析分析。

头插法插入元素分为两种情况:


  1. 插入的元素为第一个元素
  2. 插入的元素不是第一个元素


具体分析如下:


1、插入第一个元素


初始状态,LinkedList 集合属性状态:


image.png


初始状态,头尾指针都是指向 null

下面,我根据头插法代码往集合中加入第一个元素,来程序运行的效果图:


微信图片_20220426231728.png


结合运行效果图,当使用头插法插入第一个元素结束后,头尾指针都会指向第一个添加的元素。


2、插入的不是第一个元素


那当添加的不是第一个元素,结合上图加入第二个元素,程序运行效果图如下:


微信图片_20220426231759.png


当加入第二个元素时,头节点指针指向最新添加的元素,且 first 的 next 指向前一个元素,而前一个元素的 per 指针指向 first 节点。


如此往复的通过头插法添加元素我们发现,first 永远会指向最新添加的元素,并且最新元素和前一个元素互相指引形成一个双向链表。


5、尾插法


和头插法正好相反,每次添加元素的时候都是在其尾部添加元素,源码如下:


void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}


也是和头插法类似,添加数据时,插入的情况有两种:


  1. 尾插法插入的元素是第一个元素。
  2. 尾插法插入的元素不是第一个元素。


具体分析:


1、插入第一个元素


通过尾插法,插入第一个元素效果图如下:


微信图片_20220426231912.png


我们可以发现,尾插法插入第一个元素时过程虽然和头插法不太一样,但最终的结果确一样,头尾指针都指向第一个元素。


2、插入不是第一个元素


那当添加的不是第一个元素,结合上图加入第二个元素,程序运行效果图如下:


微信图片_20220426231939.png


和头插法不同的是,尾插法每次添加元素都是 last 节点移动,并指向最新添加的元素。


6、移除元素


移除方法有很多重载,我就挑几个经常使用的移除方法分析。


源码一如下:

public E remove() {
    // 移除头结点指向的元素
    return removeFirst();
}
public E removeFirst() {
    // 定义中间变量 f
    final Node<E> f = first;
    // 如果头指针为 null 说明集合中就没有元素,抛错
    if (f == null)
        throw new NoSuchElementException();
    // 真正移除第一个元素
    return unlinkFirst(f);
}
private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    // 获取移除的元素值
    final E element = f.item;
    // 获取头指针的下一个元素节点
    final Node<E> next = f.next;
    // 清空要移除的元素值,利于内存回收
    f.item = null;
    f.next = null; // help GC
    // 将头指针往后移动一位
    first = next;
    // 如果集合为空,last 和 first 指向 null
    if (next == null)
        last = null;
    else
        // 如果集合中还有元素,那 first 节点 per 指针为 null
        next.prev = null;
    // 元素个数减一
    size--;
    // 修改次数加一
    modCount++;
    // 返回移除元素值
    return element;
}


remove 不带参数的移除方法就是移除集合中 first 节点指向的元素,并且 first 顺延指向下一个元素。


结合上节插入元素的链表结构,移除的大致效果图如下:


微信图片_20220426232037.png


主要功能还是 unlinkFirst 方法,将 first 头指针指向下一个元素,并将要移除的元素置空。


源码二如下:


public E remove(int index) {
    // 检查下标,如果下标不在 size 范围内,报错
    checkElementIndex(index);
    // 获取下标指定的节点,并开始移除
    return unlink(node(index));
}
// 找到下标对应的节点
Node<E> node(int index) {
    // assert isElementIndex(index);
    // 判断下标更靠近头结点还是尾节点
    if (index < (size >> 1)) {
        // 靠近头结点
        // 遍历,找到下标为 index 的节点返回
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        // 靠近尾节点
        // 遍历,找到下标为 index 的节点返回
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}
// 移除指定节点
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;
    if (prev == null) {
        // 移除的是头结点,头结点向后指一位
        first = next;
    } else {
        // 不是头结点
        // 前驱节点的 next 指向要移除节点的后驱节点
        prev.next = next;
        // 移除节点的 per 置空
        x.prev = null;
    }
    if (next == null) {
         // 移除的是尾结点,尾结点向前指一位
        last = prev;
    } else {
        // 不是尾结点
        // 将要移除的后驱节点 per 指向要移除节点的 前驱节点
        next.prev = prev;
        // 移除节点的 next 置空
        x.next = null;
    }
    // 移除节点值置空
    x.item = null;
    // 大小减一
    size--;
    // 修改次数加一
    modCount++;
    // 返回移除元素的值
    return element;
}


结合代码,根据下标移除元素的逻辑并不是很复杂,我总结出如下实现步骤:


  1. 检查下标是否在 size 范围内
  2. 根据下标,找到移除的节点
  3. 将节点移除链表,并将链表的前驱指针和后驱指针链接正确


大体上就是这三步,难点应该就是第三步,节点移除,指针指向问题,下面我再来画个移除图,便于理解。


微信图片_20220426232201.png


源码三如下:

public boolean remove(Object o) {
    // 是否移除 null 值
    if (o == null) {
        // 移除第一个碰到的 null 元素
        for (Node<E> x = first; x != null; x = x.next) {
            // 遍历,找到第一个为 null 值得 节点
            if (x.item == null) {
                // 调用 源码二 分析的 unLink 方法,移除节点
                unlink(x);
                return true;
            }
        }
    } else {
        // 移除第一个碰到的 o 元素
        for (Node<E> x = first; x != null; x = x.next) {
            // 比较节点值是否和要移除的节点值相等
            if (o.equals(x.item)) {
                 // 调用 源码二 分析的 unLink 方法,移除节点
                unlink(x);
                return true;
            }
        }
    }
     // 没找到要移除的值,返回 false
    return false;
}


根据值移除元素,底层的实现逻辑就可以看出来,LinkedList 是可以存储 null 值的。


而且移除思路也是先变量一遍链表找到要移除的元素,再调用移除节点的方法进行移除(该方法在上面讲过),成功移除返回true,反之 false。


7、添加方法


上面分析了头插法和尾插法,那我们就来看看 LinkedList 集合是如何添加元素的了,看源码:


public boolean add(E e) {
    // 默认尾插法添加元素
    linkLast(e);
    return true;
}


前面分析了尾插法,现在看到 add 源码其底层就是通过调用尾插法的方法来添加元素,这也是集合元素有序性的原因。


通常我们也会调用下面方法,在指定的下标处添加元素,方法源码如下:


public void add(int index, E element) {
    // 检查下标是否在 size 范围内
    checkPositionIndex(index);
    // 如果就添加在 尾部,那直接使用尾插法
    if (index == size)
        linkLast(element);
    else
        // 不在尾部那就插在 index 下标指向的 node 节点之前
        linkBefore(element, node(index));
}
// 元素,插在 succ 节点之前
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    // 定义中间遍历,获取 succ 节点的 per 指向的节点
    final Node<E> pred = succ.prev;
    // 创建要插入的节点
    final Node<E> newNode = new Node<>(pred, e, succ);
    // 将 succ 的 per 指向 新创建的 节点
    succ.prev = newNode;
    // 是否是插在头结点前
    if (pred == null)
        // 是,那就把 first 指针指向新创建的节点
        first = newNode;
    else
        // 否,将开始 succ 节点 per 指向的前驱节点的 next 指向 新创建的 节点
        pred.next = newNode;
    // 元素个数加一
    size++;
    // 修改次数加一
    modCount++;
}


结合源码,在指定下标处添加元素,首先会检查下标是否在 size 范围内,然后会根据下标获取到对应位置的元素节点,最后才是插入元素。

linkBefore 方法看字面意思就知道,在指定节点之前插入元素,下面就根据该方法画出对应的运行效果图:


微信图片_20220426232336.png


指定下标插入元素的重要步骤就是搞清楚 next 及 per 指针的指向问题。例如本次案例就是先将 e 节点的 per 指向 e3 、next 指向 e2 节点,然后再把 e3 的 next 和 e2 的 per 指向 e 节点即完成一次元素的插入。


8、清空方法


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;
    }
    // 头尾指针指向 null 表示集合为空
    first = last = null;
    // size 大小
    size = 0;
    // 修改次数加一
    modCount++;
}


清空方法很简单,就是从头结点挨个遍历置空,最后将头尾指针指向 null 并修改 size 大小即可。


9、源码分析总结


经过了很长的源码分析篇幅,基本上吧 LinkedList 中比较基础的方法都过了一遍,那我针对这些源码概括一下 LinkedList 的几个特性:


  1. 底层使用 Node 节点形式存储数据
  2. first 、last 两个节点指针分别指向链表的两头
  3. first 、last 都指向 null 时表示为空集合
  4. 添加元素时,头插法和尾插法的时间复杂度都是O(1)
  5. 删除元素时,时间复杂度是O(1)
  6. 访问元素时,如果不是头尾节点,时间复杂度是O(n)
  7. 内部没有初始容量,所以不会有扩容现象
  8. 内部没有锁机制,是一个线程不安全的集合


好了,今天的内容到这里就结束了,关注我,我们下期见。


目录
相关文章
|
3天前
|
云安全 人工智能 自然语言处理
AI说的每一句话,都靠谱吗?
阿里云提供AI全栈安全能力,其中针对AI输入与输出环节的安全合规挑战,我们构建了“开箱即用”与“按需增强”相结合的多层次、可配置的内容安全机制。
|
10天前
|
域名解析 人工智能
【实操攻略】手把手教学,免费领取.CN域名
即日起至2025年12月31日,购买万小智AI建站或云·企业官网,每单可免费领1个.CN域名首年!跟我了解领取攻略吧~
|
4天前
|
安全 Java Android开发
深度解析 Android 崩溃捕获原理及从崩溃到归因的闭环实践
崩溃堆栈全是 a.b.c?Native 错误查不到行号?本文详解 Android 崩溃采集全链路原理,教你如何把“天书”变“说明书”。RUM SDK 已支持一键接入。
418 187
|
2天前
|
数据采集 消息中间件 人工智能
跨系统数据搬运的全方位解析,包括定义、痛点、技术、方法及智能体解决方案
跨系统数据搬运打通企业数据孤岛,实现CRM、ERP等系统高效互通。伴随数字化转型,全球市场规模超150亿美元,中国年增速达30%。本文详解其定义、痛点、技术原理、主流方法及智能体新范式,结合实在Agent等案例,揭示从数据割裂到智能流通的实践路径,助力企业降本增效,释放数据价值。
|
8天前
|
人工智能 自然语言处理 安全
国内主流Agent工具功能全维度对比:从技术内核到场景落地,一篇读懂所有选择
2024年全球AI Agent市场规模达52.9亿美元,预计2030年将增长至471亿美元,亚太地区增速领先。国内Agent工具呈现“百花齐放”格局,涵盖政务、金融、电商等多场景。本文深入解析实在智能实在Agent等主流产品,在技术架构、任务规划、多模态交互、工具集成等方面进行全维度对比,结合市场反馈与行业趋势,为企业及个人用户提供科学选型指南,助力高效落地AI智能体应用。
|
4天前
|
消息中间件 安全 NoSQL
阿里云通过中国信通院首批安全可信中间件评估
近日,由中国信通院主办的 2025(第五届)数字化转型发展大会在京举行。会上,“阿里云应用服务器软件 AliEE”、“消息队列软件 RocketMQ”、“云数据库 Tair”三款产品成功通过中国信通院“安全可信中间件”系列评估,成为首批获此认证的中间件产品。此次评估覆盖安全可信要求、功能完备性、安全防护能力、性能表现、可靠性与可维护性等核心指标,标志着阿里云中间件产品在多架构适配与安全能力上达到行业领先水平。
313 194