LinkedList-底层源码解析

LinkedList 底层源码解析:一张双向链表的图讲透插入与删除

基于 JDK 8 / 11 java.util.LinkedList · 本文约 4000 字

数据结构 · 源码阅读


目录

  1. 它到底是什么:一个双向链表
  2. 骨架:三个字段 + 一个静态内部类
  3. 插入:linkLast 与 linkBefore 的四步指针
  4. 删除:unlink 如何让邻居互相绕过
  5. 查找:node(int) 的折半遍历
  6. 复杂度对比与 ArrayList 之争
  7. 三个真实存在的坑
  8. 源码之外:序列化、Deque、fail-fast
  9. 手写一个迷你双向链表
  10. 小结与记忆口诀

1. 它到底是什么:一个双向链表

很多人的第一印象是"LinkedList 是一条链",这只对了一半。准确地说,java.util.LinkedList 的内部是一条 双向链表(doubly linked list):每个节点除了数据,还同时持有指向前驱指向后继的两个引用。整个容器只在两端各保存一个引用,分别叫 first(头)和 last(尾)。

双向链表的整体结构

图 1 · 双向链表整体结构:上方弧线是 next(往后),下方弧线是 prev(往回)

读图要点有三条,先记住后面才能看懂源码:

  • 上方的弧线是 next下方的弧线是 prev。两个方向各自成链,互不相干,所以插入和删除要分别处理两个方向。
  • 头节点的 prevnull,尾节点的 nextnull——这就是源码里所有 if (prev == null) 判断的来源。
  • 拿到任意一个节点,都能 O(1) 地拿到它的前驱和后继。这既是优势,也是后面"删除其实不便宜"的伏笔。

2. 骨架:三个字段 + 一个静态内部类

整个类的骨架干净得出乎意料:只有三个成员变量

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

三个细节值得单独说:

  • Nodestatic 内部类,不是普通内部类。普通内部类会隐式持有外部 LinkedList 的引用,一百万个节点就白白多出 8MB(64 位下压缩指针约 4 字节 × 1e6)。加上 static,节点只装它该装的东西。
  • size / first / last 都是 transient。指针只在内存中有意义,序列化时没必要写出去,所以 writeObject 只写 size 和每个元素的值,反序列化时再重新串链。
  • 构造函数里 prev 在前、next 在后,跟字段声明顺序一致——源码里所有 new Node<>(pred, e, succ) 都是这个顺序,别记反。

空间开销账: 存一个元素,除了元素本身,还有一个 Node 对象头(约 16 字节)+ item / next / prev 三个引用(约 12 字节)= 每个元素约 28 字节的额外开销。这就是"双向链表比数组贵"的量化答案。


3. 插入:linkLast 与 linkBefore 的四步指针

LinkedList 的插入全部收敛到三个私有方法:linkFirstlinkLastlinkBefore。先看最简单的尾插 linkLast,它是 add(E e) 的底层实现:

void linkLast(E e) {
    final Node<E> l = last;                     // ① 先存下旧的尾节点
    final Node<E> newNode = new Node<>(l, e, null);  // ② 新节点:前驱=l,后继=null
    last = newNode;                             // ③ 更新尾指针
    if (l == null)                              //    原链表为空
        first = newNode;
    else
        l.next = newNode;                       // ④ 旧尾节点的 next 指向新节点
    size++;
    modCount++;
}

尾插只动两根指针(新节点的 prev、旧尾的 next),所以是 O(1)。真正通用的是在指定节点之前插入的 linkBefore,它是 add(int index, E element) 的核心:

void linkBefore(E e, Node<E> succ) {   // succ = 插入位置上的原节点
    // assert succ != null;
    final Node<E> pred = succ.prev;              // 前驱
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;                        // ③ 后继回指新节点
    if (pred == null)
        first = newNode;                        //    插在最前面:更新 first
    else
        pred.next = newNode;                    // ④ 前驱的 next 指向新节点
    size++;
    modCount++;
}

注意 newNode 构造那一行就同时完成了第 ① ② 两步(X.prev = predX.next = succ)。把源码翻译成图就是这样:

在 P 之后插入新节点 X

图 2 · 插入操作:橙色是新节点的指针,紫色是邻居的指针

