概述
在 Java 集合框架的 Map 家族中,LinkedHashMap 占据着一个独特而精妙的生态位。它直接继承自 HashMap,不仅完整继承了哈希表近乎 O(1) 的高效存取性能,更在其内部编织了一张贯穿所有节点的双向链表,从而在混沌的哈希桶之外,维护了一个可预测的迭代顺序。 这种设计使得 LinkedHashMap 成为连接“无序效率”与“有序需求”之间的完美桥梁,无论是需要按插入顺序遍历的业务场景,还是实现经典的 LRU(最近最少使用)缓存策略,它都是不二之选。本文将深入 JDK 8 源码,层层剥开 LinkedHashMap 通过重写钩子方法来无侵入地维护双向链表的精巧设计,彻底揭示其顺序维护的底层奥秘。
- 继承 HashMap 的双向链表扩展:LinkedHashMap 并非另起炉灶,而是通过继承 HashMap 并重写
newNode等关键方法,将哈希桶中的普通节点替换为包含before/after指针的Entry,巧妙地构建了一个贯穿全部节点的双向链表。 - 两种顺序模式:通过
accessOrder字段,LinkedHashMap 支持两种迭代顺序——插入顺序(默认)保持元素首次被put的顺序;访问顺序(accessOrder=true)则在每次get或put更新时,将访问的元素移至链表尾部,为 LRU 缓存奠定基础。 - 钩子方法体系:LinkedHashMap 的优雅之处在于,它仅仅通过重写 HashMap 预留的
afterNodeAccess、afterNodeInsertion、afterNodeRemoval三个回调钩子,就实现了对双向链表的全生命周期管理,对put/get/remove等核心操作毫无侵入性。 - LRU 缓存的天然支持:通过结合
accessOrder=true并重写removeEldestEntry方法,开发者可以极其简洁地实现一个固定容量、自动淘汰最久未使用元素的缓存,这是 LinkedHashMap 最经典的应用模式。 - 性能与内存的开销:其所有基本操作(
put、get、remove)的平均时间复杂度与 HashMap 一致,均为 O(1)。但作为代价,每个节点需额外维护两个指针引用,带来了少量的内存开销和链表维护的微小时间成本。
本文将按以下架构展开:
graph TD
subgraph Part1["Part1 基础认知篇"]
M1["模块1 定义 核心特性与适用场景"]
M2["模块2 接口与继承体系"]
end
subgraph Part2["Part2 存储与构造篇"]
M3["模块3 存储结构与核心字段"]
M4["模块4 构造方法"]
end
subgraph Part3["Part3 核心原理篇"]
M5["模块5 双向链表的维护 钩子方法体系"]
M6["模块6 插入顺序模式下的 put 与遍历"]
M7["模块7 访问顺序模式与 LRU 实现"]
M8["模块8 get 与 remove 操作"]
end
subgraph Part4["Part4 迭代与序列化篇"]
M9["模块9 迭代器 基于链表的顺序遍历"]
end
subgraph Part5["Part5 对比与陷阱篇"]
M10["模块10 vs HashMap vs TreeMap"]
M11["模块11 常见陷阱与最佳实践"]
end
subgraph Part6["Part6 总结与面试篇"]
M12["模块12 注意事项与性能总结"]
M13["模块13 面试高频专题"]
end
Intro["概述"] --> Part1
Part1 --> Part2
Part2 --> Part3
Part3 --> Part4
Part4 --> Part5
Part5 --> Part6
图表说明: 这张 LinkedHashMap 深度解析全景架构图 清晰地展示了本文的六大篇章及十三个核心模块的递进关系。
- 第一层:Part 1 基础认知篇
- 作为文章的起点,本篇章旨在为读者建立对 LinkedHashMap 的宏观认知。模块 1 通过定义、特性、适用与不适用场景的决策树,精准界定其定位。模块 2 则从类继承体系出发,揭示其与 HashMap 的父子关系,为后续的源码分析打下“它是谁”的基础。
- 第二层:Part 2 存储与构造篇
- 在有了宏观认知后,本篇章深入到 LinkedHashMap 的内部。模块 3 将重点剖析其存储单元
Entry和核心字段head/tail/accessOrder,并用类图展示其结构。模块 4 则讲解如何通过构造器来配置这些核心字段,特别是accessOrder,决定其行为模式。
- 在有了宏观认知后,本篇章深入到 LinkedHashMap 的内部。模块 3 将重点剖析其存储单元
- 第三层:Part 3 核心原理篇
- 这是全文的心脏地带,我们将深度结合 JDK 8 源码,逐步拆解 LinkedHashMap 最核心的钩子方法体系。模块 5 会总览性地介绍三个核心钩子。模块 6 和 模块 7 分别聚焦于“插入顺序”和“访问顺序”这两种模式下,
put、get等操作如何与钩子方法联动以维护链表。模块 8 则专注于get和remove操作本身。
- 这是全文的心脏地带,我们将深度结合 JDK 8 源码,逐步拆解 LinkedHashMap 最核心的钩子方法体系。模块 5 会总览性地介绍三个核心钩子。模块 6 和 模块 7 分别聚焦于“插入顺序”和“访问顺序”这两种模式下,
- 第四层:Part 4 迭代与序列化篇
- 本篇章视角转向数据输出。模块 9 将深入迭代器源码,揭示 LinkedHashMap 如何绕过哈希表的随机性,直接利用其维护的双向链表进行可预测的、高效的顺序遍历。
- 第五层:Part 5 对比与陷阱篇
- 在理解原理后,本章将 LinkedHashMap 放在更广阔的技术生态中进行横向与纵向审视。模块 10 通过一个选型决策树,全方位对比它与 HashMap、TreeMap 的差异。模块 11 则结合实践,集中剖析开发者在使用中可能落入的陷阱,并提供最佳实践。
- 第六层:Part 6 总结与面试篇
- 这是文章的收尾与升华。模块 12 进行性能与内存的总结,给出最终的场景推荐。模块 13 作为独立专题,将前文所有技术核心提炼为可以被考核的面试问题,并从标准回答到加分回答进行分层梳理,实现从技术原理到工程实践的闭环。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
1.1 是什么?
LinkedHashMap 是 Java 集合框架中,直接继承自 java.util.HashMap 的一个 Map 实现类。它在拥有 HashMap 所有特性的基础上,通过内部维护一个贯穿所有节点的双向链表,保证了迭代顺序的可预测性。 这种顺序可以是元素首次插入的顺序,也可以是元素最近被访问的顺序(从最老到最新)。它是线程不安全的,允许一个 null 键和多个 null 值。
1.2 核心特性列表
- 可预测的迭代顺序:这是 LinkedHashMap 存在的根本意义。遍历 Map 时,元素的输出顺序是确定的,不再是 HashMap 那种依赖于哈希桶分布的伪随机顺序。
- 双重顺序模式:
- 插入顺序(默认):迭代顺序与元素被
put到 Map 中的先后顺序一致。重复put已存在的 Key 不会改变其在链表中的位置。 - 访问顺序:通过构造参数
accessOrder=true启用。在此模式下,每次对某个元素的get或put(更新操作)访问,都会将该元素移动到内部双向链表的尾部。这使得链表头部始终是最久未被访问或插入的元素。
- 插入顺序(默认):迭代顺序与元素被
- 基本操作性能:
put、get、remove、containsKey等核心操作的时间复杂度与 HashMap 一致,平均为 O(1)。额外的链表维护操作是常量时间的指针调整,不影响整体数量级。 - Null 值支持:Key 和 Value 均可为
null,行为与 HashMap 一致。 - 非线程安全:其所有方法均未做同步处理。若需在多线程环境下使用,必须使用
Collections.synchronizedMap(new LinkedHashMap(...))进行包装。 - Fail-Fast 机制:迭代器在遍历过程中,如果 Map 结构被非迭代器自身的方法修改,会立即抛出
ConcurrentModificationException。
1.3 适用场景决策树
下图展示了一个关于何时选择 LinkedHashMap 的决策流程:
graph TD
Start["需要 Map 存储键值对"] --> Q1{"是否需要可预测的迭代顺序?"}
Q1 -->|"否"| UseHashMap["使用 HashMap 关注极致性能 无需顺序"]
Q1 -->|"是"| Q2{"需要哪种顺序?"}
Q2 -->|"自然排序或自定义排序"| UseTreeMap["使用 TreeMap 关注键的排序"]
Q2 -->|"插入顺序或访问顺序"| Q3{"是否是实现缓存?"}
Q3 -->|"否,只是按插入顺序输出"| UseLinkedHashMap_Insert["使用 LinkedHashMap 默认插入顺序 配置解析 数据归档等"]
Q3 -->|"是,需要淘汰旧数据"| Q4{"淘汰策略是什么?"}
Q4 -->|"最近最少使用"| UseLinkedHashMap_LRU["使用 LinkedHashMap 访问顺序 并重写 removeEldestEntry"]
Q4 -->|"其他复杂策略"| CustomCache["考虑自定义缓存方案 或使用 Caffeine 或 Guava Cache"]
图表说明: 这张 LinkedHashMap 适用场景决策树 帮助您在 Map 选型时做出准确判断。
- 第一层:
需要 Map 存储键值对:这是我们的起点,明确基本的数据结构需求。 - 第二层:
是否需要可预测的迭代顺序?- 这是区分 HashMap 与“有序 Map”(LinkedHashMap, TreeMap)的核心分水岭。如果答案是否定的,直接选择 HashMap,因为它没有任何维护顺序的开销,性能最纯粹。
- 第三层:
需要哪种顺序?- 这一步在“有序 Map”内部做选择。如果需要的是基于 Key 的自然顺序或自定义比较器排序,那么 TreeMap 是唯一选择。如果需求是插入顺序或访问顺序,则进入 LinkedHashMap 的决策分支。
- 第四层:
是否是实现缓存?- 这一步进一步细分 LinkedHashMap 的使用模式。如果不是用于缓存,仅仅是为了按插入顺序遍历,那么使用默认构造的 LinkedHashMap 即可。
- 如果是用于缓存,则接着判断淘汰策略。LinkedHashMap 原生支持 LRU(最近最少使用) 策略,通过
accessOrder=true和重写removeEldestEntry可轻松实现。对于 LFU、基于时间过期等更复杂的策略,则需考虑第三方缓存库。
模块 2:接口与继承体系
LinkedHashMap 的设计严格遵循了“对扩展开放,对修改关闭”的原则。它没有重复造轮子去实现哈希算法,而是聪明地站在了 HashMap 的肩膀上。
2.1 继承关系图
classDiagram
class Map {
<<interface>>
+put(K V)
+get(Object)
+remove(Object)
+size()
}
class AbstractMap {
<<abstract>>
+toString()
+clone()
}
class HashMap {
+put(K V)
+get(Object)
+remove(Object)
+size()
#Node[]
#newNode(int K V Node)
#afterNodeAccess(Node)
#afterNodeInsertion(boolean)
#afterNodeRemoval(Node)
}
class LinkedHashMap {
-Entry head
-Entry tail
-boolean accessOrder
+LinkedHashMap()
+LinkedHashMap(int float boolean)
+get(Object)
+newNode(int K V Node)
+afterNodeAccess(Node)
+afterNodeInsertion(boolean)
+afterNodeRemoval(Node)
-linkNodeLast(Entry)
}
class Entry {
-Entry before
-Entry after
}
class Node {
-int hash
-K key
-V value
-Node next
}
Map <|.. AbstractMap
AbstractMap <|-- HashMap
HashMap <|-- LinkedHashMap
Node <|-- Entry
HashMap --> Node
LinkedHashMap --> Entry
图表说明: 这张 LinkedHashMap 核心继承与内部结构类图 揭示了其代码复用和扩展的两种主要方式:继承关系和内部节点扩展。
-
第一层:继承体系
Map接口:定义了所有 Map 应遵守的行为契约。AbstractMap抽象类:提供 Map 接口的骨架实现,如toString、clone等公共方法。HashMap类:实现哈希表的核心逻辑,包括桶数组table、哈希算法、put/get/resize等复杂处理。同时,它预留了三个重要的钩子方法:afterNodeAccess、afterNodeInsertion、afterNodeRemoval,供子类覆写。LinkedHashMap类:作为 HashMap 的子类,它几乎全盘复用了父类的数据存取和哈希计算的逻辑,但通过重写预留的钩子方法和newNode方法,无缝地插入了双向链表维护的行为。
-
第二层:内部节点扩展体系
HashMap.Node类:哈希桶链表的基础节点,包含hash、key、value和用于解决哈希冲突的next单向指针。LinkedHashMap.Entry类:它继承自HashMap.Node,并在其基础上增加了before和after两个双向指针。这个继承设计至关重要,它意味着:- 数据结构映射:在内存中,每个
Entry节点同时存在于两个逻辑结构中。它通过next指针,作为哈希桶链表的一部分;同时,它通过before和after指针,作为全局双向链表的一部分。 - 代码复用:HashMap 的所有代码都在操作
Node类型,LinkedHashMap 传入的是Entry子类型,由于多态,HashMap 的代码可以不加修改地工作。LinkedHashMap 只需在自己的钩子方法中,将节点强转为Entry并操作其before/after指针即可。
- 数据结构映射:在内存中,每个
LinkedHashMap的核心字段:head和tail指针分别指向这个全局双向链表的头和尾,accessOrder则决定了链表的行为模式。
Part 2:存储与构造篇
模块 3:存储结构与核心字段(源码剖析)
3.1 双向链表的节点:LinkedHashMap.Entry
LinkedHashMap 的存储结构是“哈希桶数组 + 双向链表”的完美结合。我们首先来看这个结合的微观表现——节点的设计。
// JDK 8 LinkedHashMap.java
// 静态内部类,继承自 HashMap.Node
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 双向链表的前驱和后继指针
// 构造方法,直接复用父类 HashMap.Node 的构造器
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
源码分析:
- 继承
HashMap.Node:Entry获得了hash、key、value和next四个字段。其中next用于解决哈希冲突,形成哈希桶中的单链表或树结构(TreeNode 也继承自 Entry)。 - 新增
before, after:这两个Entry<K,V>类型的引用构成了贯穿所有节点的双向链表骨架。它们完全不关心节点的哈希值或存储位置,只关心节点之间的先后顺序。
3.2 维护链表的关键字段
在 LinkedHashMap 类中,定义了三个核心字段来控制这个双向链表:
// JDK 8 LinkedHashMap.java
/**
* 双向链表的头节点(最老的节点)。
* 在插入顺序下,是最先插入的节点;在访问顺序下,是最久未被访问的节点。
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* 双向链表的尾节点(最新的节点)。
* 在插入顺序下,是最后插入的节点;在访问顺序下,是最近被访问的节点。
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* 迭代顺序的标志位。
* true: 访问顺序(get/put 会将被访问元素移到链表尾)
* false: 插入顺序(默认)
*/
final boolean accessOrder;
字段详解:
head和tail:这两个指针分别指向双向链表的头和尾,是遍历整个 Map 的入口和出口。transient关键字表示它们不参与默认序列化。accessOrder:这是一个final字段,一旦在构造器中设定,就无法更改。它决定了 LinkedHashMap 的行为模式。所有维护链表顺序的逻辑,都会检查这个字段。
3.3 双结构并存的内存模型
classDiagram
direction LR
class LinkedHashMap {
-table: Node[]
-head: Entry
-tail: Entry
}
class table ["table (哈希桶数组)"]
class Bucket0 ["bucket[0]"]
class Bucket1 ["bucket[1]"]
class BucketN ["bucket[n]"]
class Node_A_Entry["Entry A (hash=5)"]
class Node_B_Entry["Entry B (hash=1)"]
class Node_C_Entry["Entry C (hash=5)"]
LinkedHashMap *-- table
table --> Bucket0
table --> Bucket1
table --> BucketN
Bucket1 --> Node_B_Entry : next
BucketN --> Node_A_Entry : next
Node_A_Entry --> Node_C_Entry : next
LinkedHashMap *-- Node_A_Entry : head
Node_A_Entry --> Node_B_Entry : after
Node_B_Entry --> Node_C_Entry : after
Node_C_Entry --> Node_A_Entry : before (隐含)
LinkedHashMap *-- Node_C_Entry : tail
图表示意说明:
这张图展示了三个 Entry(A, B, C)如何同时存在于哈希桶和双向链表中。假设插入顺序为 A -> B -> C,且 A 和 C 的哈希码指向了同一个桶 bucket[n]。
-
第一层:哈希桶视角(水平方向)
- 通过哈希算法,A 和 C 落入
bucket[n],它们通过next指针形成的单链表解决冲突。B 单独落入了bucket[1]。 - 这一结构完全由 HashMap 的机制维护,保证了
get操作的快速查找。
- 通过哈希算法,A 和 C 落入
-
第二层:双向链表视角(垂直/弧形箭头)
- 根据插入顺序,A 是最先插入的,B 是第二个,C 是最后插入的。
head指向 A,A 的after指向 B,B 的after指向 C,C 为tail。同时,每个节点的before指针也指向前一个节点。- 这一独立于哈希桶的结构由 LinkedHashMap 的钩子方法维护,保证了迭代的有序性。
-
关键结论:一个
LinkedHashMap.Entry节点,是哈希桶单链表和全局双向链表的交叉点。LinkedHashMap 的巧妙之处就在于它完美地分离了这两个关注点,让 HashMap 负责快速存取,而自己专注于顺序维护。
模块 4:构造方法(源码剖析)
LinkedHashMap 提供了五个构造器,其核心都是调用父类 HashMap 的构造器来初始化哈希表结构,然后设置自己的特有字段。
// JDK 8 LinkedHashMap.java
// 1. 默认构造器
public LinkedHashMap() {
super(); // 调用 HashMap(),初始容量16,负载因子0.75
accessOrder = false; // 默认为插入顺序
}
// 2. 指定初始容量
public LinkedHashMap(int initialCapacity) {
super(initialCapacity); // 调用 HashMap(int initialCapacity)
accessOrder = false;
}
// 3. 指定初始容量和负载因子
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor); // 调用 HashMap(int, float)
accessOrder = false;
}
// 4. 从另一个 Map 构造
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super(); // 初始化为默认容量和负载因子
accessOrder = false;
putMapEntries(m, false); // 批量插入,会调用newNode,新节点按传入Map迭代顺序链入
}
// 5. 关键构造器:指定顺序模式
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder; // 设定访问顺序模式
}
构造器详解与 Demo:
- 前四个构造器都将
accessOrder设置为false,即插入顺序模式。 - 第五个构造器是启用 LRU 缓存的关键,它允许我们将
accessOrder设置为true。
import java.util.LinkedHashMap;
import java.util.Map;
public class LinkedHashMapConstructorDemo {
public static void main(String[] args) {
// 1. 默认插入顺序
Map<Integer, String> defaultMap = new LinkedHashMap<>();
defaultMap.put(3, "C");
defaultMap.put(1, "A");
defaultMap.put(2, "B");
System.out.println("插入顺序: " + defaultMap);
// 输出: 插入顺序: {3=C, 1=A, 2=B} (迭代顺序与插入顺序一致)
// 2. 指定初始容量和负载因子,访问顺序
Map<Integer, String> accessOrderMap = new LinkedHashMap<>(16, 0.75f, true);
accessOrderMap.put(3, "C");
accessOrderMap.put(1, "A");
accessOrderMap.put(2, "B");
System.out.println("初始访问顺序: " + accessOrderMap);
// 输出: 初始访问顺序: {3=C, 1=A, 2=B}
// 关键:访问已存在的key
accessOrderMap.get(1);
System.out.println("访问1之后: " + accessOrderMap);
// 输出: 访问1之后: {3=C, 2=B, 1=A} (1被移到了尾部)
// 更新已存在的key
accessOrderMap.put(2, "BB");
System.out.println("更新2之后: " + accessOrderMap);
// 输出: 更新2之后: {3=C, 1=A, 2=BB} (2被移到了尾部)
}
}
Part 3:核心原理篇
模块 5:双向链表的维护——钩子方法体系(源码剖析)
HashMap 的设计者预见到了子类可能需要响应节点的增、删、改、查事件,因此在 putVal、removeNode、getNode 等核心方法中,预留了三个回调方法。LinkedHashMap 正是通过重写这些“钩子”,以“横切”的方式,非侵入式地将双向链表的维护逻辑织入到了 HashMap 的操作流程中。
5.1 put 操作中的钩子方法调用链
当一个 put 操作发生时,HashMap 的 putVal 方法内部会按特定顺序触发多个钩子。下面的流程图清晰地展示了这一过程,并标注了责任方的切换。
flowchart TD
Start(LinkedHashMap.put key, value) --> CallSuperPutVal[调用 HashMap.putVal]
subgraph HashMap.putVal 流程
PutValStart[开始] --> HashCalc[计算 hash 值]
HashCalc --> CheckExist{检查桶中<br/>是否已存在 Key?}
CheckExist -- 否 --> CreateNode[调用 newNode 创建节点]
CreateNode --> AddToBucket[将节点放入哈希桶]
CheckExist -- 是 --> UpdateValue[更新节点的 value 值]
UpdateValue --> AfterAccess[调用 afterNodeAccess]
AddToBucket --> AfterInsertion[调用 afterNodeInsertion]
AfterInsertion --> PutValEnd[putVal 结束]
AfterAccess --> PutValEnd
end
subgraph LinkedHashMap 钩子方法
NewNodeImpl["重写 newNode:<br/>1. 调用构造器 new Entry<>(...)<br/>2. 调用 linkNodeLast 将<br/>新 Entry 链入双向链表尾部"]
AfterAccessImpl{"重写 afterNodeAccess:<br/>1. 判断 accessOrder == true?<br/>2. 是,将传入节点<br/>移至双向链表尾部"}
AfterInsertionImpl["重写 afterNodeInsertion:<br/>1. 调用 removeEldestEntry<br/>2. 若返回true,移除头节点"]
end
CreateNode -.-> NewNodeImpl
AfterAccess -.-> AfterAccessImpl
AfterInsertion -.-> AfterInsertionImpl
PutValEnd --> End(put 操作完成)
图表说明:
这张 put 操作中钩子方法调用时序图 揭示了 LinkedHashMap 如何在不上报的情况下接管控制权。
- 第一层:
LinkedHashMap.put调用- LinkedHashMap 自身没有重写
put方法。当调用put时,实际上直接进入了HashMap.putVal的逻辑。这是“复用”的体现。
- LinkedHashMap 自身没有重写
- 第二层:
HashMap.putVal的标准流程- 流程进行哈希计算和查找。根据 Key 是否存在,分为两个分支:
- Key 不存在(新增节点):HashMap 调用
newNode()方法来创建一个新的Node实例。 - Key 已存在(替换旧值):HashMap 在更新完
value后,会调用afterNodeAccess(e),通知“节点 e 被访问了”。
- Key 不存在(新增节点):HashMap 调用
- 在新增节点放入桶后,HashMap 还会调用
afterNodeInsertion(evict),通知“有新节点插入了”。
- 流程进行哈希计算和查找。根据 Key 是否存在,分为两个分支:
- 第三层:LinkedHashMap 的钩子实现(责任方切换)
重写 newNode:这是双向链表建立的起点。LinkedHashMap 重写的newNode方法返回了一个LinkedHashMap.Entry实例,并立即调用了linkNodeLast方法,将这个新节点链接到了双向链表的尾部。这一步确保了新插入的元素在链表中处于“最新”的位置。重写 afterNodeAccess:当 Key 已存在时触发。该方法会检查accessOrder。如果accessOrder为true,它会执行一系列before/after指针的调整,将该节点从链表当前位置“摘除”,并移动到链表尾部,使其成为最新的节点。重写 afterNodeInsertion:该方法是一个扩展点,默认调用removeEldestEntry(first)。如果该方法返回true,则会直接移除链表的头节点(最老的节点)。这是一个为 LRU 缓存而生的钩子。
5.2 核心钩子方法源码详解
1. newNode 与 linkNodeLast
// LinkedHashMap.java
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
// 1. 创建 LinkedHashMap.Entry 实例
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<>(hash, key, value, e);
// 2. 将新节点链接到双向链表的尾部
linkNodeLast(p);
// 3. 返回该节点,该节点将由 HashMap 用于放入哈希桶
return p;
}
// 链接节点到尾部
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail; // 获取当前尾节点
tail = p; // 新节点成为尾节点
if (last == null)
// 如果之前的尾节点为null,说明链表为空,新节点也同时是头节点
head = p;
else {
// 否则,建立双向连接
p.before = last;
last.after = p;
}
}
2. afterNodeAccess
// LinkedHashMap.java
void afterNodeAccess(Node<K,V> e) { // 将节点移至链表尾部
LinkedHashMap.Entry<K,V> last;
// 只有在accessOrder为true,且当前节点e不是尾节点时,才需要移动
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// 1. 从链表中“摘除”节点p
p.after = null;
if (b == null)
// p是头节点,则将它的后继设为新的头节点
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
// p是尾节点?此情况理论上在开头 `tail != e` 已排除
last = b;
// 2. 将节点p链接到尾部
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
// 3. 更新尾指针
tail = p;
++modCount; // 结构修改次数+1,触发fail-fast
}
}
3. afterNodeInsertion
// LinkedHashMap.java
void afterNodeInsertion(boolean evict) { // evict在HashMap中为true
LinkedHashMap.Entry<K,V> first;
// 如果 removeEldestEntry 返回 true,则移除头节点(最老元素)
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
// 调用 HashMap的removeNode方法移除节点,会触发afterNodeRemoval
removeNode(hash(key), key, null, false, true);
}
}
// 默认永远返回 false,不执行淘汰
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
4. afterNodeRemoval
// LinkedHashMap.java
void afterNodeRemoval(Node<K,V> e) { // 从双向链表中删除节点e
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// 断开节点p的前后连接
p.before = p.after = null;
// 更新其前驱和后继的指针,将p从链表中摘除
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
模块 6:插入顺序模式下的 put 与遍历(源码剖析)
当 accessOrder=false 时,LinkedHashMap 工作在插入顺序模式。这是最纯粹、最易于理解的模式。
6.1 put 操作的全生命周期
我们跟踪一个全新的 Key 从 put 进入 Map 到最终在内存中落位的全过程。
sequenceDiagram
participant Client
participant LHM as LinkedHashMap
participant HM as HashMap.putVal
participant Bucket as 哈希桶数组
participant DLL as 全局双向链表
Client ->> LHM: put("K1", "V1")
LHM ->> HM: super.putVal("K1", "V1")
HM ->> HM: 计算 hash("K1") = index
HM ->> Bucket: 检查 table[index] 无冲突
Bucket -->> HM: null
HM ->> LHM: newNode(hash, "K1", "V1", null)
Note over LHM: 重写的钩子方法
LHM ->> LHM: new Entry<>(...)
LHM ->> DLL: linkNodeLast(newEntry)
Note over DLL: tail从null变为newEntry,<br/>head也指向newEntry
DLL -->> LHM:
LHM -->> HM: 返回 newEntry
HM ->> Bucket: table[index] = newEntry
HM ->> LHM: afterNodeInsertion(true)
Note over LHM: 重写的钩子方法,<br/>removeEldestEntry返回false,无操作
HM -->> LHM:
LHM -->> Client: 返回 null (无旧值)
Client ->> LHM: put("K2", "V2")
Note over Client,LHM: 重复上述过程...
图表说明:
这张 插入顺序下 put 操作的完整时序图 动态展示了新增一个键值对时,内存中各组件如何协作。
- 第一层:
Client调用LHM.put- 请求进入 LinkedHashMap,但被直接委托给了父类
HashMap.putVal。
- 请求进入 LinkedHashMap,但被直接委托给了父类
- 第二层:
HashMap执行哈希查找- HashMap 进行哈希计算,定位到桶索引,假设此时无冲突,桶为空。
- 因为 Key 不存在,HashMap 需要创建一个新的节点。它不会自己去
new,而是调用newNode钩子。
- 第三层:
LinkedHashMap接管节点创建(钩子激活)- 控制权回到 LinkedHashMap 重写的
newNode方法中。 - 关键步骤 1:
newNode内部创建了LinkedHashMap.Entry实例,该实例同时持有next(此处为 null)和before/after指针。 - 关键步骤 2:紧接着调用
linkNodeLast。此时双向链表为空,因此head和tail都指向了这个新 Entry。它现在是链表上的第一个节点。节点创建完毕,返回给 HashMap。
- 控制权回到 LinkedHashMap 重写的
- 第四层:
HashMap完成存放并触发后续钩子- HashMap 将拿到的 Entry 节点引用放入到
table[index]的桶位中。 - HashMap 接着调用
afterNodeInsertion(true),这又是一个钩子。在默认实现中,removeEldestEntry返回false,所以这里什么也不会发生。
- HashMap 将拿到的 Entry 节点引用放入到
关键结论:在插入顺序模式下,新节点通过 newNode -> linkNodeLast 被加到链表尾部。afterNodeAccess 永远不会生效(因为 accessOrder 为 false)。所以节点的相对顺序从首次插入后就不再改变。
6.2 基于链表的迭代
LinkedHashMap 的迭代器(KeySet、Values、EntrySet 的迭代器)直接遍历双向链表,完全绕开了哈希桶。
// LinkedHashMap.java 内部抽象类
abstract class LinkedHashIterator {
LinkedHashMap.Entry<K,V> next; // 下一个要返回的节点
LinkedHashMap.Entry<K,V> current; // 当前节点
int expectedModCount; // 用于fail-fast
LinkedHashIterator() {
next = head; // 从头节点开始遍历
expectedModCount = modCount;
}
public final boolean hasNext() {
return next != null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after; // 关键:通过after指针移动到下一个节点!
return e;
}
// ... 省略 remove 相关代码
}
// Entry 迭代器
final class LinkedEntryIterator extends LinkedHashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
源码分析:nextNode() 方法中的 next = e.after; 就是迭代有序性的秘密。它让迭代完全遵循了双向链表的顺序,而该顺序正是由我们之前分析的钩子方法体系所维护的。
模块 7:访问顺序模式与 LRU 实现(源码剖析)
当 accessOrder=true 时,LinkedHashMap 的行为发生了质变,afterNodeAccess 钩子被激活,使得每一次对元素的“访问”都会改变其在双向链表中的位置。
7.1 get 操作的指针变化过程
下面的时序图展示了在一个 accessOrder=true 的 Map 中,执行 get(1) 时,节点 1 从链表中间移动到尾部的完整过程。假设链表初始状态为 head -> A(1) -> B(2) -> C(3) -> tail。
sequenceDiagram
participant Client
participant LHM_Get as LinkedHashMap.get
participant HM_GetN as HashMap.getNode
participant LHM_Access as LinkedHashMap.afterNodeAccess
Client ->> LHM_Get: get(1)
LHM_Get ->> HM_GetN: super.getNode( hash(1) )
Note over HM_GetN: 通过哈希桶快速找到节点 A(1)
HM_GetN -->> LHM_Get: 返回节点 A
Note over LHM_Get: accessOrder == true
LHM_Get ->> LHM_Access: afterNodeAccess(A)
activate LHM_Access
Note over LHM_Access: 1. 从原位置“摘除”节点 A
LHM_Access ->> LHM_Access: b = A.before (null), a = A.after (B)
LHM_Access ->> LHM_Access: A.after = null
Note over LHM_Access: b == null,所以 head = a (即B)
LHM_Access ->> LHM_Access: a.before = b (即B.before = null)
Note over LHM_Access: 2. 将节点 A 链接到尾部
LHM_Access ->> LHM_Access: last = tail (C)
LHM_Access ->> LHM_Access: p.before = last (A.before = C)
LHM_Access ->> LHM_Access: last.after = p (C.after = A)
LHM_Access ->> LHM_Access: tail = p (tail = A)
LHM_Access ->> LHM_Access: modCount++
deactivate LHM_Access
LHM_Get -->> Client: 返回 A.value
Note over Client: 链表变为<br/>head -> B(2) -> C(3) -> A(1) -> tail
图表说明:
这张 访问顺序下 get 操作的指针操作时序图 详尽地展示了 afterNodeAccess 被触发时,一系列精密的指针“手术”。
- 第一层:
get调用与节点查找Client调用LinkedHashMap.get(1)。LinkedHashMap.get首先调用父类HashMap.getNode,利用哈希表 O(1) 的高效性找到键为1的节点A。此步骤证明了查找性能不受链表影响。
- 第二层:触发访问后回调
- LinkedHashMap 的
get方法拿到节点A后,判断accessOrder == true。条件成立,则调用afterNodeAccess(A)。
- LinkedHashMap 的
- 第三层:
afterNodeAccess执行“摘除”手术(步骤1)- 记录节点
A的前驱b(null)和后继a(B)。 - 将节点
A的after指针置为null,切断它与后继的联系。 - 由于
b为null,说明A是头节点,因此将head指针指向a(即 节点 B 成为新的头节点)。 - 将
a(节点 B)的before指针置为null。至此,节点A被完全从链表上“摘”了下来。
- 记录节点
- 第四层:
afterNodeAccess执行“重链接”手术(步骤2)- 记录当前尾节点
last(节点 C)。 - 将节点
A的before指针指向last(C)。 - 将
last(C)的after指针指向A。 - 关键一步:将
tail指针更新为A,使节点A成为新的尾节点。
- 记录当前尾节点
- 最终结果:链表的顺序从
A -> B -> C变为了B -> C -> A。最近被访问的A去到了链表尾部,相应地,最久未访问的B沉淀到了链表头部。
7.2 实现 LRU 缓存
LRU(Least Recently Used,最近最少使用)缓存是 LinkedHashMap 的终极应用。实现一个固定容量的 LRU 缓存,只需两步:
- 设置
accessOrder = true。 - 重写
removeEldestEntry方法,定义何时移除最老的元素。
import java.util.LinkedHashMap;
import java.util.Map;
// 一个最大容量为3的LRU缓存
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int MAX_CAPACITY;
public LRUCache(int capacity) {
// 关键1: 设置 accessOrder = true
// 使用容量+1,并设置负载因子为1,避免不必要的扩容开销
super(capacity, 0.75f, true);
this.MAX_CAPACITY = capacity;
}
// 关键2: 重写此方法,当Map大小超过我们设定的最大容量时,返回true
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > MAX_CAPACITY;
}
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "A");
cache.put(2, "B");
cache.put(3, "C");
System.out.println("初始: " + cache); // {1=A, 2=B, 3=C}
// 访问1,1被移到尾部
cache.get(1);
System.out.println("访问1后: " + cache); // {2=B, 3=C, 1=A}
// 插入4,将触发淘汰最老元素(即2)
cache.put(4, "D");
System.out.println("插入4后: " + cache); // {3=C, 1=A, 4=D}
// 更新3,3被移到尾部
cache.put(3, "C-updated");
System.out.println("更新3后: " + cache); // {1=A, 4=D, 3=C-updated}
// 插入5,淘汰最老元素(1)
cache.put(5, "E");
System.out.println("插入5后: " + cache); // {4=D, 3=C-updated, 5=E}
}
}
源码逻辑还原:
put(4, "D")触发HashMap.putVal。newNode创建新节点并linkNodeLast到尾部。afterNodeInsertion(true)被调用。- 在
afterNodeInsertion内部,调用我们重写的removeEldestEntry。此时size()为 4,大于MAX_CAPACITY(3),返回true。 - 因此,
removeNode(hash(key), ...)被调用,参数key是链表的头节点head,即最久未使用的元素 (2, "B")。 remove操作又会触发afterNodeRemoval,将节点 2 从双向链表中移除。一个完美的 LRU 闭环就此完成。
模块 8:get 与 remove 操作(源码剖析)
8.1 get 操作流程
// LinkedHashMap.java
public V get(Object key) {
Node<K,V> e;
// 1. 调用父类HashMap的getNode查找节点
if ((e = getNode(hash(key), key)) == null)
return null;
// 2. 如果开启了访问顺序,则触发钩子
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
流程图如下:
flowchart TD
Start[LinkedHashMap.get] --> CallSuper[调用 HashMap.getNode]
CallSuper --> Found{找到节点 e?}
Found -- 是, e!=null --> CheckOrder{accessOrder == true?}
CheckOrder -- 是 --> CallAccess[调用 afterNodeAccess, 将e移至链表尾部]
CheckOrder -- 否 --> ReturnVal
CallAccess --> ReturnVal[返回 e.value]
Found -- 否, e==null --> ReturnNull[返回 null]
图表说明:
这张 get 操作流程图 极其简洁,因为它极大地复用了父类逻辑。
- 核心复用:查找节点的核心逻辑
getNode是 HashMap 的,性能为 O(1)。 - 条件增强:唯一的增强是在成功找到节点后,加了一个
if (accessOrder)的判断。如果为 true,就调用afterNodeAccess,实现了“访问即更新”的语义。如果为 false,get方法的行为与 HashMap 完全相同。
8.2 remove 操作流程
LinkedHashMap 没有重写 remove 方法,完全复用 HashMap 的 remove。但 HashMap 的 removeNode 方法在成功删除节点后,会调用钩子 afterNodeRemoval,这就给了 LinkedHashMap 修改自己数据结构的机会。
flowchart TD
Start[LinkedHashMap.remove key] --> CallSuper[调用 HashMap.removeNode]
subgraph HashMap.removeNode 流程
FindAndRemove[查找并移除节点 e] --> IsRemoved{移除成功?}
IsRemoved -- 是 --> CallRemovalHook[调用 afterNodeRemoval e]
IsRemoved -- 否 --> End
end
CallRemovalHook -.-> |钩子回调| DLL_Remove[LinkedHashMap.afterNodeRemoval:<br/>将e从双向链表中摘除]
CallRemovalHook --> End[remove 操作结束]
DLL_Remove --> End
关键结论:LinkedHashMap 的 remove 之所以能保证双向链表的正确,完全依赖于 HashMap 在删除节点后主动回调的 afterNodeRemoval 钩子。LinkedHashMap 在此钩子中完成了将其从双向链表中摘除的清理工作。
Part 4:迭代与序列化篇
模块 9:迭代器——基于链表的顺序遍历
正如模块 6.2 所分析的,LinkedHashMap 的所有集合视图迭代器都基于其内部的双向链表。
flowchart TD
Start[创建迭代器] --> Init[iterator.next = this.head]
subgraph 调用 next 遍历
Next[调用 iterator.next] --> FetchNext[next = next.next]
FetchNext --> GetValue[return next.key/value/entry]
GetValue --> UpdateNext[next = e.after]
UpdateNext --> More{next == null?}
More -- 否 --> Next
More -- 是 --> End[遍历结束]
end
subgraph 内存结构
Head[head] --> A((Entry A))
A -- after --> B((Entry B))
B -- after --> C((Entry C))
C -- after --> null
Tail[tail] --> C
end
Init --> A
图表说明:
这张 迭代器遍历流程图 展示了从 head 到 tail 的顺序访问过程,并与内存中的链表结构相映射。
- 第一层:初始化
- 在迭代器(如
LinkedKeyIterator)的构造器中,next指针被初始化为this.head,即指向全局双向链表的头节点。
- 在迭代器(如
- 第二层:迭代过程
hasNext()方法仅仅检查next是否为null。nextNode()方法首先记录当前next指向的节点e,然后关键的一步是执行next = e.after,将迭代器的游标沿着链表的after指针移动到下一个节点。
- 第三层:数据结构映射
- 该流程与右侧的内存结构图完美对应。迭代器从
Entry A开始,通过after指针走到Entry B,再走到Entry C,最后遇到null结束。
- 该流程与右侧的内存结构图完美对应。迭代器从
- 核心结论:无论哈希桶的内部结构多么复杂,迭代器只认双向链表这一条路,因此能保证严格按链表顺序输出。这种实现不仅保证了顺序,还避免了对哈希桶数组的扫描,从而在遍历时不会访问那些空的桶位,效率高于 HashMap 的迭代器。
Part 5:对比与陷阱篇
模块 10:LinkedHashMap vs HashMap vs TreeMap——有序性的三种维度
Java 集合框架中这三个 Map 实现,分别代表了 Map 有序性的三个不同维度。
flowchart TD
Start["选择 Map 实现"] --> Q1{"是否需要 key-value 映射?"}
Q1 -->|"是"| Q2{"key 是否需要排序?"}
Q2 -->|"需要按 key 的自然顺序或比较器排序"| TreeMap["TreeMap"]
Q2 -->|"不需要排序"| Q3{"迭代时是否需要可预测的顺序?"}
Q3 -->|"不需要"| HashMap["HashMap"]
Q3 -->|"需要"| Q4{"需要哪种可预测的顺序?"}
Q4 -->|"插入顺序或访问顺序"| LinkedHashMap["LinkedHashMap"]
subgraph 特性对比
TreeMap_feat["TreeMap: 基于红黑树 Key 有序 (SortedMap) O(log n) 基本操作 内存开销大 适用于需要排序或范围查询的场景"]
HashMap_feat["HashMap: 基于哈希表 迭代顺序不可预测 O(1) 基本操作 内存开销最小 适用于无需关心顺序的常规缓存和映射"]
LinkedHashMap_feat["LinkedHashMap: 基于哈希表+双向链表 迭代顺序可预测(插入/访问) O(1) 基本操作 略慢于HashMap 内存开销中等(每个节点多两个指针) 适用于LRU缓存和顺序敏感的场景"]
end
TreeMap --> TreeMap_feat
HashMap --> HashMap_feat
LinkedHashMap --> LinkedHashMap_feat
图表说明: 这张 Map 选型决策树与特性对比图 全景式地展示了 LinkedHashMap 在 Map 家族中的独特地位。
- 第一层:排序维度的抉择
- 首要问题是 Key 是否需要排序。如果需要,
TreeMap是唯一选择。它基于红黑树实现,保证了 Key 的自然顺序或比较器顺序,并天然支持范围查询。代价是 O(log n) 的时间性能。
- 首要问题是 Key 是否需要排序。如果需要,
- 第二层:预测性维度的抉择
- 如果不需要排序,则进入“无序 vs 可预测顺序”的抉择。如果无需可预测的迭代顺序,
HashMap是最轻量、最快(但因无序而无法用于特定场景)的选择。 - 如果需要可预测的迭代顺序(最常见的是“先 put 先出”或 LRU),
LinkedHashMap就是不二之选。它在保持 O(1) 高性能的同时,通过微小的内存和性能开销,换来了宝贵的顺序性。
- 如果不需要排序,则进入“无序 vs 可预测顺序”的抉择。如果无需可预测的迭代顺序,
- 核心结论:三者的本质区别在于用什么样的开销换取了什么样的顺序。HashMap 用最低开销换取最大性能,但无顺序;TreeMap 用最大开销(时间与空间)换取 Key 的排序能力;而 LinkedHashMap 则用中等开销在性能和顺序之间找到了一个极佳的平衡点。
模块 11:常见陷阱与最佳实践
陷阱 1:Key 可变性导致的“幽灵”节点
错误代码:
class MutableKey {
int id;
String name;
// equals和hashCode依赖于id和name
}
public static void main(String[] args) {
Map<MutableKey, String> map = new LinkedHashMap<>();
MutableKey key = new MutableKey(1, "A");
map.put(key, "Value1");
// 错误操作:修改了 Key 中影响 hashCode 或 equals 的字段
key.id = 2;
// 后果1: 无法通过新 key 或旧 key 找到该值
System.out.println(map.get(key)); // 极大概率输出 null
// 后果2: 迭代器仍然能遍历到这个节点(因为链表指针未变),但其数据已不可访问
map.forEach((k, v) -> System.out.println(k + " -> " + v)); // 会打印出这个“幽灵”节点
// 后果3: 该节点无法被 remove(key) 移除,造成内存泄漏
}
最佳实践:永远使用不可变对象(如 String、Integer,或自定义的 final 类)作为 Map 的 Key。
陷阱 2:accessOrder=true 下遍历时执行 get
在访问顺序模式下,遍历过程本身(通过迭代器)不算是“访问”,不会改变顺序。但如果遍历的代码体内有 get 操作,就会立即改变 Map 的结构。
// 错误演示
Map<Integer, String> map = new LinkedHashMap<>(16, 0.75f, true);
map.put(1, "A"); map.put(2, "B"); map.put(3, "C");
// 尝试在迭代中访问,期望正常结束
for (Integer key : map.keySet()) {
if (key == 1) {
map.get(2); // 危险操作!get(2)会将2移到链表尾部
}
System.out.print(key + " ");
}
// 该循环可能会输出 "1 3 ",跳过了2,因为2被移到了尾部
// 在某些版本或情况下,甚至可能因为ConcurrentModificationException而直接崩溃。
最佳实践:在 accessOrder=true 模式下,绝对不要在迭代遍历keySet、entrySet、values 的过程中对 Map 执行 get 或 put(更新)操作。如果必须做,请先拷贝一份快照再遍历。
陷阱 3:默认容量不足导致的频繁扩容
在实现 LRU 缓存时,我们通常将容量设得很小。但 LinkedHashMap 默认的容量是 16,负载因子是 0.75。
// 不推荐:容量为5,但仍会经历从默认16容量扩张的过程
new LRUCache(5);
// 推荐:精确设置容量和负载因子,避免不必要的扩容
new LRUCache(5, 1.0f); // 设置负载因子为1,将扩容机会降到最低
在构造 LRU 缓存时,为了精确控制内存,最好在构造器中明确指定 int initialCapacity 并使用较高的负载因子(如 1.0f),避免在达到期望容量之前就因负载因子而触发扩容。
陷阱 4:多线程环境下的 ConcurrentModificationException
错误代码:
Map<Integer, String> map = new LinkedHashMap<>(); // 非线程安全
// 线程1: 迭代
new Thread(() -> map.forEach((k,v) -> {
try { Thread.sleep(10); } catch (Exception e) {}
})).start();
// 线程2: 修改
new Thread(() -> map.put(4, "D")).start();
// 几乎100%会抛出 ConcurrentModificationException
解决方案:
- 使用
Collections.synchronizedMap(new LinkedHashMap(...)),但对迭代仍需手动加锁。 - 直接使用
java.util.concurrent.ConcurrentHashMap(但会失去顺序)。 - 如果需要并发且有序,可将
ConcurrentHashMap与额外的 List 或 Queue 结合使用,或直接使用 Guava 等库提供的线程安全缓存。
Part 6:总结与面试篇
模块 12:注意事项与性能总结
时间复杂度总结表:
| 操作 | 平均时间复杂度 | 说明 |
|---|---|---|
get | O(1) | 哈希查找 O(1) + (可选)移动链表 O(1) |
put | O(1) | 哈希插入 O(1) + 链接链表 O(1) + (可选)淘汰 O(1) |
remove | O(1) | 哈希删除 O(1) + 摘除链表 O(1) |
containsKey | O(1) | 纯哈希操作 |
| 迭代 | O(n) | 直接遍历全局双向链表,效率极高 |
clone | O(n) | 需要重建哈希表和链表 |
内存开销分析:
- 每个节点除了存储
key、value、hash、next(继承自HashMap.Node)外,还多存储了before和after两个对象引用。 - 在 64 位 JVM 且开启指针压缩的情况下,每个引用占用 4 字节。因此每个节点额外开销为 8 字节。对于大型 Map,这个开销会变得显著。
推荐使用场景:
- 需要按插入顺序输出:数据库查询结果的映射、解析配置文件并保留顺序。
- 实现 LRU 缓存:在存储空间有限的情况下,自动维护数据的“冷热”程度。
- 需要稳定迭代顺序的单元测试:避免因哈希顺序不确定导致的测试不稳定。
模块 13:面试高频专题
1. LinkedHashMap 和 HashMap 的区别是什么?有序性如何保证?
- 标准回答:最核心的区别是 LinkedHashMap 能保证迭代顺序,而 HashMap 不能。LinkedHashMap 继承自 HashMap,复用了它的所有哈希操作,但其内部通过重写
newNode、afterNodeInsertion、afterNodeAccess、afterNodeRemoval等钩子方法,额外维护了一条贯穿所有节点的双向链表。迭代时,LinkedHashMap 遍历的是这个有序的双向链表,而非哈希桶数组,从而保证了顺序。 - 追问模拟:“这个有序性分哪些情况?”
- 回答:分两种,通过构造器中的
accessOrder参数控制。默认为false,是插入顺序,即迭代顺序与put的先后顺序一致。设为true后,是访问顺序,任何对元素的get或put(更新)操作,都会将该元素移动到内部链表的尾部,头部就是最久未被访问的元素。
- 回答:分两种,通过构造器中的
- 加分回答:LinkedHashMap 的这种设计是典型的模板方法模式。HashMap 定义了算法骨架(
putVal、getNode等),并预留了afterNode*钩子作为扩展点。LinkedHashMap 作为子类实现了这些钩子,从而在不改变父类核心算法的情况下,增加了新的行为(维护顺序)。这体现了开闭原则的强大威力。
2. LinkedHashMap 的内部结构是什么?双向链表如何与哈希桶结合?
- 标准回答:其底层数据结构是“数组 + 单向链表/红黑树 + 双向链表”的综合体。它的静态内部类
Entry<K,V>继承了HashMap.Node<K,V>,因此除了拥有key、value、hash和用于解决哈希冲突的next指针外,还额外增加了before和after两个指针。所有Entry节点通过next指针形成哈希桶的结构,同时又通过before和after指针横跨全局,形成一条双向链表。一个节点同时存在于这两个维度之中。 - 追问模拟:“为什么说一个节点同时存在两个结构中?”
- 回答:当一个新节点被
put进来时,HashMap 的逻辑会把它挂载到对应桶的单链表/红黑树上(next连接),而 LinkedHashMap 的newNode钩子又会把它链接到全局双向链表的尾部(before/after连接)。从查找角度看,通过 Key 的哈希值能快速在桶中找到它;从迭代角度看,从head出发能沿着after指针按顺序遍历到它。它的内存地址只有一份,但被两种逻辑结构所引用。
- 回答:当一个新节点被
3. LinkedHashMap 如何实现 LRU 缓存的原理?removeEldestEntry 的用法?
- 标回答:LRU 实现依赖于两个核心点。第一,构造器设置
accessOrder=true,这样每次get或put更新操作都会将被访问的元素移到双向链表的尾部,自然而然地,链表头部沉淀下来的就是最久未被访问的元素。第二,重写removeEldestEntry(Map.Entry eldest)方法。该方法在新节点插入后被afterNodeInsertion调用,当它返回true时,Map 就会删除当前链表头节点(即eldest参数)。通常我们在此方法中判断size() > 最大容量。 - 追问模拟:“如果我不重写
removeEldestEntry,只设置accessOrder=true,会发生什么?”- 回答:那么 Map 就只是一个具有“访问顺序”迭代能力的普通 Map,不会自动淘汰任何元素。其容量会随着元素的插入而无限增长,直到内存溢出。
accessOrder负责维护顺序,而removeEldestEntry负责定义淘汰的触发边界,两者缺一不可。
- 回答:那么 Map 就只是一个具有“访问顺序”迭代能力的普通 Map,不会自动淘汰任何元素。其容量会随着元素的插入而无限增长,直到内存溢出。
- 加分回答:这种实现是 LRU 最简单的版本。在生产环境中,更推荐使用 Guava Cache 或 Caffeine,因为它们支持更细粒度的过期策略(如基于时间的过期、基于大小的回收、异步刷新等),并且在并发性能上做了大量优化。
4. afterNodeAccess 和 afterNodeInsertion 方法分别什么时候被调用?
- 标准回答:
afterNodeInsertion(boolean evict):在putVal方法中,当成功插入一个新节点后调用。HashMap 会传入true作为evict参数(除非是在反序列化时)。afterNodeAccess(Node<K,V> e):它在两个地方被调用:一是在putVal中,如果 Key 已存在,更新 Value 之后被调用;二是在get方法中,成功查找到节点且accessOrder为true时被调用。
- 追问模拟:“那
afterNodeRemoval呢?它在哪里被调用?”- 回答:它在
removeNode方法中,当节点被成功从哈希桶中移除之后被调用,用于通知 LinkedHashMap 进行链表上的清理工作。
- 回答:它在
5. LinkedHashMap 的迭代器遍历顺序是什么?如果遍历中调用 get 会发生什么?(accessOrder 下)
- 标准回答:遍历顺序严格遵循其内部双向链表的顺序。如果
accessOrder为false,就是插入顺序;如果为true,就是从最久未使用到最近被使用的顺序。在accessOrder=true模式下,如果在使用迭代器遍历(如keySet().iterator())的过程中,直接调用map.get(),这个get会触发afterNodeAccess,从而改变链表结构和顺序。这可能导致迭代器跳过一些元素,或者重复返回某些元素,甚至可能因为检查到modCount变化而直接抛出ConcurrentModificationException。
6. 请手写一个利用 LinkedHashMap 实现固定大小的 LRU 缓存示例。
- 答案参考 模块 7.2 的
LRUCache示例代码。 - 加分回答:可以进一步扩展,比如在
removeEldestEntry中加入回调,用于在淘汰元素时执行持久化或资源清理操作。也可以重写get或put方法,增加统计命中率的逻辑,为缓存的性能分析提供数据支持。
7. LinkedHashMap 的扩容机制?与 HashMap 有何异同?
-
标准回答:LinkedHashMap 完全复用 HashMap 的
resize()方法来进行扩容。当元素数量超过当前容量与负载因子的乘积(即阈值)时,就会触发扩容。扩容过程包括新建一个更大的桶数组,并将原有节点通过哈希算法重新分散到新的桶中(即rehash)。关键差异在于:HashMap 只负责重建哈希桶的next链,而 LinkedHashMap 维护的全局before/after双向链表在整个扩容过程中完全不受影响,其节点间的相对顺序保持不变。因为扩容只操作节点的next指针,不触碰before/after指针。 -
追问模拟:“扩容后,双向链表的顺序会乱掉吗?”
- 回答:绝对不会。
resize()只遍历哈希桶数组,通过next指针重新分配节点位置。双向链表独立于桶结构,其头尾指针和节点的前后关系在扩容前后完全一致。因此,扩容是 LinkedHashMap 对存储结构的透明优化,不会破坏任何顺序约定。
- 回答:绝对不会。
-
追问模拟:“树化与双向链表的关系呢?”
- 回答:在 JDK 8 中,当某个桶的链表长度超过 8 且容量达到 64 时,HashMap 会将该桶的
Node链表转换为TreeNode红黑树。TreeNode作为LinkedHashMap.Entry的子类,同样持有before/after指针。树化过程中,全局双向链表依然被保留,只是桶内部的查找结构从单向链表变成了树。这意味着即使发生树化,迭代顺序依旧稳定。
- 回答:在 JDK 8 中,当某个桶的链表长度超过 8 且容量达到 64 时,HashMap 会将该桶的
-
加分回答:这种设计再次印证了关注点分离的优势。HashMap 管理“空间-存取”效率(桶数组和
next链),LinkedHashMap 专门管理“时间-顺序”约束(双向链表)。两者的修改互不干扰,使得 LinkedHashMap 可以在不触动父类复杂扩容逻辑的前提下,悄无声息地保持着自己的秩序。
8. LinkedHashMap 允许 null 键和值吗?与 HashMap 一致吗?
- 标准回答:是的,LinkedHashMap 允许 一个
null键和多个null值,这与 HashMap 的行为完全一致。在内部实现上,null键会被哈希到桶数组的第 0 号桶,并在双向链表中作为一个普通的Entry节点存在。顺序维护对null键没有特殊处理——如果是插入顺序,null键按其初次put的位置落在链表中;如果是访问顺序,对null键执行get同样会将它移至链表尾部。 - 追问模拟:“如果在 LRU 缓存中使用
null作为键,会不会有问题?”- 回答:技术上可以工作,但强烈不推荐。因为
null作为键含义模糊,容易导致业务逻辑混乱,且在多数序列化、RPC 或某些缓存框架中,null键可能被禁止或产生不可预知的行为。此外,null键也增加了NullPointerException的风险。
- 回答:技术上可以工作,但强烈不推荐。因为
- 加分回答:在严格的工程实践中,更提倡使用
Optional或专门的空对象模式来代替null,尤其是在作为缓存 Key 的场景,这可以避免模棱两可的状态,并使缓存的语义更加清晰。
9. 为什么说 LinkedHashMap 占用更多内存?多在哪里?
- 标准回答:LinkedHashMap 的内存开销主要集中在它的内部节点类
Entry<K,V>上。每一个Entry除了继承自HashMap.Node的key,value,hash,next四个字段外,还额外增加了两个对象引用字段:Entry<K,V> before和Entry<K,V> after。在 64 位 JVM 且开启指针压缩(-XX:+UseCompressedOops)的情况下,每个引用占用 4 字节,因此每个节点比 HashMap 的Node多出 8 字节。如果 LinkedHashMap 中有 100 万个条目,仅这两个指针就会带来约 8 MB 的额外内存消耗。此外,类本身还有head和tail两个Entry引用,其开销几乎可以忽略不计。 - 追问模拟:“这个额外的内存开销会引发严重的性能问题吗?”
- 回答:对大多数应用来说,这点开销是可接受的。但在内存敏感或数据量巨大(如千万级条目)的场景下,额外的内存意味着更高的 GC 频率和更严重的内存碎片。此时就需要权衡:是用这 8 字节换有序性,还是使用 HashMap 更节约内存。相比 TreeMap(每个节点还要存
parent,left,right,color等),LinkedHashMap 的额外开销已经算小的。
- 回答:对大多数应用来说,这点开销是可接受的。但在内存敏感或数据量巨大(如千万级条目)的场景下,额外的内存意味着更高的 GC 频率和更严重的内存碎片。此时就需要权衡:是用这 8 字节换有序性,还是使用 HashMap 更节约内存。相比 TreeMap(每个节点还要存
- 加分回答:在一些超高吞吐低延迟系统中,甚至可能会选择使用
LinkedHashMap的轻量级替代方案,比如基于数组的双端队列加哈希表自己组合,以便更精细地控制内存布局。不过,对于绝大多数业务场景,LinkedHashMap 的“适度开销 + 顺序保证”依然是最具性价比的选择。
10. 请手写一个利用 LinkedHashMap 实现固定大小的 LRU 缓存示例。
- 标准回答与示例代码:
import java.util.LinkedHashMap;
import java.util.Map;
public class FixedSizeLRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public FixedSizeLRUCache(int maxSize) {
// 参数说明:初始容量,负载因子,访问顺序模式
// 使用 maxSize*2 作为初始容量以减少扩容,负载因子 0.75 保持空间与时间的平衡
super(maxSize, 0.75f, true);
this.maxSize = maxSize;
}
/**
* 重写淘汰条件:当 Map 大小超过设定的最大容量时返回 true,
* 这会触发自动移除最久未被访问(也即链表头)的元素。
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
// 测试
public static void main(String[] args) {
FixedSizeLRUCache<Integer, String> cache = new FixedSizeLRUCache<>(3);
cache.put(1, "one");
cache.put(2, "two");
cache.put(3, "three");
System.out.println(cache); // {1=one, 2=two, 3=three}
cache.get(1); // 访问 1,使其成为最新
cache.put(4, "four"); // 插入 4,2 作为最老元素被淘汰
System.out.println(cache); // {3=three, 1=one, 4=four}
}
}
-
追问模拟:“
initialCapacity设多少最合适?为什么这里用了maxSize?”- 回答:这里设为
maxSize是为了避免在达到目标容量之前就因负载因子(0.75)而触发不必要的一次扩容。由于极大概率容量就稳定在maxSize,用maxSize作为初始容量可直接安排足够的桶空间,避免插入过程中的扩容抖动。在生产中也可以直接指定new LinkedHashMap(maxSize, 0.75f, true),或使用(int) (maxSize / 0.75f) + 1以完全杜绝扩容。
- 回答:这里设为
-
追问模拟:“如果我想在淘汰前做点额外操作,比如将淘汰的数据写回数据库,该怎么扩展?”
- 回答:直接在
removeEldestEntry方法中,当判断到要返回true时,可以先从eldest参数中取出键和值进行持久化或回调,然后再返回true。或者在重写的remove方法中捕获删除事件,但需注意避免循环调用。
- 回答:直接在
-
加分回答:这个实现是线程不安全的。要使其线程安全,可以使用
Collections.synchronizedMap(new FixedSizeLRUCache<>(...)),但这种方法在迭代时仍需手动同步。更优方案是使用并发安全的缓存库,如 Caffeine,它提供了真正的线程安全、自动淘汰、统计和监听功能,且在高并发下性能卓越。但在面试或简单场景下,手写 LRU 通过 LinkedHashMap 演示了对数据结构和继承特性的深刻理解。