跳至主要內容

Mr.Hope大约 17 分钟

引言

LinkedList 的插入操作真的是 O(1) 吗?这个看似常识的答案,在实际生产中可能让你踩坑。

大多数开发者知道 LinkedList 基于双向链表,却忽略了它在真实场景中的性能陷阱:for 循环 + get(i) 会让时间复杂度退化到 O(n²),CPU 缓存不友好导致的性能下降,以及每个节点额外 ~32 字节的内存开销。

本文将从源码级别揭示 LinkedList 的真实面貌,看完你将掌握:

  1. LinkedList 的底层是基于什么数据结构实现的?
  2. LinkedList 的插入和删除操作时间复杂度是否都是 O(1)?
  3. LinkedList 和 ArrayList 相比,哪种结构更占内存?
  4. LinkedList 真的不支持随机访问吗?
  5. LinkedList 是线程安全的吗?

简介

LinkedList 底层是基于双向链表实现的,内部有三个属性:size 用来存储元素个数,first 指向链表头节点,last 指向链表尾节点。

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

头尾节点都是由 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;
    }
}

类图架构

LinkedList 实现了 List 接口,提供了集合操作的常用方法,当然也包含随机访问的方法,只不过没有像 ArrayList 那样实现 RandomAccess 接口,LinkedList 提供的随机访问方法时间复杂度不是常量级别的。

LinkedList 还实现了 Deque 接口,Deque 是 double ended queue 的缩写,读音是 /dek/,读错就尴尬了。 Deque 是双端队列,可以在头尾进行插入和删除操作,兼具栈和队列的性质。

Deque 提供了大量增删查方法,目的是区分不同语义:对于添加操作,addFirst() 在队列满时会抛出异常,而 offerFirst() 则返回 false;对于删除和查询,以 poll/peek 开头的方法在元素不存在时返回 null,而以 remove/get/element 开头的方法则会抛出异常。

常用方法分类如下:

操作类型头部操作(抛出异常)头部操作(返回特殊值)尾部操作(抛出异常)尾部操作(返回特殊值)
添加addFirst(e)push(e)offerFirst(e)addLast(e)add(e)offerLast(e)offer(e)
删除removeFirst()remove()pop()pollFirst()poll()removeLast()pollLast()
查询getFirst()element()peekFirst()peek()getLast()peekLast()

核心工作原理

初始化

LinkedList 只有一个构造方法——无参构造,并不能像 ArrayList 那样指定初始容量。

List<Integer> list = new LinkedList<>();

构造方法的底层实现也是一个空方法,没有做任何操作:

public LinkedList() {
}

这意味着 LinkedList 创建时不会预先分配任何节点,所有节点都是在添加元素时才动态创建。

💡 核心提示:LinkedList 的无参构造是真正的"零成本"初始化——它不分配任何内存。这与 ArrayList 不同(ArrayList 无参构造也只是赋值空数组引用,同样零分配)。但 LinkedList 每次 add 都要 new Node(),意味着每次添加都是一次独立的堆分配,在大量元素场景下,这会导致内存碎片化,且每个节点的 GC 追踪开销更大。

添加元素

添加元素的方法根据位置区分,共有三种:在头部添加、在尾部添加、在任意位置添加。

方法含义不返回值返回布尔值
在头部添加addFirst(e)push(e)offerFirst(e)
在尾部添加addLast(e)add(e)offer(e)offerLast(e)
在任意位置添加add(index, e)-

先看一下使用最多的 add(e) 方法底层实现:

// 添加元素
public boolean add(E e) {
    // 在末尾添加元素
    linkLast(e);
    return true;
}

// 在末尾添加元素
void linkLast(E e) {
    // 1. 获取尾节点
    final Node<E> l = last;
    // 2. 初始化新节点
    final Node<E> newNode = new Node<>(l, e, null);
    // 3. 追加到末尾
    last = newNode;
    if (l == null) {
        first = newNode;
    } else {
        l.next = newNode;
    }
    size++;
    modCount++;
}

linkLast 的核心逻辑:保存原尾节点 → 创建新节点(前驱指向原尾节点) → 更新 last 指针 → 如果链表为空则同时设置 first → 否则将原尾节点的 next 指向新节点。

再看一个从头部添加元素的 push()

// 添加元素
public void push(E e) {
    // 在头部添加元素
    addFirst(e);
}

// 在头部添加元素
public void addFirst(E e) {
    linkFirst(e);
}

// 在头部添加元素,底层私有实现
private void linkFirst(E e) {
    // 1. 获取头节点
    final Node<E> f = first;
    // 2. 初始化新节点
    final Node<E> newNode = new Node<>(null, e, f);
    // 3. 追加到头部
    first = newNode;
    if (f == null) {
        last = newNode;
    } else {
        f.prev = newNode;
    }
    size++;
    modCount++;
}

