LinkedHashMap讲解与LRU缓存

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)

扩容步骤

  1. 创建新数组,容量翻倍
  2. 遍历双向链表(不是遍历旧数组),重新计算每个元素的位置
  3. 将元素放入新数组
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 的区别?

  1. LinkedHashMap 继承 HashMap
  2. LinkedHashMap 额外维护双向链表保证顺序
  3. LinkedHashMap 遍历顺序确定,HashMap 不确定
  4. LinkedHashMap 内存开销略大(多了两个指针)

Q4: 如何用 LinkedHashMap 实现 LRU 缓存?

  1. 使用 accessOrder = true 构造
  2. 重写 removeEldestEntry() 方法或手动检查大小
  3. 超出容量时删除链表头部元素

九、总结

LinkedHashMap 巧妙地结合了哈希表和双向链表:

  • 哈希表提供 O(1) 的快速存取
  • 双向链表维护元素顺序

这种设计使得 LinkedHashMap 既保持了 HashMap 的高效,又能按顺序遍历,是实现 LRU 缓存等场景的理想选择。

相关推荐
zsd_312 小时前
npm指定本地缓存、安装包、仓库路径
前端·缓存·npm·node.js·私服·安装包·本地
熏鱼的小迷弟Liu2 小时前
【Redis】Redis为什么快?
数据库·redis·缓存
进击的小菜鸡dd2 小时前
互联网大厂Java面试:微服务、电商场景下的全栈技术问答与解析
java·spring boot·缓存·微服务·消息队列·日志·电商
短剑重铸之日2 小时前
《7天学会Redis》Day 7 - Redisson 全览
java·数据库·redis·后端·缓存·redission
v***59835 小时前
redis 使用
数据库·redis·缓存
acaad12 小时前
Redis下载与安装(Windows)
数据库·redis·缓存
超级种码13 小时前
Redis:Redis 数据类型
数据库·redis·缓存
产幻少年16 小时前
redis位图
数据库·redis·缓存
短剑重铸之日17 小时前
《7天学会Redis》Day 4 - 高可用架构设计与实践
数据库·redis·缓存