为什么第 ④ 步必须最后做?
因为第 ③ 步 P.next.prev = X 还要靠 P.next 找到后继节点 N。一旦先执行 P.next = X,原来的 N 就再也找不到了,链表后半段直接丢失。源码里把 succ.prev = newNode 放在 pred.next = newNode 之前,正是这个道理。

所以 add(int index, E element) 的复杂度是 O(n) + O(1) = O(n)——瓶颈不在"改指针",而在先找到那个位置

public void add(int index, E element) {
    checkPositionIndex(index);            // 越界检查:0 <= index <= size
    if (index == size)
        linkLast(element);                // 尾部:O(1)
    else
        linkBefore(element, node(index));  // 中间:定位 O(n) + 插入 O(1)
}

删除只有 unlink(Node<E> x) 一个方法,remove(int)remove(Object)poll() 全都复用它:

E unlink(Node<E> x) {
    final E element = x.item;
    final Node<E> next = x.next;        // 后继
    final Node<E> prev = x.prev;        // 前驱

    if (prev == null) {
        first = next;                   // 删的是头节点:first 后移
    } else {
        prev.next = next;               // ① 前驱的 next 跨过 x 指向后继
        x.prev = null;                  //    断开,帮助 GC
    }

    if (next == null) {
        last = prev;                    // 删的是尾节点:last 前移
    } else {
        next.prev = prev;               // ② 后继的 prev 跨过 x 指向前驱
        x.next = null;
    }

    x.item = null;                      // ③ 清空数据,让 GC 能回收元素对象
    size--;
    modCount++;
    return element;
}

对照图看,删除比插入简单:每个方向只需要改一根指针,然后把自己置空交给 GC。

删除节点 P

图 3 · 删除操作:两个方向各改一根指针,P 被"架空"后连引用一起置空

源码里那两处 x.prev = null / x.next = null / x.item = null 不是为了正确性,而是为了 不制造内存泄漏:如果只让邻居绕过 x,x 自己还攥着 item、next、prev 三个引用,会连带把整条链"拖住"不释放。


5. 查找:node(int) 的折半遍历

双向链表唯一的查找手段就是"一个一个数过去"。但因为有 last 指针,JDK 做了一个朴素却有效的优化——看下标在前半段还是后半段,决定从哪头开始走

Node<E> node(int index) {
    // assert isElementIndex(index);
    if (index < (size >> 1)) {          // index < size/2,从头往后
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {                            // 否则从尾往前,这正是 prev 的价值
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

size >> 1 是位运算版的除以 2。注意它只是把常数砍半,复杂度依然是 O(n)——这也是 get(int index) 为什么被反复劝退的原因。


6. 复杂度对比与 ArrayList 之争

操作 LinkedList ArrayList 说明
add(E) 尾插 O(1) O(1) 均摊 LinkedList 每次都 new Node;ArrayList 扩容时才搬
addFirst / addLast O(1) O(n) 数组头部插入要整体后移
add(index, E) O(n) O(n) 链表卡在定位;数组卡在 arraycopy,但后者是内存块级拷贝,常数极小
get(index) O(n) O(1) 链表最大短板
remove(int index) O(n) O(n) 同理,定位 + 搬运
已知节点处插/删 O(1) 通过 Iterator / ListIterator 迭代时才是 O(1)
内存 每元素约 +28 字节 数组尾部可能有闲置容量 链表节点还分散在堆上,缓存命中率差

纠正一句流传很广的话:"LinkedList 增删快,ArrayList 增删慢"。 这句话漏掉了定位成本。

只有在已经持有节点引用(比如用 ListIterator 迭代到某个位置)时,LinkedList 的增删才是 O(1)。只要方法是按 index 或按 对象 操作的,LinkedList 都要先 O(n) 找过去,总体并不比 ArrayList 快,反而因为指针跳转、缓存不友好,实测常常更慢。


7. 三个真实存在的坑

坑一:用下标 for 循环遍历,直接变成 O(n²)

// 灾难:每次 get(i) 都要从链头/链尾重新走一遍
for (int i = 0; i < list.size(); i++) {
    use(list.get(i));        // 总复杂度 O(n²)
}

// 正确:迭代器持有当前节点,next() 只走一步
for (String s : list) {      // 增强 for 底层就是 Iterator,O(n)
    use(s);
}

10 万元素的 LinkedList,两种写法能差出几百倍。这是面试里最高频的一道题。

坑二:remove(Object) 只会删第一个匹配项

public boolean remove(Object o) {
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) { unlink(x); return true; }
        }
    } else {
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) { unlink(x); return true; }  // 找到就 return
        }
    }
    return false;
}

