概述
LinkedList 是 Java 集合框架中一个"身份双重"的经典实现:它既是一个基于双向链表、支持按索引访问的 List,也是一个无界双端队列 Deque。正因为底层采用 Node 节点加 first/last 指针的存储模型,LinkedList 在头尾插入删除场景展现出 O(1) 的极致性能,但同时也因内存离散分布、无法随机寻址而付出了遍历成本。本文以 JDK 8 源码为锚点,系统拆解其存储结构、核心操作指针变化、迭代器 fail-fast 机制、序列化设计以及与 ArrayList 的全方位对比,并针对并发陷阱给出安全的替代方案。阅读完本文,你将获得从源码细节到生产级选型的完整认知链条。
- 双向链表的存储结构与内存特征:每个节点持有前后指针,内存离散分布,无扩容开销但节点对象头占用较大。
- 头尾插入/删除的 O(1) 实现机制 :通过
first/last指针和linkFirst/linkLast/unlinkFirst/unlinkLast方法实现常量时间操作。 - 随机访问的二分遍历优化 :
get(index)根据index与size/2的关系决定从头或尾遍历,将平均时间复杂度控制在 O(n/2)。 - 与 ArrayList 的全维度对比:从数据结构、时间复杂度、内存占用、CPU缓存局部性、使用场景等角度进行深度对比。
- fail-fast 迭代器在链表中的落地 :
ListItr通过modCount校验实现 fail-fast,支持双向遍历和remove/set/add操作。 - 非线程安全的原因及替代方案 :并发修改链表指针可导致数据损坏,对比三种线程安全
List方案,推荐并发队列替代品。
全文组织架构说明
上图将全文划分为六大篇章,共 16 个模块,由基础认知逐步深入到源码实现、并发对策,最终汇总性能总结与面试专题。
- Part 1:基础认知篇 --- 奠定概念基石:定义 LinkedList 的核心特性与适用场景,并梳理其接口与继承体系。
- Part 2:存储与构造篇 --- 深入存储层:分析
Node内部类、first/last字段以及构造器与批量添加的源码细节。 - Part 3:核心原理篇 --- 拆解核心操作:通过流程图逐行阐释头尾插入、指定位置插入、删除与查询的指针变化和二分优化。
- Part 4:双端队列与迭代篇 --- 拓展双端队列与迭代:说明 Deque 方法映射、迭代器 fail-fast 机制以及
transient序列化设计。 - Part 5:对比与并发篇 --- 聚焦对比与并发:全方位对比 ArrayList,揭示线程不安全原因并给出三种安全替代方案及并发队列推荐。
- Part 6:总结与面试篇 --- 汇总生产实践与面试:梳理注意事项、性能速查表,并独立归纳 10 个高频面试题及追问、加分回答。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
java.util.LinkedList 是基于双向链表 实现的 List 及 Deque 接口集合类。它允许 null 元素,内部通过静态内部类 Node<E> 维护元素间的关联,并用 first 与 last 变量直接持有头尾引用。
6 大核心特性:
- 双向链表结构 :每个元素都是一个
Node节点,同时拥有prev(前驱)和next(后继)指针。 - 列表与双端队列双接口 :同时实现
List与Deque,既能按索引操作,也能当栈、队列或双端队列使用。 - 头尾操作极致高效 :
addFirst/addLast/removeFirst/removeLast时间复杂度均为 O(1)。 - 禁止随机访问 :
get(int index)必须逐节点遍历,复杂度 O(n),但通过二分方向优化了常数时间。 - 非线程安全 :没有内置同步机制,并发读写会抛出
ConcurrentModificationException甚至造成链表结构损坏。 - 支持克隆与序列化 :实现
Cloneable和Serializable,但序列化时不会直接序列化Node链,而是逐元素写入,以此屏蔽内部结构。
适用与反例场景决策图:
图 1.1 --- 适用场景决策树详细说明
步骤 1:判断是否主要为头尾增删操作
- 若业务逻辑频繁调用
addFirst/addLast/removeFirst/removeLast(如实现栈、FIFO 队列),则 LinkedList 的头尾 O(1) 特性独树一帜。 - 若否,则进入随机访问评估。
步骤 2:线程安全需求(头尾场景)
- 如果需要线程安全,JDK 提供了
ConcurrentLinkedDeque或LinkedBlockingDeque,它们基于 CAS 或锁,避免同步整个链表。 - 如果不需要,LinkedList 即为最佳原生选择。
步骤 3:大量依靠索引随机访问?
get(index)需要折半遍历,当集合较大且索引访问占比高时,性能远不及基于数组的ArrayList,此时应直接选择 ArrayList。- 若索引访问不主要,再检查是否需要 FIFO 队列。
步骤 4:元素可否为 null
- LinkedList 允许
null元素,因此如果业务数据中null有业务含义,可使用 LinkedList 做队列。 - 若不允许
null,则ArrayDeque是更高效的选择,因为它没有节点开销,空间利用率更好。
核心决策点(关键点提炼):
- 头尾操作多 → LinkedList。
- 随机访问多 → ArrayList。
- 并发 + 头尾操作多 → ConcurrentLinkedDeque / LinkedBlockingDeque。
- 纯队列且无 null → ArrayDeque。
模块 2:接口与继承体系
LinkedList 的继承体系展现了"既是 List,也是 Deque"的设计:
图 2.1 --- 接口与继承体系类图说明
分层解读:
- 顶层接口 :
Iterable赋予集合 for-each 遍历能力。 - Collection → List / Queue / Deque :
List提供基于索引的顺序操作,Queue提供单向队列方法,Deque则扩展为双端队列(可在两端插入、移除和检查元素)。 - 抽象骨架类 :
AbstractCollection→AbstractList→AbstractSequentialList。其中AbstractSequentialList最小化了实现List所需的工作量:它只要求子类提供listIterator()和size(),而get、set、add、remove等全部依赖迭代器。 - LinkedList 实现 :直接继承
AbstractSequentialList,同时实现List、Deque、Cloneable和Serializable。这使得 LinkedList 既能按索引遍历,又具备双端队列的全部操作。
关键点提炼:
AbstractSequentialList是顺序访问 List 的骨架,区别于AbstractList的随机访问模型。- 实现
Deque使得 LinkedList 具备了超出 List 的能力(栈、队列、双端操作)。
Part 2:存储与构造篇
模块 3:存储结构与核心字段(源码剖析)
LinkedList 的真实存储单元是内部类 Node<E>:
java
private static class Node<E> {
E item;
Node<E> prev;
Node<E> next;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.prev = prev;
this.next = next;
}
}
LinkedList 自身只保留三个字段:
java
transient int size = 0;
transient Node<E> first;
transient Node<E> last;
结构示意类图:
图 3.1 存储结构类图说明
步骤解读:
LinkedList实例持有first和last引用,若链表为空则为null。Node实例之间通过prev和next形成双向链路:头节点prev == null,尾节点next == null。- 非空链表遍历可从头
first向后,或从last向前。 size记录节点个数,用于边界检查和二分优化。
边界条件分析:
- 空链表 :
first == null && last == null,size == 0。 - 单元素链表 :
first == last,且first.prev == null && first.next == null。 size未使用volatile,表明非线程安全。
模块 4:构造方法与初始化
LinkedList 提供两个构造器,核心在于如何将外部集合转为链表:
java
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
真正的逻辑在 addAll:
java
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
Node<E> pred, succ;
if (index == size) {
succ = null;
pred = last;
} else {
succ = node(index);
pred = succ.prev;
}
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
size += numNew;
modCount++;
return true;
}
Demo 代码 4.1 --- 构造与批量添加展示:
java
import java.util.LinkedList;
import java.util.Arrays;
public class LinkedListConstructionDemo {
public static void main(String[] args) {
// 无参构造
LinkedList<String> list1 = new LinkedList<>();
list1.add("A");
// 通过集合构造
LinkedList<String> list2 = new LinkedList<>(Arrays.asList("B", "C", "D"));
System.out.println(list2); // [B, C, D]
// 批量添加到指定位置
list2.addAll(1, Arrays.asList("X", "Y"));
System.out.println(list2); // [B, X, Y, C, D]
}
}
源码解读要点:
toArray()先获取集合快照,避免混合遍历修改。node(index)定位插入点(succ),并将pred置为其前驱。若index == size,succ = null,pred = last,直接在尾部追加。- 循环创建节点并将
pred不断后移,最后通过pred.next = succ与后续链表连接。 modCount++记录一次结构性修改,供迭代器检测。
Part 3:核心原理篇
模块 5:头尾插入(addFirst / addLast)源码深度剖析
addFirst 和 addLast 分别调用 linkFirst 和 linkLast,它们是最高频的 O(1) 操作。
linkFirst 源码(简化注释):
java
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
f.prev = newNode;
size++;
modCount++;
}
addFirst 指针变化流程图:
prev=null, item=e, next=f"] B --> C["first = newNode"] C --> D{"f == null ?"} D -->|是: 空链表| E["last = newNode"] D -->|否: 非空链表| F["f.prev = newNode"] E --> G["size++ ; modCount++"] F --> G G --> END["结束"]
图 5.1 --- addFirst 流程图层次分明说明
步骤 1:Node f = first
源码对应:final Node<E> f = first;
保存旧的头节点引用,用于后续判定空链表与新节点的链接。
步骤 2:创建 newNode
源码对应:final Node<E> newNode = new Node<>(null, e, f);
新节点的 prev 为 null(因为它将成为新头),next 指向旧的 first。
步骤 3:first = newNode
将 first 字段更新为新节点,完成头部替换。无论链表是否为空,此步均执行。
步骤 4:if (f == null)
- 空链表分支 :
f为null,说明此时链表无元素,因此last也必须指向同一个新节点newNode,使得链表仅有一个节点。 - 非空分支 :
f.prev = newNode,将旧头节点的前驱指针指向新节点,完成双向链接。
步骤 5:size++ ; modCount++
记录元素个数与结构修改次数。
边界条件分析:
- 空链表 :
first和last同时指向newNode,newNode.prev和newNode.next皆为null。 - 单元素链表 :执行
addFirst后,新头节点的next指向原单节点,原单节点的prev指向新头。last仍指向原单节点。 - 多次 addFirst 仅改变
first,last自第一次插入后保持不变。
linkLast 是镜像操作,只需将上述逻辑中的 first 换为 last,next / prev 方向对调。
模块 6:指定位置插入(add(int index, E element))源码剖析
add(index, element) 涉及结点定位与 linkBefore,时间复杂度 O(n)。
java
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
node(index) 二分方向优化:
java
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;
}
}
linkBefore 实现:
java
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++;
}
插入全流程流程图:
图 6.1 --- add(index, element) 流程图详解
分步骤解读:
1. 索引范围检查
checkPositionIndex(index) 确保 0 <= index <= size,否则抛出 IndexOutOfBoundsException。
2. index == size 快速路径
若 index == size,等价于在尾部插入,直接走 linkLast,避免不必要的节点定位。
3. node(index) 二分决策
- 核心优化 :比较
index与size >> 1(即size/2),选择靠近目标的一端开始遍历。 - 前向遍历 :
Node<E> x = first; for (int i = 0; i < index; i++) x = x.next;循环结束条件为i == index,返回第index个节点。 - 后向遍历 :
Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev;反向走直到i == index。
4. linkBefore 插入
- pred = succ.prev:取得原位置节点的前驱。
- 新节点构造 :
newNode(pred, e, succ),其prev指向pred,next指向succ。 - succ.prev = newNode:将后置节点的前驱指针连接到新节点。
- pred == null 分支 :若
succ原本为头节点(pred == null),则first需要更新为新节点;否则pred.next = newNode。
关键点提炼:
- 二分方向使遍历步骤数最多为
size/2,但仍为 O(n)。 - 插入指针操作与
linkFirst/linkLast类似,核心是维护pred与succ的相邻关系。
边界条件分析:
- index == 0 :
succ为原first,pred == null,因此first会指向新节点,新节点成为新头。 - index == size :走
linkLast分支,此时last指向新尾节点。 - 单元素链表 :在
index 0插入前元素,转变为双元素;node(1)返回尾节点,可正常插入为第二个元素。
模块 7:删除操作(removeFirst / removeLast / remove(Object))源码剖析
删除操作的精华在于 unlink 系列方法,它们将节点从链中摘除并置空引用以帮助 GC。
removeFirst 与 unlinkFirst:
java
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; // help GC
first = next;
if (next == null)
last = null;
else
next.prev = null;
size--;
modCount++;
return element;
}
remove(Object) 与通用 unlink:
java
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;
}
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> prev = x.prev;
final Node<E> next = x.next;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
unlink 节点摘除流程图:
prev = x.prev
next = x.next"] A --> B{"prev == null ?"} B -->|是 头节点| C["first = next"] B -->|否 非头节点| D["prev.next = next
x.prev = null"] C --> E{"next == null ?"} D --> E E -->|是 尾节点| F["last = prev"] E -->|否 非尾节点| G["next.prev = prev
x.next = null"] F --> H["x.item = null"] G --> H H --> I["size--, modCount++"] I --> END["返回 element"]
图 7.1 --- unlink 流程图详细说明
步骤分解对应源码:
-
获取变量 :
element = x.item; prev = x.prev; next = x.next;备份节点数据及前后指针,后续操作将基于这些引用修改链表结构。
-
处理前驱 (prev == null) 分支
- 是头节点 :
first = next,链表头直接后移,丢弃当前节点。 - 非头节点 :
prev.next = next,前驱的next跨过当前节点,直接指向当前节点的next;随后x.prev = null断开前驱引用,帮助 GC。
- 是头节点 :
-
处理后继 (next == null) 分支
- 是尾节点 :
last = prev,链尾前移。 - 非尾节点 :
next.prev = prev,后继的prev跨过当前节点指向prev;同时x.next = null断开后继引用。
- 是尾节点 :
-
清理与维护
x.item = null释放元素引用;size--更新大小;modCount++记录结构修改。
关键点提炼:
- 指针修改的顺序保证链表不出现断口:先链接前后节点,再断开当前节点的指针对 GC 友好。
- 删除操作始终返回被删元素,供调用者使用。
边界条件分析:
- 删除唯一节点 :
prev == null && next == null,执行后first = null,last = null,链表回到空状态。 - 删除头节点 (但非唯一):
prev == null,first更新为原第二节点,next.prev被置null。 - 删除尾节点 (但非唯一):
next == null,last更新为原倒数第二节点,prev.next被置null。
remove(Object) 特别逻辑:
- 分开处理
o == null和o != null,避免在equals()中出现空指针。 - 遍历定位后仅移除第一个匹配项,若需全删需外部循环。
模块 8:查询操作(get / indexOf)源码剖析
get 实现完全依赖 node(index):
java
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
get 流程图与二分方向决策:
for(i=0; i
for(i=size-1; i>index; i--) x=x.prev"] FORWARD --> RET["return x.item"] BACKWARD --> RET
图 8.1 --- get 流程图说明
执行步骤:
checkElementIndex(index)确保index在[0, size-1]范围内。- 二分判断
index位于前半段还是后半段:- 前半段从
first出发,循环index次到达目标节点。 - 后半段从
last出发,反向移动(size-1-index)步。
- 前半段从
- 返回
x.item。
关键点:
- 即使二分优化,最坏仍需
size/2次指针追踪。 - 没有像 ArrayList 的随机访问优化,因其元素内存不连续,无法通过偏移量直接计算地址。
indexOf 源码片段:
java
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;
}
从头至尾线性遍历,时间复杂度 O(n),且不做二分优化,因为无法预测目标位置。
Part 4:双端队列与迭代篇
模块 9:Deque 接口实现------栈与队列操作
LinkedList 实现了 Deque,因此所有栈、队列、双端操作方法均映射到内部 add/remove 系列方法。
方法映射关系图:
图 9.1 --- Deque 方法映射层次说明
分步骤解读:
- 栈 (LIFO) :
push()调用addFirst(),将元素压入顶部;pop()调用removeFirst()移除顶部元素。符合栈的"后进先出"语义。 - 队列 (FIFO) :
offer()内部调用addLast()入队到尾部;poll()调用unlinkFirst()从头部出队;peek()访问头部元素但不移除。 - 双端队列 :
offerFirst/offerLast分别映射addFirst/addLast;pollFirst/pollLast映射removeFirst/removeLast。 - 恒为 O(1) :由于 LinkedList 无容量限制,
offer系方法永远不会返回false;poll在空表时返回null,而remove抛出异常。
为何做栈/队列时推荐 ArrayDeque 而非 LinkedList?
- 内存开销:ArrayDeque 基于循环数组,无额外 Node 对象头,空间节约、GC 压力小。
- 缓存局部性:数组元素连续存放,CPU 缓存命中率高;LinkedList 节点散落,每次访问可能 cache miss。
- 不允许 null :ArrayDeque 禁止
null元素,若业务数据包含 null 才需 LinkedList。 - 结论:除特殊情况(null 元素、需要 List 接口)外,对于栈和队列,ArrayDeque 是更快更省内存的选择。
模块 10:迭代器深度剖析------fail-fast 与链表遍历
LinkedList 的迭代器由内部类 ListItr 实现,支持双向遍历和在迭代过程中的增删改。
核心字段(简写):
java
private class ListItr implements ListIterator<E> {
private Node<E> lastReturned; // 刚返回的节点
private Node<E> next; // 下一次 next() 返回的节点
private int nextIndex; // next 的索引
private int expectedModCount = modCount;
...
}
next() 与 remove() 协同时序图:
next = next.next
nextIndex++ ListItr-->>Client: element Client->>ListItr: remove() ListItr->>ListItr: checkForComodification() alt lastReturned == null ListItr-->>Client: 抛 IllegalStateException end ListItr->>LinkedList: unlink(lastReturned) LinkedList-->>ListItr: void ListItr->>ListItr: if (next == lastReturned)
next = lastReturned.next
else nextIndex--
lastReturned = null
expectedModCount = modCount ListItr-->>Client: 完成删除
图 10.1 --- 迭代器 next/remove 交互说明
分步骤详细解析:
next() 流程
checkForComodification()比对modCount与expectedModCount,若不等立即抛出ConcurrentModificationException。- 获取
next指向的节点,赋给lastReturned。 next后移至next.next,nextIndex++。- 返回
lastReturned.item。
remove() 流程
checkForComodification()再次检查并发修改。- 若
lastReturned == null,表明调用者未先调用next()或previous(),或已重复调用remove(),直接抛出IllegalStateException。 - 调用
LinkedList.this.unlink(lastReturned)执行底层节点移除。此时modCount会自增。 - 同步迭代器状态 :
- 若
next == lastReturned(例如刚刚previous()后就remove()),则next需后移到lastReturned.next。 - 否则
nextIndex--(因为删除了next前面的元素)。 - 置
lastReturned = null,防止重复删除。 - 将
expectedModCount更新为新的modCount,使得接下来迭代器操作不再抛异常。
- 若
关键点提炼:
- fail-fast 机制 :任何直接调用集合的结构性修改(add/remove 等)都会递增
modCount,导致所有活跃迭代器的expectedModCount不匹配,从而在下一次迭代器操作时抛出异常。 - 迭代器自身 remove/add 在修改集合后会同步
expectedModCount = modCount,因此合法修改不会导致自身抛异常。 - 单向遍历陷阱 :如果在
for-each(本质是迭代器)中使用list.remove()会立即抛出ConcurrentModificationException。
边界条件分析:
- 空链表 :
hasNext()返回 false,调用next()抛出NoSuchElementException。 - 单元素迭代 :调用
next()返回唯一元素,之后hasNext()变为 false;若紧接着remove(),链表变空,first == last == null。
模块 11:序列化与 transient 设计
LinkedList 的 first 与 last 字段标记为 transient,序列化时并非直接保存整个节点图,而是采用 按元素逐步写入 的策略。
java
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
s.defaultWriteObject(); // 写入 size 等非 transient 字段
for (Node<E> x = first; x != null; x = x.next) {
s.writeObject(x.item);
}
}
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject(); // 读取 size
int size = s.readInt(); // 实际上是读取了 size,但这里源码直接循环 readObject
for (int i = 0; i < size; i++)
linkLast((E) s.readObject());
}
序列化/反序列化时序图:
图 11.1 --- 序列化流程说明
分步骤对应:
- 序列化开始 :
ObjectOutputStream调用LinkedList.writeObject。 - 写入非 transient 字段 :
s.defaultWriteObject()将size写入流(size并非 transient,注意first/last是 transient,不写入)。 - 逐元素写入 :从
first出发遍历每个节点,s.writeObject(x.item)仅写入数据本身,忽略prev/next指针。 - 反序列化 :
readObject()先通过defaultReadObject()恢复size,然后循环size次,依次linkLast重建链表。
设计意图:
- 跨平台兼容 :序列化结果仅包含元素顺序,不暴露内部
Node结构,未来版本改变内部实现不会破坏序列化兼容性。 - 节省空间 :避免将
prev/next指针写入流,尤其在分布式环境中减少传输数据量。 - 安全性:防止序列化攻击利用底层链表结构。
边界条件:
- 空链表 :
size == 0,writeObject仅写入 size,循环不执行;readObject同样不执行linkLast,恢复后链表为空。
Part 5:对比与并发篇
模块 12:LinkedList 与 ArrayList 全方位对比
两者在底层数据结构、性能开销和适用场景上截然相反。
操作时间复杂度对比:
| 操作 | ArrayList | LinkedList |
|---|---|---|
| get(int) | O(1) | O(n) |
| add(E)(尾部) | 均摊 O(1) | O(1) |
| add(int, E) | O(n)(系统数组拷贝) | O(n)(定位) |
| remove(int) | O(n) | O(n) |
| remove(Object) | O(n) | O(n) |
| addFirst/removeFirst | 需拷贝,O(n) | O(1) |
内存与缓存特性:
- ArrayList :内部
Object[]连续存储,只有引用类型时每个元素占一个引用宽度;扩容时产生一次性拷贝开销;对 CPU 缓存极其友好。 - LinkedList:每个元素额外需要两个指针(共 24 字节开销,各 JVM 有差异),且节点分散在堆各处,导致迭代过程中 CPU cache miss 明显增加。
选型决策树:
图 12.1 --- 选型决策树详细说明
决策步骤解读:
- 步骤 1:大量索引随机访问 → 直接选 ArrayList,LinkedList 的 O(n) 访问不可接受。
- 步骤 2:增删集中在头部或尾部 → 若同时需要 null 或 List 接口功能,选 LinkedList;否则 ArrayDeque 性能更佳。
- 步骤 3:中间位置频繁插入删除 → 需权衡尺寸。链表定位虽慢,但数组拷贝的代价随元素数量线性增长。当元素极多(如 100 万级),拷贝整个数组的成本可能高于链表遍历;但一般业务中,ArrayList 的 System.arraycopy 实现极其高效,多数场景仍表现良好。
- 反例:绝不可在大量随机访问场景使用 LinkedList。
关键点提炼:
- 确定性场景:读多写少、按索引访问 → ArrayList;频繁头尾增删、无随机访问 → LinkedList / ArrayDeque。
- 混合场景建议通过实际压测决定,不唯理论复杂度。
模块 13:并发问题与线程安全方案
非线程安全的根本原因:
LinkedList 所有修改操作均未采用同步手段。多个线程同时修改 first/last 或内部节点指针可能导致:
- 节点丢失 :两个线程同时执行
addFirst,可能互相覆盖first指针,导致之前插入的节点脱离链表。 - 链表环路 :指针更新交叉可能导致
next/prev形成回路,引发遍历死循环。 - size 不一致:非原子递增导致元素个数与实际节点数不符。
- fail-fast 抛出 :并发修改会触发
ConcurrentModificationException。
多线程并发 addFirst 故障示意:
读取 first=f (null) T2->>LIST: addFirst("B")
读取 first=f (null)
newNodeB, first=newNodeB
f==null, last=newNodeB Note over LIST: 此时 first/ last 均指向节点B T1->>LIST: 创建newNodeA (prev=null,next=null)
first=newNodeA, f==null → last=newNodeA Note over LIST: first 指向 A, last 指向 A
节点 B 丢失,且无引用
图 13.1 --- 多线程故障说明
层次说明:
- Thread-1 和 Thread-2 同时进入
addFirst,均获得当前first == null。 - Thread-2 率先完成
linkFirst("B"),first与last都指向节点 B。 - Thread-1 继续执行,重新将
first与last指向节点 A,节点 B 丢失。 - 这就是典型竞态条件,最终链表被破坏。
三种线程安全 List 对比:
| 实现 | 同步策略 | 迭代器特性 | 适用场景 |
|---|---|---|---|
Vector |
方法级 synchronized |
较旧,枚举器不 fail-fast | 遗留系统兼容,不推荐新代码 |
Collections.synchronizedList(new LinkedList()) |
同步包装器,每个方法内在 mutex 上同步 | 迭代器需要外部同步 | 低并发替代方案 |
CopyOnWriteArrayList |
写时复制整个数组,读无锁 | 迭代器不受修改影响,弱一致性 | 读多写少,元素总量不大 |
为何不推荐用同步包装的 LinkedList 做并发队列:
- 粗粒度锁 :
synchronizedList每个操作都持有同一个锁,并发吞吐量极低,尤其迭代期间锁定整个集合。 - 复合操作风险 :非原子操作(如
if(!list.isEmpty()) list.removeFirst())需额外同步,否则竞态依然存在。 - 专业并发队列替代 :
ConcurrentLinkedQueue(无界非阻塞)和LinkedBlockingQueue(有界阻塞)采用 CAS 或双锁设计,支持更高并发且天然保证线程安全。若需双端队列,有ConcurrentLinkedDeque和LinkedBlockingDeque。
加分建议:
- 多数并发队列场景无需 List 接口,直接使用
Queue/Deque的专业实现即可获得极致性能。 - 若必需 List 语义且读多写少,
CopyOnWriteArrayList是更好的线程安全选择,但写开销极大。
Part 6:总结与面试篇
模块 14:注意事项与最佳实践
反面案例 1:索引循环随机访问
❌ for (int i = 0; i < list.size(); i++) { list.get(i); }
每次 get 都从头/尾遍历,时间复杂度 O(n²)。
✅ 使用增强 for 或 Iterator,全程只需一次顺序遍历。
反面案例 2:for-each 中直接删除
❌ for (String s : list) { if (s.equals("del")) list.remove(s); }
抛出 ConcurrentModificationException。
✅ 显式使用迭代器:Iterator<String> it = list.iterator(); while(it.hasNext()){ if(it.next().equals("del")) it.remove(); }
反面案例 3:同步要求下裸用 LinkedList
❌ 多线程共享一个 LinkedList,无任何同步机制。
✅ 使用 Collections.synchronizedList 并记得迭代时同步锁定,或直接改用并发集合。
最佳实践摘要:
- 遍历:永远用迭代器或增强 for。
- 批量导入 :
addAll优于循环add,减少定位次数。 - 栈/队列选择 :优先
ArrayDeque,除非需要 null 或 List 索引功能。 - 删除条件复杂时 :使用
removeIf(JDK8+)或Iterator。 - 序列化:注意 transient 字段,避免直接序列化 Node 导致版本兼容问题。
模块 15:性能总结与选型建议
时间复杂度速查表:
| 方法 | 时间复杂度 | 备注 |
|---|---|---|
| addFirst / addLast | O(1) | 直接调整指针 |
| removeFirst / removeLast | O(1) | 直接摘除头尾节点 |
| get(int) | O(n) | 二分方向优化,平均 n/4 |
| add(int, E) | O(n) | 定位 O(n),插入 O(1) |
| remove(int) | O(n) | 定位 + 摘除 |
| indexOf / remove(Object) | O(n) | 全链表遍历 |
| 迭代器 next/remove | O(1) / O(1) | 仅修改指针 |
选型边界:
- 当 集合规模较小(< 1000)且操作混合时,ArrayList 和 LinkedList 实际性能差异常可忽略,但 ArrayList 内存占用更优。
- 当 头尾操作频率占比超过 80%,且不涉及索引查找,LinkedList 价值凸显。
- 当 内存压力大且元素数量庞大,LinkedList 的节点开销可能成为致命伤。
模块 16:面试高频专题
以下精选 10 个必考问题,均附带标准回答、追问及加分回答。
1. LinkedList 底层使用什么数据结构?
标准回答:
底层采用双向链表,内部定义静态内部类 Node<E>,包含元素 item 以及前驱 prev 和后继 next 指针。LinkedList 自身维护 first 和 last 两个引用分别指向头尾节点。
追问:为什么选择双向链表而非单向链表?
- 单向链表无法在 O(1) 时间内删除尾部节点,因为需要遍历找到前驱。双向链表可通过
last.prev直接定位,实现双端队列的高效操作。
加分回答:
AbstractSequentialList 的设计天然要求支持双向遍历的 ListIterator。单向链表无法高效实现 hasPrevious() 和 previous(),因此不能用于满足 List 接口的全部契约。
2. addFirst 与 addLast 如何保证 O(1)?
标准回答:
addFirst 调用 linkFirst,只需新建节点、重新赋值 first 指针,若原链表为空同步更新 last,否则将原头节点的 prev 指向新节点。不涉及遍历,所以是 O(1)。addLast 镜像操作。
追问:如果是空链表,addFirst 会影响 last 吗?
- 会。
linkFirst中if (f == null) last = newNode;保证插入第一个节点后,first与last指向同一节点。
加分回答:
可以对比 ArrayList 的 add(0, e),需要拷贝整个数组,复杂度 O(n),体现了链表在头部插入的绝对优势。
3. LinkedList 随机访问为什么慢?
标准回答:
get(index) 通过 node(index) 方法从头或尾逐个遍历直到目标位置,时间复杂度 O(n)。即使通过 size >> 1 进行二分方向优化,也只是将常数因子减半,无法改变线性本质。
追问:CPU 缓存对遍历的影响?
- ArrayList 的数组连续存储,空间局部性好,CPU 能预取缓存行;LinkedList 节点内存分散,每一次指针跳转都可能触发 cache miss,实际性能差异比理论更大。
加分回答:
在存在大量随机访问的场合,即使 LinkedList 实现了 List 接口,也应视为顺序访问集合,否则性能会急剧下降。
4. ArrayList 和 LinkedList 的使用场景有何区别?
标准回答:
- ArrayList 适合频繁随机读取、尾部追加场景,内存连续、缓存友好。
- LinkedList 适合频繁头尾增删、无需索引访问的场景,也可用于实现栈和队列。
追问:中间插入何时 LinkedList 可能更优?
当元素数量极大(百万级),ArrayList 的数组拷贝成本可能高于链表遍历加指针修改的开销,但一般需实际压测确定。
加分回答:
实际开发中,ArrayList 的覆盖场景远广于 LinkedList,后者常用于算法题(如 LRU 缓存、约瑟夫环)或因特殊 Deque 需求。
5. LinkedList 如何实现栈和队列?
标准回答:
LinkedList 实现了 Deque 接口。
- 作为栈:
push()映射addFirst(),pop()映射removeFirst()。 - 作为队列:
offer()映射addLast(),poll()映射removeFirst()。
全部操作 O(1)。
追问:用它做队列有什么缺点?
内存开销大,不支持界满阻塞,不适合高并发生产者-消费者模型。应使用 ArrayDeque(单机高效)或 LinkedBlockingQueue(并发阻塞)。
加分回答:
常规栈和队列首选 ArrayDeque,其循环数组节省空间且 CPU 友好,仅当业务数据包含 null 或必须使用 List 接口时才回退到 LinkedList。
6. 迭代器 fail-fast 是如何实现的?
标准回答:
LinkedList 维护 modCount 字段,每次结构性修改都会递增。迭代器创建时记录 expectedModCount = modCount。任何操作前调用 checkForComodification() 比对两者,若不匹配立即抛出 ConcurrentModificationException。
追问:迭代器自身删除为什么不会抛异常?
迭代器的 remove() 在调用 unlink 后会更新 expectedModCount = modCount,使之与集合的最新修改计数一致。
加分回答:
fail-fast 行为仅能尽力检测并发修改,不能保证在所有情况下都正确抛异常,因此不能将其作为并发控制手段。必须通过同步机制保证线程安全。
7. LinkedList 是线程安全的吗?如何实现线程安全的 List?
标准回答:
LinkedList 完全非线程安全。可通过 Collections.synchronizedList(new LinkedList<>()) 获取同步包装器,所有方法加入互斥锁。但迭代期间仍需手动同步。
追问:Vector 和同步包装器区别?
Vector 是遗留类,方法自带 synchronized;同步包装器使用内部 mutex,更灵活。但两者都是粗粒度锁,并发性能差。
加分回答:
更现代的选择是根据场景使用 CopyOnWriteArrayList(读多写少)或直接改用并发队列/双端队列如 ConcurrentLinkedDeque,它们采用无锁 CAS 等机制,显著提升并发吞吐。
8. 序列化时为什么 first/last 用 transient?如何重新还原链表?
标准回答:
first 和 last 被标记为 transient,避免序列化整个 Node 图。writeObject 仅循环写入每个元素的 item;readObject 在反序列化时读取元素并依次调用 linkLast 重建链表,保证了版本兼容和流格式简洁。
追问:如果误使用默认序列化会有什么后果?
可能序列化大量冗余指针信息,且当 JDK 内部 Node 结构变化时,不同版本间无法兼容反序列化。
加分回答:
size 并未标记 transient,因此可由 defaultWriteObject 直接写入流,为反序列化时的循环次数提供依据,减少了额外写入长度的需要。
9. remove(Object) 删除元素具体流程?
标准回答:
从 first 开始依次遍历节点,对 null 使用 ==、对非 null 使用 equals 匹配。找到第一个匹配节点后调用 unlink(x) 将其从链表摘除,x.prev 与 x.next 重新互联,置空 x.item 并更新 first/last 如需要,最后 size--、modCount++。
追问:如果链表中有多个相同对象,remove 会全部删除吗?
不会,仅移除第一个匹配节点。如需全删,应使用 removeIf 或 while(list.remove(obj))。
加分回答:
unlink 的指针操作顺序经过精心设计,先链接前后节点、再断开自身指针,有效防止遍历过程中出现断链;同时将 x.item 置 null 帮助 GC 快速回收大对象。
10. 什么情况下你会坚持使用 LinkedList?
标准回答:
- 需要频繁在 List 头部插入或删除元素。
- 需要同时使用 List 与 Deque 的功能,且不引入额外类。
- 数据集中包含
null而其他双端队列(如 ArrayDeque)不支持。
追问:能举出一个典型应用吗?
LRU 缓存的简单实现中,需要将最近访问的元素移至头部并淘汰尾部(removeLast),LinkedList 的 remove(Object) + addFirst 完美匹配,时间复杂度均可接受。
加分回答:
在不需要随机访问且集合生命周期中频繁发生结构性变化的场景,LinkedList 避免数组复制的峰值延时,有利于控制 GC 停顿(无大数组一次性回收)。但实际选型仍需根据 JVM 行为和服务需求权衡。