LinkedHashMap 实现详解
一、什么是 LinkedHashMap?
LinkedHashMap 是 Java 集合框架中的一个重要数据结构,它继承自 HashMap,在 HashMap 的基础上额外维护了一个双向链表来记录元素的顺序。
核心特点
| 特性 | 说明 |
|---|---|
| 有序性 | 可以按插入顺序 或访问顺序遍历元素 |
| 时间复杂度 | 增删改查都是 O(1) |
| 允许 null | key 和 value 都可以为 null |
| 非线程安全 | 多线程环境需要外部同步 |
与 HashMap 的对比
HashMap: 无序,遍历顺序不确定
LinkedHashMap: 有序,遍历顺序 = 插入顺序 或 访问顺序
二、数据结构设计
2.1 整体架构
LinkedHashMap = 哈希表 + 双向链表
┌─────────────────────────────────────────────────────────────────┐
│ LinkedHashMap │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 哈希表(数组 + 链表):实现 O(1) 的快速存取 │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← buckets 数组 │
│ └───┴─┬─┴───┴─┬─┴───┴───┴─┬─┴───┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ [A:1] [C:3] [E:5] ← 哈希冲突时形成链表 │
│ │ │ │
│ ▼ ▼ │
│ [B:2] [D:4] │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 双向链表:维护元素顺序(插入顺序 或 访问顺序) │
│ │
│ head ←→ [A:1] ←→ [B:2] ←→ [C:3] ←→ [D:4] ←→ [E:5] ←→ tail │
│ │
│ head/tail 是哨兵节点,不存储实际数据 │
│ │
└─────────────────────────────────────────────────────────────────┘
2.2 节点结构 (Entry)
关键点:每个节点同时存在于两个结构中!
java
static class Entry<K, V> {
final int hash; // 哈希值(缓存避免重复计算)
final K key; // 键
V value; // 值
Entry<K, V> next; // 哈希桶链表指针(处理哈希冲突)
Entry<K, V> before; // 双向链表 - 前驱指针
Entry<K, V> after; // 双向链表 - 后继指针
}
图示:
┌─────────────────┐
│ Entry │
├─────────────────┤
│ hash: 12345 │
│ key: "apple" │
│ value: 1 │
├─────────────────┤
哈希桶链表 ←── │ next ──────────►│ ──► 同一桶的下一个节点
├─────────────────┤
双向链表前驱 ←── │ before ─────────│ ──► 上一个插入的节点
双向链表后继 ←── │ after ──────────│ ──► 下一个插入的节点
└─────────────────┘
三、核心算法详解
3.1 哈希函数
java
private int hash(Object key) {
if (key == null) return 0;
int h = key.hashCode();
return h ^ (h >>> 16); // 扰动函数
}
为什么要用扰动函数?
假设 hashCode = 0b 1010_1100_0011_0101_0000_0000_0000_0000
直接使用: index = hash & (n-1)
只有低位参与运算,高位被浪费
使用扰动函数: h ^ (h >>> 16)
将高16位与低16位混合
让高位也参与到索引计算中
减少哈希冲突
3.2 索引计算
java
int index = (table.length - 1) & hash;
为什么用位运算而不是取模?
前提:table.length 必须是 2 的幂次方
当 n = 2^k 时:
hash % n 等价于 hash & (n-1)
例如 n = 16 (2^4):
hash % 16 = hash & 15 = hash & 0b1111
位运算比取模快很多!
3.3 put 操作流程
┌─────────────────┐
│ put(key, val) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算 hash 值 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算数组索引 │
│ index = hash & │
│ (length - 1) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 遍历该位置的链表 │
└────────┬────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 找到相同 key │ │ 没有找到 key │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 更新 value │ │ 创建新节点 │
│ │ │ 头插法插入桶 │
│ 若 accessOrder │ │ 添加到双向链表 │
│ 则移动到链表尾 │ │ 尾部 │
└────────┬────────┘ └────────┬────────┘
│ │
│ ▼
│ ┌─────────────────┐
│ │ size > threshold│
│ │ ? │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
│ │ Yes │ No
│ ▼ │
│ ┌─────────────────┐ │
│ │ 扩容 resize │ │
│ └─────────────────┘ │
│ │ │
└────────────────────┴─────────────────┘
│
▼
┌─────────────────┐
│ 返回 │
└─────────────────┘
3.4 双向链表操作
添加到尾部 (linkToLast)
操作前:
head ←→ [A] ←→ [B] ←→ tail
↑
last
操作后 (插入 [C]):
head ←→ [A] ←→ [B] ←→ [C] ←→ tail
java
private void linkToLast(Entry<K, V> entry) {
Entry<K, V> last = tail.before; // 1. 找到当前最后一个节点
entry.before = last; // 2. 新节点的前驱指向 last
entry.after = tail; // 3. 新节点的后继指向 tail
last.after = entry; // 4. last 的后继指向新节点
tail.before = entry; // 5. tail 的前驱指向新节点
}
移除节点 (unlinkEntry)
操作前:
... ←→ [A] ←→ [B] ←→ [C] ←→ ...
↑ ↑ ↑
before entry after
操作后:
... ←→ [A] ←→ [C] ←→ ...
java
private void unlinkEntry(Entry<K, V> entry) {
entry.before.after = entry.after; // 前驱的后继 = 后继
entry.after.before = entry.before; // 后继的前驱 = 前驱
}
3.5 扩容机制 (resize)
触发条件 :size > threshold (threshold = capacity * loadFactor)
扩容步骤:
- 创建新数组,容量翻倍
- 遍历双向链表(不是遍历旧数组),重新计算每个元素的位置
- 将元素放入新数组
java
private void resize() {
int newCapacity = table.length << 1; // 容量翻倍
Entry<K, V>[] newTable = new Entry[newCapacity];
// 遍历双向链表重新分配(LinkedHashMap 的优势)
for (Entry<K, V> e = head.after; e != tail; e = e.after) {
int index = (newCapacity - 1) & e.hash;
e.next = newTable[index]; // 头插法
newTable[index] = e;
}
table = newTable;
}
为什么遍历双向链表而不是遍历旧数组?
- 遍历双向链表:正好访问 size 个元素
- 遍历旧数组:需要遍历 oldCapacity 个桶,可能很多是空的
四、两种顺序模式
4.1 插入顺序 (accessOrder = false)
默认模式。元素按照插入的先后顺序排列。
java
MyLinkedHashMap<String, Integer> map = new MyLinkedHashMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 遍历顺序: C → A → B (按插入顺序)
4.2 访问顺序 (accessOrder = true)
每次访问(get/put)一个元素,就把它移到链表尾部。
java
MyLinkedHashMap<String, Integer> map = new MyLinkedHashMap<>(16, 0.75f, true);
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
// 初始顺序: A → B → C
map.get("A"); // 访问 A
// 访问后顺序: B → C → A (A 被移到最后)
这就是实现 LRU 缓存的核心思想!
五、实现 LRU 缓存
LRU (Least Recently Used) 最近最少使用缓存:当缓存满时,淘汰最久未使用的元素。
java
public class LRUCache<K, V> extends MyLinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(16, 0.75f, true); // 使用访问顺序模式
this.maxSize = maxSize;
}
@Override
public V put(K key, V value) {
V result = super.put(key, value);
// 如果超出容量,删除最老的元素(链表头部)
if (size() > maxSize) {
Entry<K, V> oldest = getFirst();
if (oldest != null) {
remove(oldest.getKey());
}
}
return result;
}
}
使用示例:
java
LRUCache<String, Integer> cache = new LRUCache<>(3); // 最多存3个
cache.put("A", 1); // [A]
cache.put("B", 2); // [A, B]
cache.put("C", 3); // [A, B, C]
cache.get("A"); // 访问A,A移到最后: [B, C, A]
cache.put("D", 4); // 容量超出,删除最老的B: [C, A, D]
六、时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| put | O(1) 平均 | 哈希定位 + 链表尾部插入 |
| get | O(1) 平均 | 哈希定位 |
| remove | O(1) 平均 | 哈希定位 + 链表删除 |
| containsKey | O(1) 平均 | 哈希定位 |
| 遍历 | O(n) | 遍历双向链表 |
注意:最坏情况下(所有元素哈希冲突)会退化为 O(n)。JDK8+ 的实现在链表长度超过 8 时会转换为红黑树,保证最坏 O(log n)。
七、与 JDK 实现的对比
| 特性 | 本实现 | JDK LinkedHashMap |
|---|---|---|
| 哈希冲突处理 | 链表 | 链表 + 红黑树 |
| 迭代器 | 简单遍历 | 完整 Iterator 实现 |
| 序列化 | 不支持 | 支持 |
| removeEldestEntry | 不支持 | 支持(用于 LRU) |
| 线程安全 | 否 | 否 |
八、常见面试题
Q1: LinkedHashMap 如何保证有序?
答:通过维护一个双向链表。每个节点除了存储 key/value 和 next 指针外,还有 before/after 指针连接前后节点。
Q2: 访问顺序模式下,哪些操作会改变顺序?
答 :get()、put()(更新已存在的 key)、putIfAbsent() 等访问操作都会将节点移到链表尾部。
Q3: LinkedHashMap 和 HashMap 的区别?
答:
- LinkedHashMap 继承 HashMap
- LinkedHashMap 额外维护双向链表保证顺序
- LinkedHashMap 遍历顺序确定,HashMap 不确定
- LinkedHashMap 内存开销略大(多了两个指针)
Q4: 如何用 LinkedHashMap 实现 LRU 缓存?
答:
- 使用
accessOrder = true构造 - 重写
removeEldestEntry()方法或手动检查大小 - 超出容量时删除链表头部元素
九、总结
LinkedHashMap 巧妙地结合了哈希表和双向链表:
- 哈希表提供 O(1) 的快速存取
- 双向链表维护元素顺序
这种设计使得 LinkedHashMap 既保持了 HashMap 的高效,又能按顺序遍历,是实现 LRU 缓存等场景的理想选择。