linkFirstlinkLast 逻辑对称:保存原头节点 → 创建新节点(后继指向原头节点) → 更新 first 指针 → 如果链表为空则同时设置 last → 否则将原头节点的 prev 指向新节点。

最后看在任意位置添加的 add(index, e) 底层实现:

// 在下标index位置添加元素
public void add(int index, E element) {
    // 检查下标是否越界
    checkPositionIndex(index);

    // 如果index等于链表长度,则添加到末尾
    if (index == size) {
        linkLast(element);
    } else {
        // 添加到指定位置前面(先找到index位置的元素)
        linkBefore(element, node(index));
    }
}

// 在当前元素前面添加新元素
void linkBefore(E e, Node<E> succ) {
    final Node<E> pred = succ.prev;
    // 创建新节点,并将新节点插入到当前节点之前
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null) {
        first = newNode;
    } else {
        pred.next = newNode;
    }
    size++;
    modCount++;
}

这里调用了 checkPositionIndex 进行越界检查(允许 index == size,因为可以在末尾追加):

// 检查位置下标是否越界
private void checkPositionIndex(int index) {
    if (!isPositionIndex(index)) {
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
}

// 判断位置下标是否合法
private boolean isPositionIndex(int index) {
    return index >= 0 && index <= size;
}

add(index, e) 内部还依赖了 node(index) 方法来定位节点,这个方法在查询部分详细分析。

查询元素

查询元素的方法按位置区分,共有三种:查询头节点、查询尾节点、查询任意位置元素。

方法含义如果不存在则返回null如果不存在则抛异常
查询头部peek()peekFirst()getFirst()element()
查询尾部peekLast()getLast()
查询任意位置-get(index)

看一下从头查询的 element() 方法的底层实现:

// 查询元素
public E element() {
    return getFirst();
}

// 获取第一个元素
public E getFirst() {
    final Node<E> f = first;
    if (f == null) {
        throw new NoSuchElementException();
    }
    return f.item;
}

再看一个查询尾节点的 getLast() 方法的底层实现:

// 获取最后一个元素
public E getLast() {
    final Node<E> l = last;
    if (l == null) {
        throw new NoSuchElementException();
    }
    return l.item;
}

头尾查询都是 O(1) 时间复杂度,因为直接访问 firstlast 指针。

再看一个查询任意位置的方法 get(index) 的底层实现:

// 查询下标是index位置的元素
public E get(int index) {
    // 检查下标是否越界
    checkElementIndex(index);
    // 返回对应下标的元素
    return node(index).item;
}

// 返回对应下标的元素
Node<E> node(int 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;
    }
}

node(index) 方法做了一个重要优化:通过 index < (size >> 1) 判断索引落在前半段还是后半段,前半段从头节点开始向后遍历,后半段从尾节点开始向前遍历。这样最多只需要遍历链表的一半,将最坏情况的遍历步数从 n 降低到了 n/2。

💡 核心提示:虽然 node(index) 做了优化,但时间复杂度仍然是 O(n)。这意味着在 LinkedList 上做随机访问(如 get(i) 在循环中)的性能远远劣于 ArrayList。下面这个代码就是一个经典陷阱:

// 糟糕的做法:每次循环都要从头部/尾部遍历到 index
for (int i = 0; i < list.size(); i++) {
    process(list.get(i));
}

这段代码的总时间复杂度是 O(n²),因为每次 get(i) 都要遍历最多 n/2 步。应该使用迭代器或增强 for 循环来遍历 LinkedList。

越界检查 checkElementIndexcheckPositionIndex 不同——它要求 index < size(不允许等于),因为查询必须指向已存在的元素:

// 检查下标是否越界
private void checkElementIndex(int index) {
    if (!isElementIndex(index)) {
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
}

// 判断下标是否越界
private boolean isElementIndex(int index) {
    return index >= 0 && index < size;
}

可见 LinkedList 也支持随机访问,只不过时间复杂度是 O(n)。

除了按索引查询,LinkedList 还提供了按值查找的方法 indexOf(Object o)

// 返回指定元素第一次出现的下标
public int indexOf(Object o) {
    int index = 0;
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null)
                return index;
            index++;
        }
    } else {
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item))
                return index;
            index++;
        }
    }
    return -1;
}

indexOf 从头到尾线性遍历查找,对 null 值做了特殊处理(用 == 而不是 equals),时间复杂度为 O(n)。

删除元素

删除元素的方法按位置区分,也分为三种:删除头节点、删除尾节点、删除任意位置节点。