注意它是从头单向遍历的,没有用折半优化。要删所有匹配项,得用 removeIf 或迭代器的 remove()

坑三:Null 元素与 remove() 的歧义

LinkedList 允许存 null,所以 remove() 无参版本(来自 Deque)删除的是头节点并返回它的值;如果你存了 null,remove() 返回 null 时你分不清"删掉了 null 元素"还是"链表本来就空"。空链表时它直接抛 NoSuchElementException,而 poll() 才返回 null——需要"空就返回 null"的语义时用 poll()


8. 源码之外:序列化、Deque、fail-fast

  • 序列化:字段全 transientwriteObject 只写 size 再加每个 itemreadObject 反过来用 linkLast 逐个元素重建链表。所以序列化后的体积与指针结构无关。
  • Deque:LinkedList 实现了 Deque,所以有一整套 offerFirst/offerLast/pollFirst/pollLast/peekFirst/peekLast/push/pop。这些全是 O(1),因为只动 first / last——这是它最被低估的优势场景:当队列/栈用
  • fail-fast:每个结构性修改都 modCount++,迭代时若发现 expectedModCount != modCount 立即抛 ConcurrentModificationException。要在遍历中删除,请用 iterator.remove(),它会同步 expectedModCount

9. 手写一个迷你双向链表

把上面的四步指针自己敲一遍,比读十遍源码都管用。下面是一个保留核心逻辑的精简版:

public class MiniLinkedList<E> {
    private static class Node<E> {
        E item; Node<E> prev, next;
        Node(Node<E> prev, E item, Node<E> next) {
            this.prev = prev; this.item = item; this.next = next;
        }
    }
    private Node<E> first, last;
    private int size;

    public void add(E e) {                        // 尾插 O(1)
        Node<E> l = last;
        Node<E> node = new Node<>(l, e, null);
        last = node;
        if (l == null) first = node; else l.next = node;
        size++;
    }

    public E get(int i) { return node(i).item; }

    private Node<E> node(int i) {                // 折半遍历
        if (i < (size >> 1)) {
            Node<E> x = first;
            for (int k = 0; k < i; k++) x = x.next;
            return x;
        }
        Node<E> x = last;
        for (int k = size - 1; k > i; k--) x = x.prev;
        return x;
    }

    public E remove(int i) {                     // 定位 O(n) + 解除 O(1)
        Node<E> x = node(i), p = x.prev, n = x.next;
        if (p == null) first = n; else { p.next = n; x.prev = null; }
        if (n == null) last = p;  else { n.prev = p; x.next = null; }
        E old = x.item; x.item = null; size--;
        return old;
    }
}

10. 小结与记忆口诀

  1. 结构:三个字段(size / first / last)+ 一个 static Node(item / prev / next)。头 prev、尾 next 为 null,源码里所有边界判断都源于此。
  2. 插入:四根指针——新节点先接两头(1、2),邻居再回头指(3),最后才改前驱的 next(4)。顺序错了就丢节点。
  3. 删除:两根指针——prev.next = nextnext.prev = prev,再把 x 的三个域全部置空。
  4. 查找:只能遍历;node(index)size >> 1 判断从哪头走,砍半常数,仍是 O(n)。
  5. 选型:需要按下标随机访问 → ArrayList(或数组);需要频繁在两端增删 / 当栈和队列 / 在迭代器位置插入删除 → LinkedList。

一句话版本: 双向链表用"每个元素多两个引用"换来了"已知节点处 O(1) 增删 + 双向遍历",代价是随机访问退化成 O(n)、内存开销和缓存命中率变差。想清楚你的访问模式,再决定用哪个 List。


插图:双向链表结构、插入四步指针、删除两根指针(同时提供 SVG 源文件与 PNG 高清版,见 images/ 目录)。

posted @ 2026-09-03 22:04  白鹿为溪  阅读(10)  评论(0)    收藏  举报