LinkedHashMap 详解
本章定位:集合框架中唯一能同时提供 HashMap 的 O(1) 查找性能和元素顺序维护能力的 Map 实现。LinkedHashMap 通过在 HashMap 的哈希表基础上叠加一条双向链表,实现了插入顺序(insertion-order)和访问顺序(access-order)两种遍历模式。基于访问顺序模式,只需重写
removeEldestEntry方法即可轻松实现 LRU 缓存——这是 LinkedHashMap 最经典的工程应用。飞翔科技的最近访问员工缓存、浏览器前进后退、在线用户活跃度追踪等场景都依赖它。本章由白歌带领小崔深入 LinkedHashMap 的内核,大翔关注缓存淘汰策略的合理性,孔蓝验证访问顺序下的并发陷阱。
定义速览表
| 属性 | 说明 |
|---|---|
| 底层数据结构 | HashMap(数组 + 链表 + 红黑树)+ 双向链表 |
| 默认遍历顺序 | 插入顺序(insertion-order),即 accessOrder = false |
| 访问顺序模式 | accessOrder = true 时,最近访问的元素移到链表尾部 |
| 是否允许 null | 是(键和值各一个 null,与 HashMap 一致) |
| 线程安全 | 否 |
| 时间复杂度 | get/put/remove 平均 O(1)(与 HashMap 相同,链表操作是 O(1) 指针调整) |
| 实现继承 | HashMap<K,V> → AbstractMap<K,V> → Map<K,V> |
| fail-fast | 是(继承自 HashMap,迭代器在结构性修改时抛 ConcurrentModificationException) |
| 内存开销 | 比 HashMap 多(每个 Entry 额外维护 before/after 两个引用,共 16 字节) |
| 扩容机制 | 与 HashMap 完全一致(容量 ×2,负载因子 0.75) |
接口继承体系
方法速查表
基础 Map 操作(继承自 HashMap)
| 方法 | 描述 | 时间复杂度 |
|---|---|---|
put(K key, V value) | 插入键值对,维护链表顺序 | 平均 O(1) |
get(Object key) | 获取值,accessOrder 模式下移动节点到尾部 | 平均 O(1) |
remove(Object key) | 删除键值对,从链表中移除节点 | 平均 O(1) |
containsKey(Object key) | 判断是否包含指定键 | 平均 O(1) |
containsValue(Object value) | 判断是否包含指定值(沿双向链表遍历,比 HashMap 更高效) | O(n) |
size() | 返回键值对数量 | O(1) |
clear() | 清空所有键值对,重置链表头尾指针 | O(n) |
LinkedHashMap 特有构造器
| 构造方法 | 描述 |
|---|---|
LinkedHashMap() | 默认:插入顺序,初始容量 16,负载因子 0.75 |
LinkedHashMap(int initialCapacity) | 指定初始容量 |
LinkedHashMap(int initialCapacity, float loadFactor) | 指定初始容量和负载因子 |
LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) | 关键构造器——accessOrder=true 启用访问顺序模式 |
LinkedHashMap(Map<? extends K, ? extends V> m) | 用已有 Map 初始化,插入顺序 |
可重写的回调方法(模板方法模式)
| 方法 | 描述 | 默认行为 |
|---|---|---|
removeEldestEntry(Map.Entry<K,V> eldest) | 每次 put/putAll 后调用,返回 true 则移除最老条目 | 返回 false(不移除) |
afterNodeAccess(Node<K,V> e) | get/put(更新)后调用,accessOrder 模式下将节点移到链表尾部 | — |
afterNodeInsertion(boolean evict) | 插入新节点后调用,会触发 removeEldestEntry 检查 | — |
afterNodeRemoval(Node<K,V> e) | 删除节点后调用,从双向链表中移除节点 | — |
核心原理:双向链表 + HashMap
内部结构全景
LinkedHashMap 的每一个 Entry 节点同时存在于两个数据结构中:
两层结构的职责分工:
- HashMap 哈希表:负责快速查找——通过
hashCode定位桶,通过equals确认键 - 全局双向链表:负责维护遍历顺序——
head指向最老的条目,tail指向最新的条目
LinkedHashMap.Entry 节点结构
// JDK 8 源码(简化)
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 双向链表的前驱和后继指针
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next); // next 用于 HashMap 桶内链表
}
}
每个 Entry 节点包含 6 个字段:hash、key、value、next(桶内链表)、before(双向链表前驱)、after(双向链表后继)。
两种遍历顺序
插入顺序模式下,双向链表记录的是元素首次插入的顺序——即使后续更新已存在的 key,其在链表中的位置也不会改变。
访问顺序模式下,每次调用 get 或 put(更新已存在 key)都会将该节点从链表中摘除,重新插入到链表尾部。这样链表头部始终是最久未访问的元素,链表尾部是最近访问的元素——这正是 LRU 算法的核心数据结构。
核心原理:LRU 缓存实现
accessOrder 模式下的节点移动源码
// JDK 8 LinkedHashMap.afterNodeAccess 源码(简化注释版)
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;
LinkedHashMap.Entry<K,V> b = p.before, a = p.after;
p.after = null;
// 步骤1:从当前位置摘除
if (b == null) head = a;
else b.after = a;
if (a != null) a.before = b;
else last = b;
// 步骤2:插入到链表尾部
if (last == null) head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount; // 修改计数(导致迭代器 fail-fast)
}
}
关键点:afterNodeAccess 会修改 modCount,这意味着在访问顺序模式下,遍历过程中调用 get 会触发 ConcurrentModificationException——这是最常见的陷阱之一。
removeEldestEntry 与 LRU 缓存模板
// LRU 缓存的标准模板
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
public LRUCache(int maxCapacity) {
// 16: 初始容量, 0.75f: 负载因子, true: 访问顺序模式
super(16, 0.75f, true);
this.maxCapacity = maxCapacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当 size 超过最大容量时,允许移除最老的条目
return size() > maxCapacity;
}
}
工作原理:每次 put 新条目后,HashMap 会回调 afterNodeInsertion,该方法内部调用 removeEldestEntry。如果返回 true,则自动移除双向链表头部(即 head 指向的)条目——在访问顺序模式下,这就是最久未使用的条目。
完整代码示例
示例一:LRU 缓存——飞翔科技最近访问员工缓存
import java.util.*;
/**
* 场景:飞翔科技的员工管理系统需要缓存最近查询的 5 个员工信息。
* 白歌要求使用 LinkedHashMap 实现 LRU 淘汰策略:
* - 缓存满时自动淘汰最久未访问的员工
* - 再次访问已有员工时将其标记为"最近使用"
* 小崔负责实现,孔蓝编写测试用例验证淘汰逻辑。
*/
class EmployeeCache extends LinkedHashMap<String, String> {
private final int maxSize;
public EmployeeCache(int maxSize) {
super(16, 0.75f, true); // accessOrder = true(访问顺序模式)
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
boolean shouldRemove = size() > maxSize;
if (shouldRemove) {
System.out.println(" [淘汰] " + eldest.getKey() + " → " + eldest.getValue()
+ "(最久未使用)");
}
return shouldRemove;
}
}
public class FeiXiangLRUCache {
public static void main(String[] args) {
System.out.println("=== 飞翔科技员工缓存(容量=5,LRU 策略) ===\n");
EmployeeCache cache = new EmployeeCache(5);
// 1. 预热缓存:加载 5 名员工
System.out.println("1. 预热缓存(添加 5 名员工):");
cache.put("E001", "大翔-CEO");
cache.put("E002", "白歌-架构师");
cache.put("E003", "小崔-后端开发");
cache.put("E004", "孔蓝-测试工程师");
cache.put("E005", "Frank-市场专员");
printCache(cache);
// 预期:E001 → E002 → E003 → E004 → E005
// 2. 访问 E001(大翔被移到尾部)
System.out.println("\n2. 查询 E001(大翔)后:");
System.out.println(" 大翔信息: " + cache.get("E001"));
printCache(cache);
// 预期:E002 → E003 → E004 → E005 → E001
// 3. 访问 E003(小崔被移到尾部)
System.out.println("\n3. 查询 E003(小崔)后:");
System.out.println(" 小崔信息: " + cache.get("E003"));
printCache(cache);
// 预期:E002 → E004 → E005 → E001 → E003
// 4. 添加第 6 条 → 触发淘汰(最久未使用的 E002 白歌被移除)
System.out.println("\n4. 新增 E006(超出容量):");
cache.put("E006", "黄俪-前端开发");
printCache(cache);
// 预期:E004 → E005 → E001 → E003 → E006(E002 被淘汰)
// 5. 再次访问 E004,再添加第 7 条
System.out.println("\n5. 查询 E004(孔蓝)后,新增 E007:");
cache.get("E004");
cache.put("E007", "韩信-运维工程师");
printCache(cache);
// E005 最久未使用,被淘汰
// 6. 最终缓存快照
System.out.println("\n=== 最终缓存内容 ===");
int i = 1;
for (Map.Entry<String, String> entry : cache.entrySet()) {
System.out.printf(" %d. %s → %s%n", i++, entry.getKey(), entry.getValue());
}
}
private static void printCache(LinkedHashMap<String, String> cache) {
System.out.println(" 当前缓存: " + cache.keySet());
}
}
=== 飞翔科技员工缓存(容量=5,LRU 策略) ===
1. 预热缓存(添加 5 名员工):
当前缓存: [E001, E002, E003, E004, E005]
2. 查询 E001(大翔)后:
大翔信息: 大翔-CEO
当前缓存: [E002, E003, E004, E005, E001]
3. 查询 E003(小崔)后:
小崔信息: 小崔-后端开发
当前缓存: [E002, E004, E005, E001, E003]
4. 新增 E006(超出容量):
[淘汰] E002 → 白歌-架构师(最久未使用)
当前缓存: [E004, E005, E001, E003, E006]
5. 查询 E004(孔蓝)后,新增 E007:
[淘汰] E005 → Frank-市场专员(最久未使用)
当前缓存: [E001, E003, E006, E004, E007]
=== 最终缓存内容 ===
1. E001 → 大翔-CEO
2. E003 → 小崔-后端开发
3. E006 → 黄俪-前端开发
4. E004 → 孔蓝-测试工程师
5. E007 → 韩信-运维工程师
示例二:插入顺序 vs 访问顺序——两种模式的行为对比
import java.util.*;
/**
* 场景:白歌让小崔写一个对比 Demo,直观展示 LinkedHashMap
* 在插入顺序和访问顺序两种模式下对同一组操作的不同行为。
* 孔蓝用这个 Demo 给新人讲解 LinkedHashMap 的核心特性。
*/
public class LinkedHashMapOrderComparison {
public static void main(String[] args) {
System.out.println("═══════════════════════════════════════");
System.out.println(" LinkedHashMap 两种顺序模式对比");
System.out.println("═══════════════════════════════════════\n");
// ========== 模式一:插入顺序(默认) ==========
System.out.println("--- 模式一:插入顺序(accessOrder = false,默认) ---");
LinkedHashMap<String, Integer> insertionMap = new LinkedHashMap<>();
insertionMap.put("大翔", 80000);
insertionMap.put("白歌", 35000);
insertionMap.put("小崔", 12000);
insertionMap.put("孔蓝", 15000);
System.out.println("初始状态: " + insertionMap.keySet());
insertionMap.get("大翔"); // 访问不改变顺序
System.out.println("get(大翔) 后: " + insertionMap.keySet());
insertionMap.put("白歌", 36000); // 更新已存在的 key 不改变顺序
System.out.println("put(白歌)更新后: " + insertionMap.keySet());
insertionMap.put("Frank", 22000); // 新 key 追加到尾部
System.out.println("put(Frank)新增后: " + insertionMap.keySet());
System.out.println(" 结论:顺序始终等于首次插入的顺序\n");
// ========== 模式二:访问顺序 ==========
System.out.println("--- 模式二:访问顺序(accessOrder = true) ---");
LinkedHashMap<String, Integer> accessMap =
new LinkedHashMap<>(16, 0.75f, true);
accessMap.put("大翔", 80000);
accessMap.put("白歌", 35000);
accessMap.put("小崔", 12000);
accessMap.put("孔蓝", 15000);
System.out.println("初始状态: " + accessMap.keySet());
accessMap.get("大翔"); // 访问 → 移到尾部
System.out.println("get(大翔) 后: " + accessMap.keySet());
accessMap.put("白歌", 36000); // 更新 → 移到尾部
System.out.println("put(白歌)更新后: " + accessMap.keySet());
accessMap.put("Frank", 22000); // 新 key 追加到尾部
System.out.println("put(Frank)新增后: " + accessMap.keySet());
System.out.println(" 结论:被访问/更新的 key 移到尾部,头部始终是最久未访问的\n");
// ========== 补充验证:两种模式下的 containsValue 行为 ==========
System.out.println("--- 补充验证:containsValue 对顺序的影响 ---");
LinkedHashMap<String, Integer> testMap =
new LinkedHashMap<>(16, 0.75f, true);
testMap.put("A", 1);
testMap.put("B", 2);
testMap.put("C", 3);
System.out.println("containsValue 前: " + testMap.keySet());
testMap.containsValue(2); // containsValue 遍历但不触发 afterNodeAccess
System.out.println("containsValue 后: " + testMap.keySet());
System.out.println(" 结论:containsValue 不改变访问顺序(不触发节点移动)");
}
}
═══════════════════════════════════════
LinkedHashMap 两种顺序模式对比
═══════════════════════════════════════
--- 模式一:插入顺序(accessOrder = false,默认) ---
初始状态: [大翔, 白歌, 小崔, 孔蓝]
get(大翔) 后: [大翔, 白歌, 小崔, 孔蓝]
put(白歌)更新后: [大翔, 白歌, 小崔, 孔蓝]
put(Frank)新增后: [大翔, 白歌, 小崔, 孔蓝, Frank]
结论:顺序始终等于首次插入的顺序
--- 模式二:访问顺序(accessOrder = true) ---
初始状态: [大翔, 白歌, 小崔, 孔蓝]
get(大翔) 后: [白歌, 小崔, 孔蓝, 大翔]
put(白歌)更新后: [小崔, 孔蓝, 大翔, 白歌]
put(Frank)新增后: [小崔, 孔蓝, 大翔, 白歌, Frank]
结论:被访问/更新的 key 移到尾部,头部始终是最久未访问的
--- 补充验证:containsValue 对顺序的影响 ---
containsValue 前: [A, B, C]
containsValue 后: [A, B, C]
结论:containsValue 不改变访问顺序(不触发节点移动)
示例三:LinkedHashMap 作为简易 FIFO 缓存(插入顺序模式)
import java.util.*;
/**
* 场景:飞翔科技的"操作日志缓冲区"需要按插入顺序保留最近 100 条日志,
* 超出容量时按 FIFO(先进先出)淘汰——即淘汰最早写入的日志。
* 这是 LinkedHashMap 在插入顺序模式下的另一个经典应用。
*/
class FIFOCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public FIFOCache(int maxSize) {
// accessOrder = false(默认),插入顺序模式
super(16, 0.75f, false);
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
boolean shouldRemove = size() > maxSize;
if (shouldRemove) {
System.out.println(" [FIFO淘汰] " + eldest.getKey() + " → " + eldest.getValue());
}
return shouldRemove;
}
}
public class FeiXiangFIFOLogBuffer {
public static void main(String[] args) {
System.out.println("=== 飞翔科技操作日志缓冲区(容量=4,FIFO 策略) ===\n");
FIFOCache<Integer, String> logBuffer = new FIFOCache<>(4);
// 模拟操作日志写入
logBuffer.put(1, "大翔 登录系统");
logBuffer.put(2, "白歌 创建项目");
logBuffer.put(3, "小崔 提交代码");
logBuffer.put(4, "孔蓝 执行测试");
System.out.println("初始 4 条日志: " + logBuffer.keySet());
// 第 5 条日志 → 触发 FIFO 淘汰(第 1 条被移除)
System.out.println("\n新增第 5 条日志:");
logBuffer.put(5, "Frank 发送周报");
System.out.println("当前日志序号: " + logBuffer.keySet());
// 第 6 条日志 → 淘汰第 2 条
System.out.println("\n新增第 6 条日志:");
logBuffer.put(6, "黄俪 更新页面");
System.out.println("当前日志序号: " + logBuffer.keySet());
// 展示完整日志内容
System.out.println("\n=== 当前日志缓冲 ===");
for (Map.Entry<Integer, String> entry : logBuffer.entrySet()) {
System.out.println(" [" + entry.getKey() + "] " + entry.getValue());
}
}
}
=== 飞翔科技操作日志缓冲区(容量=4,FIFO 策略) ===
初始 4 条日志: [1, 2, 3, 4]
新增第 5 条日志:
[FIFO淘汰] 1 → 大翔 登录系统
当前日志序号: [2, 3, 4, 5]
新增第 6 条日志:
[FIFO淘汰] 2 → 白歌 创建项目
当前日志序号: [3, 4, 5, 6]
=== 当前日志缓冲 ===
[3] 小崔 提交代码
[4] 孔蓝 执行测试
[5] Frank 发送周报
[6] 黄俪 更新页面
易错场景
反例一:accessOrder 模式下遍历时 get 导致 ConcurrentModificationException
孔蓝在测试 LRU 缓存时写了一段遍历代码,结果抛出了异常:
// ❌ 错误:accessOrder 模式下遍历时调用 get
LinkedHashMap<String, String> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("A", "1");
map.put("B", "2");
map.put("C", "3");
for (String key : map.keySet()) {
if (key.equals("A")) {
map.get("B"); // get 触发 afterNodeAccess → 修改 modCount → 迭代器检测到并发修改!
}
}
// 抛出 ConcurrentModificationException
原理:afterNodeAccess 方法会执行 ++modCount,而增强 for 循环底层使用的迭代器在每次 next() 时都会检查 modCount 是否与创建时一致。不一致则立即抛出 ConcurrentModificationException。
纠正:
// ✅ 方案一:遍历前收集需要访问的 key
List<String> keysToAccess = new ArrayList<>();
for (String key : map.keySet()) {
if (key.equals("A")) keysToAccess.add("B");
}
for (String key : keysToAccess) {
map.get(key);
}
// ✅ 方案二:使用迭代器的 remove 或显式索引遍历
// ✅ 方案三:使用 ConcurrentHashMap + 自定义 LRU 实现(多线程场景)
反例二:未重写 removeEldestEntry 导致缓存无限增长
小崔第一次实现 LRU 缓存时忘了最重要的一步:
// ❌ 错误:构造时设置了 accessOrder=true,但忘记重写 removeEldestEntry
LinkedHashMap<String, String> cache = new LinkedHashMap<>(16, 0.75f, true);
// ... 疯狂 put ...
// 结果:缓存永远不会自动淘汰,等同于普通 LinkedHashMap!
原理:removeEldestEntry 的默认实现永远返回 false(JDK 文档明确说明)。不重写它,accessOrder=true 只是改变了遍历顺序,但不会触发任何淘汰行为。LRU 缓存的"淘汰"机制完全由 removeEldestEntry 提供。
纠正:
// ✅ 即使是最简单的 LRU 缓存也必须重写 removeEldestEntry
LinkedHashMap<String, String> cache = new LinkedHashMap<String, String>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 100; // 最大 100 条
}
};
反例三:插入顺序模式下 put 已有 key 误以为会改变顺序
// ❌ 误区:认为 put 已有 key 会将该 key 移到尾部
LinkedHashMap<String, String> map = new LinkedHashMap<>(); // 默认插入顺序
map.put("A", "1");
map.put("B", "2");
map.put("A", "updated"); // 更新已存在 key
System.out.println(map.keySet()); // [A, B] —— A 仍在 B 前面!
// 很多人误以为输出应该是 [B, A]
原理:在插入顺序模式下,put 一个已存在的 key 时,LinkedHashMap 会先找到已有节点,更新其 value,但不会改变该节点在双向链表中的位置。这是因为插入顺序的语义是"记录首次插入时的顺序"。
反例四:多线程环境下使用 LinkedHashMap 的 LRU 缓存
// ❌ 错误:多个线程同时访问/修改同一个 LinkedHashMap
final LinkedHashMap<String, String> cache = new LinkedHashMap<String, String>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 100;
}
};
// 线程1:查询
new Thread(() -> cache.get("key1")).start();
// 线程2:插入(可能触发淘汰)
new Thread(() -> cache.put("key2", "value2")).start();
// 线程3:遍历
new Thread(() -> {
for (String key : cache.keySet()) { /* ... */ }
}).start();
// 结果:数据不一致、ConcurrentModificationException、甚至死循环(链表指针损坏)
纠正:
// ✅ 方案一:使用 Collections.synchronizedMap 包装
Map<String, String> syncCache = Collections.synchronizedMap(cache);
// ✅ 方案二:使用 ConcurrentHashMap + 自定义 LRU 实现
// ✅ 方案三:使用 Guava Cache(内置线程安全的 LRU)
面试考点
Q1:LinkedHashMap 与 HashMap 的区别?遍历性能如何?
维度 LinkedHashMap HashMap 底层结构 HashMap + 双向链表 数组 + 链表 + 红黑树 遍历顺序 可预测(插入顺序或访问顺序) 不可预测(取决于哈希分布) 内存占用 更大(每个节点多 2 个引用) 更小 get/put 性能 基本相同(链表操作是 O(1)) 相同 遍历性能 更快(沿双向链表遍历所有节点,只遍历有效节点,跳过空桶) 需要遍历 table 数组的所有桶(包括空桶) null 键/值 允许 允许 关键认知:LinkedHashMap 的遍历性能实际优于 HashMap——因为它只需沿双向链表遍历,时间复杂度 O(size);而 HashMap 需要遍历整个 table 数组(含空桶),时间复杂度 O(capacity)。
Q2:LinkedHashMap 如何实现 LRU 缓存?核心步骤是什么?
实现 LRU 缓存只需三步: ① 构造方法:传入
accessOrder = true,启用访问顺序模式。 ② 重写 removeEldestEntry:当size() > maxCapacity时返回true,触发自动淘汰。 ③ 正常使用:get/put 操作会自动将访问的条目移到链表尾部;新增条目时若超出容量,afterNodeInsertion回调removeEldestEntry,从链表头部移除最久未使用的条目。额外说明:
removeEldestEntry是特意设计为protected方法的——它是模板方法模式中的"钩子方法",供子类扩展。JDK 文档明确说明该方法的存在意义就是为 LRU 缓存提供扩展点。
Q3:插入顺序和访问顺序有何区别?各自适用场景?
插入顺序(
accessOrder=false,默认):遍历顺序等于元素首次插入的顺序;put 已有 key 不改变位置。适用于需要保留"首次出现顺序"的场景,如配置项覆盖(后来的不改变位置)、操作历史记录、FIFO 队列缓存。访问顺序(
accessOrder=true):遍历顺序按最近访问排序;get 或 put(更新已有 key)将条目移到链表尾部。适用于 LRU 缓存、最近使用列表、用户活跃度追踪等场景。核心区别在于
afterNodeAccess回调:访问顺序模式下每次访问都会触发节点重新排队,同时增加modCount(导致迭代器 fail-fast)。
Q4:LinkedHashMap 的 containsValue 为什么比 HashMap 快?
HashMap 的
containsValue需要双重循环:外层遍历 table 数组的每个桶,内层遍历每个桶内的链表/红黑树节点——时间复杂度 O(capacity + size),因为必须检查所有桶(包括空桶)。LinkedHashMap 重写了
containsValue,改为沿双向链表遍历——时间复杂度 O(size),因为双向链表只连接有效节点,跳过了所有空桶。这是"维护遍历顺序"带来的附加收益:双向链表本身就是一张"有效节点的索引表"。
Q5:LinkedHashMap 的 Entry 节点为什么同时有 next 和 before/after 指针?
next指针服务于 HashMap 的桶内冲突解决——当两个 key 的 hash 值落在同一个桶时,它们通过next指针形成链表(或红黑树,此时为TreeNode)。before/after指针服务于 LinkedHashMap 的全局顺序维护——无论节点落在哪个桶,它们都通过before/after串成一条全局有序链表。这两个链表服务于完全不同的目的,互相独立但也互相关联:删除节点时,既要断开桶内链表(修改
next),也要断开全局双向链表(修改before/after),同时还要处理红黑树退化、rehash 迁移等复杂情况。LinkedHashMap 通过重写newNode、afterNodeRemoval等模板方法,在 HashMap 的钩子中悄悄完成了这些额外维护工作——这正是模板方法设计模式的经典应用。