方法含义返回布尔值(不存在返回false)返回旧值(不存在抛异常)
从头部删除remove(o)removeFirstOccurrenceremove()poll()pollFirst()removeFirst()pop()
从尾部删除removeLastOccurrencepollLast()removeLast()
从任意位置删除-remove(index)

先看一个从头开始删除的方法 remove() 的底层实现:

// 删除元素
public E remove() {
    // 删除第一个元素
    return removeFirst();
}

// 从头删除元素
public E removeFirst() {
    final Node<E> f = first;
    if (f == null) {
        throw new NoSuchElementException();
    }
    // 调用实际的删除方法
    return unlinkFirst(f);
}

// 删除第一个元素
private E unlinkFirst(Node<E> f) {
    final E element = f.item;
    final Node<E> next = f.next;
    // 断开头节点与后继节点的连接
    f.item = null;
    f.next = null;
    first = next;
    if (next == null) {
        last = null;
    } else {
        next.prev = null;
    }
    size--;
    modCount++;
    return element;
}

unlinkFirst 的删除逻辑:备份元素值 → 将待删除节点的 itemnext 置为 null(帮助 GC 回收) → 更新 first 指针 → 如果链表变为空则同时置 lastnull → 否则将新头节点的 prev 置为 null

再看一个从最后一个节点开始删除的方法 removeLast() 的底层实现:

// 删除最后一个元素
public E removeLast() {
    final Node<E> l = last;
    if (l == null) {
        throw new NoSuchElementException();
    }
    // 实际的删除逻辑
    return unlinkLast(l);
}

// 删除最后一个元素
private E unlinkLast(Node<E> l) {
    final E element = l.item;
    // 断开与前一个节点的连接
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null;
    last = prev;
    if (prev == null) {
        first = null;
    } else {
        prev.next = null;
    }
    size--;
    modCount++;
    return element;
}

再看一个从任意位置删除的方法 remove(index) 的底层实现:

// 删除下标是index位置的元素
public E remove(int index) {
    // 检查下标是否越界
    checkElementIndex(index);
    // 删除下标对应的元素(先找到下标对应的元素)
    return unlink(node(index));
}

// 删除下标对应的元素
E unlink(Node<E> x) {
    final E element = x.item;
    // 1. 备份当前节点的前后节点
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    // 2. 断开与前驱节点的连接
    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }

    // 3. 断开与后继节点的连接
    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}

unlink 是通用的节点删除方法:备份前后节点 → 根据是否为头/尾节点分别处理 → 将待删除节点的三个引用(itemprevnext)全部置为 null,帮助 GC 回收。

除了按索引删除,LinkedList 还提供了按值删除的方法 remove(Object o)

// 删除指定元素第一次出现的位置
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 false;
}

同样对 null 值做了特殊处理,先遍历找到第一个匹配的节点,然后调用 unlink 删除。时间复杂度为 O(n)。

LinkedList vs ArrayList 内存对比

这是面试中的经典问题。我们来算一笔账:

ArrayList:底层是一个 Object[] 数组 + size int字段。存储 n 个元素的内存开销 ≈ 数组引用大小 × n(紧凑连续内存)。

LinkedList:底层每个元素是一个独立的 Node 对象,每个 Node 包含:

  • item 引用(指向实际元素对象)
  • prev 引用(指向前驱节点)
  • next 引用(指向后继节点)
  • 对象头(Object header,64位 JVM 上约 12~16 字节)

所以在 64 位 JVM 上,LinkedList 每个元素额外占用约 32 字节(3 个引用 × 8 字节 + 对象头)的开销,还不包括每个 Node 对象的内存对齐填充。

💡 核心提示:LinkedList 不仅占用更多内存,而且由于节点分散在堆中,CPU 缓存命中率远低于 ArrayList。ArrayList 的连续内存布局对 CPU 缓存行(cache line)非常友好,预取器可以高效加载相邻数据。这也是为什么在实际性能测试中,ArrayList 的遍历速度往往比 LinkedList 快数倍的原因。

并发修改的问题

关于 ConcurrentModificationException:LinkedList 的迭代器是 fail-fast 的。在创建迭代器时会记录当前的 modCount 值,每次调用 next()remove() 时都会检查 modCount 是否与预期值一致。如果不一致(说明在迭代过程中有其他线程或代码修改了链表结构),就会抛出 ConcurrentModificationException。这是 Java 集合框架的一种快速失败机制,目的是尽早发现并发修改问题,而不是等到数据错乱后才暴露。

生产环境避坑指南

基于上述源码分析,以下是 LinkedList 在生产环境中常见的陷阱:

陷阱现象解决方案
在 LinkedList 上用 for 循环 + get(i) 遍历O(n²) 性能灾难,CPU 飙高使用增强 for 循环或 Iterator
以为 LinkedList 插入一定比 ArrayList 快中间位置插入同样需要 O(n) 定位节点只在头尾操作时 LinkedList 更快
LinkedList 作为高并发共享对象节点链接被破坏,数据丢失或死循环使用 Collections.synchronizedList() 或并发队列
大数据量下 LinkedList 内存溢出每个节点额外占用 ~32 字节,GC 压力大数据量大时优先用 ArrayList
误用 element()/getFirst() 在空列表上抛出 NoSuchElementException先用 isEmpty() 检查,或用 peek()/peekFirst()

总结

学完了 LinkedList 核心方法的源码,现在可以很容易回答文章开头的几个问题了。

  1. LinkedList 的底层是基于什么数据结构实现的?

    答案:双向链表。

  2. LinkedList 的插入和删除操作时间复杂度是否都是 O(1)?

    答案:不是。在头尾操作的时间复杂度是 O(1),在中间位置或按索引操作的时间复杂度是 O(n)。

  3. LinkedList 和 ArrayList 相比,哪种结构存储数据的时候更占内存?

    答案:LinkedList 每个节点额外包含两个引用(前驱和后继),因此存储相同数量的元素时,LinkedList 占用的内存更多。

  4. LinkedList 真的不支持随机访问吗?

    答案:LinkedList 支持按索引访问(get(index)),但时间复杂度是 O(n) 而非 O(1),因此没有实现 RandomAccess 接口。

  5. LinkedList 是线程安全的吗?

    答案:不是。内部没有提供同步机制来保证线程安全,并发修改可能导致数据错乱。如果需要线程安全,可以使用 Collections.synchronizedList() 包装,或者使用 CopyOnWriteArrayList

关键操作时间复杂度对比

操作方法示例时间复杂度说明
头部插入addFirst() / push()O(1)直接修改 first 指针
尾部插入add() / addLast()O(1)直接修改 last 指针
中间插入add(index, e)O(n)node(index) 定位
头部删除removeFirst() / pop()O(1)直接修改 first 指针
尾部删除removeLast()O(1)直接修改 last 指针
中间删除remove(index)O(n)node(index) 定位
按值删除remove(Object)O(n)需线性遍历查找
头部查询getFirst() / peek()O(1)直接访问 first
尾部查询getLast()O(1)直接访问 last
随机访问get(index)O(n)node(index) 定位
按值查找indexOf() / contains()O(n)需线性遍历
遍历iterator() / forEachO(n)逐个节点访问

LinkedList vs ArrayList 对比

特性LinkedListArrayList
底层结构双向链表动态数组
内存布局分散堆中,节点独立对象连续数组内存
CPU 缓存友好度差(指针跳跃)优(连续内存)
头部插入/删除O(1)O(n) 需整体移动
尾部插入O(1) 平均O(1) 平均,扩容时 O(n)
随机访问O(n)O(1)
每个元素内存开销~32 字节 + 对象头~引用大小(紧凑排列)
遍历时删除Iterator.remove() O(1)remove(index) O(n),removeIf() O(n)
实现 RandomAccess
推荐场景频繁头尾操作的栈/队列大多数场景的首选 List

使用建议

  1. 频繁头尾操作选 LinkedList:如果业务场景主要是头部或尾部的插入和删除(如实现栈或队列),LinkedList 的 O(1) 性能优于 ArrayList。
  2. 频繁随机访问选 ArrayList:如果需要频繁按索引访问元素或进行中间位置的操作,ArrayList 的 O(1) 随机访问性能远优于 LinkedList。
  3. 避免在 LinkedList 中使用 for(i) 遍历:由于 LinkedList 不支持高效的随机访问,在循环中使用 get(i) 会导致 O(n²) 性能,应使用增强 for 循环或迭代器。
  4. 避免在 LinkedList 中使用二分查找等算法:由于 LinkedList 不支持高效的随机访问,依赖二分查找等需要随机访问的算法性能会退化。

行动清单

  1. 检查点:扫描项目代码,确认没有在 LinkedList 上使用 for (int i = 0; i < list.size(); i++) 模式遍历。
  2. 优化建议:如果列表主要用于头尾操作,考虑使用更专门的 ArrayDeque(基于循环数组的双端队列,性能优于 LinkedList)。
  3. 避坑:LinkedList 遍历时修改结构要用 Iterator.remove()removeIf(),避免 ConcurrentModificationException
  4. 避坑:大数据量场景优先选 ArrayList——不仅省内存,遍历性能也更好(CPU 缓存友好)。
  5. 避坑getFirst() / element() / removeFirst() 在空列表上会抛异常,使用 peek() / poll() 替代更安全。
  6. 扩展阅读:推荐阅读《Effective Java》第3版第65条(接口优先于反射)、第67条(优先使用基本类型而不是装箱基本类型)。