你的缓存该“退伍”了:一文彻底吃透 LRU 缓存淘汰算法

168 阅读7分钟

别再死记硬背了,从双向链表到 HashMap,手写一个工业级 LRU 其实很简单


前言:当缓存满了,该踢谁出去?

想象一下这个场景:你的手机内存只有 128GB,但你想装的 App、拍的照片、缓存的视频远远超过这个数。操作系统每天都要面临一个“灵魂拷问”——新数据要存进来,到底该删掉哪个旧数据?

在计算机世界里,这个问题叫做缓存淘汰策略。而今天我们要聊的 LRU(Least Recently Used,最近最少使用)正是其中最经典、最接地气的一种思路,它的核心哲学非常朴素:

如果一个数据最近被访问过,那么它将来被访问的概率也更高;反之,如果一个数据很久没被碰过,那它大概率可以“卷铺盖走人”了。

LRU 的身影无处不在——Redis 的内存淘汰机制、MySQL 的 Buffer Pool、操作系统的页面置换、浏览器的历史记录……几乎所有需要“有限空间存无限数据”的场景,都有它的影子。

今天,我们不只讲概念,我会带你从零手写一个完整的 LRU 缓存,把里面的设计细节、坑点、优化思路全都掰开揉碎讲清楚。


一、从需求出发:LRU 到底要干什么?

我们先明确一下 LRU 缓存的核心功能需求:

  1. 固定容量:缓存有上限 n,满了就不能再随意往里塞。
  2. 快速读取:get(key) —— 如果 key 存在,返回 value,并且这次访问要让它变成“最近使用过”;如果不存在,返回 -1。
  3. 快速写入:put(key, value) —— 如果 key 已存在,更新 value 并标记为最近使用;如果不存在,插入新节点,但若容量超限,淘汰掉最久未使用的那一个。
  4. 所有操作的时间复杂度都必须是 O(1)。

最后这一点是硬骨头。如果只是用数组或普通链表,查找和移动都得 O(n),在大数据量下根本扛不住。那怎么办?——空间换时间。


二、数据结构选型:为什么是“双向链表 + HashMap”?

我们先从直觉出发。

方案一:单用数组

用数组按访问时间顺序存储 key,每次访问就把该元素移到末尾,淘汰时删掉头部。但问题是——在数组中间删除/移动元素是 O(n) 的,而且查找一个 key 的位置还得遍历,太慢。

方案二:单用链表

用链表确实可以 O(1) 删除和移动节点(前提是知道节点位置),但查找 key 对应的节点仍然需要遍历,还是 O(n)。

方案三:HashMap + 双向链表 —— 完美组合

  • HashMap:负责 O(1) 查找,key → 节点引用。
  • 双向链表:负责维护“新旧程度”的顺序,头部是最久未用,尾部是最近刚用。

为什么一定要双向链表?因为我们要在 O(1) 时间内删除任意节点(比如移动节点到尾部时,需要知道它的前驱和后继),单链表做不到这一点。

于是,经典结构跃然纸上:

+--------+     +----------+     +----------+     +----------+     +--------+
|  head  | <-> |  Node A  | <-> |  Node B  | <-> |  Node C  | <-> |  tail  |
|(dummy) |     | (最近最少)|     | (中间)   |     | (最近最多)|     |(dummy) |
+--------+     +----------+     +----------+     +----------+     +--------+
   ^                                                                   ^
   |                                                                   |
   +------------------ HashMap: key -> Node引用 -----------------------+

两个哨兵节点(dummy head/tail)让边界处理变得干干净净,再也不用写一堆 if (node == null) 的判断。


三、代码实战:从 0 到 1 实现一个工业级 LRU

下面我们一步步实现,我会边写边解释为什么这么设计。

第一步:定义节点类

class ListNode {
    constructor(key, value) {
        this.key = key
        this.value = value
        this.prev = null
        this.next = null
    }
}

每个节点既存 key 也存 value,key 是为了在淘汰时能从 HashMap 中删除对应的条目。

第二步:LRU 主框架

class LRUCache {
    constructor(capacity) {
        this.capacity = capacity
        this.map = new Map()
        // 初始化两个哨兵节点,它们不存实际数据
        this.head = new ListNode(null, null)
        this.tail = new ListNode(null, null)
        this.head.next = this.tail
        this.tail.prev = this.head
    }
    // ... 后续方法
}

这里的 map 存的是 key -> node 的映射,保证 get 能瞬间拿到节点。

第三步:核心辅助方法 —— 链表的增删改

先把最底层的链表操作写好,后面所有逻辑都基于它们。

// 删除链表中的任意节点(O(1))
removeNode(node) {
    node.prev.next = node.next
    node.next.prev = node.prev
}

// 将节点添加到链表尾部(紧挨着 tail 哨兵之前)
addToTail(node) {
    node.prev = this.tail.prev
    node.next = this.tail
    this.tail.prev.next = node
    this.tail.prev = node
}

// 把某个节点移动到尾部 —— 先删后加
moveToTail(node) {
    this.removeNode(node)
    this.addToTail(node)
}

这三个方法虽短,但却是整个 LRU 的“发动机”。注意顺序:addToTail 里一定要先改新节点的前后指针,再改旧节点的指针,否则容易丢失引用。

第四步:get 方法 —— 访问即“续命”

get(key) {
    if (!this.map.has(key)) {
        return -1
    }
    const node = this.map.get(key)
    this.moveToTail(node)  // 这次访问让它变成“最新”
    return node.value
}

逻辑简单到只有三行:查不到返回 -1,查到了就移到尾部并返回值。

第五步:put 方法 —— 插入、更新、淘汰三位一体

put(key, value) {
    if (this.map.has(key)) {
        // 场景1:更新已有 key
        const node = this.map.get(key)
        node.value = value
        this.moveToTail(node)
    } else {
        // 场景2:插入新 key
        const node = new ListNode(key, value)
        this.map.set(key, node)
        this.addToTail(node)
        
        // 如果容量超标,淘汰最久未使用的(即 head 后面的那个)
        if (this.map.size > this.capacity) {
            this.removeHead()
        }
    }
}

removeHead() {
    const node = this.head.next   // 第一个真实节点
    this.removeNode(node)
    this.map.delete(node.key)     // 别忘了从 HashMap 里也删掉
}

这里有个容易踩的坑:必须先判断 key 是否存在,再决定是更新还是插入。如果先检查容量再更新,会导致明明 key 已存在却误判为超容,从而错误地淘汰其他节点。


四、手写模拟:用例子走一遍流程

假设容量 n = 3,我们按顺序执行:

  1. put('A', 1) → 链表:[A]
  2. put('B', 2) → [A, B]
  3. put('C', 3) → [A, B, C]
  4. get('A') → A 被移到尾部,链表变成 [B, C, A]
  5. put('D', 4) → 容量超了,移除头部 B,链表变成 [C, A, D]

你会发现,被淘汰的永远是那个最久没被“翻牌子”的,这就是 LRU 的精髓。


五、复杂度分析与工程化思考

  • 时间复杂度:get 和 put 都是 O(1),因为 HashMap 查找是 O(1),链表增删改也是 O(1)。
  • 空间复杂度:O(n),存储 n 个节点和对应的 Map 映射。

但实战中,这个基础版本还有几个可以优化的点:

  1. 内存占用:每个节点都有前后指针,对于 int 类型的 value 来说,指针的开销甚至比数据本身还大。在 Java 中可以用 LinkedHashMap 直接开箱即用,在 Redis 中则用更紧凑的结构。
  2. 并发安全:上述代码在多线程环境下会出问题,需要加锁或使用 ConcurrentHashMap + 原子操作。
  3. 缓存污染:如果一次批量查询了大量冷数据,可能会把热数据挤出去。解决方案有 LRU-K、Two-Queue 等变种。

六、延伸思考:LRU 的“亲戚们”

  • LFU(Least Frequently Used):看的是“访问频率”而非“访问时间”,用计数器来淘汰访问次数最少的。适合热点非常稳定的场景。
  • FIFO(先进先出):最简单的队列,但完全无视访问模式,命中率往往不如 LRU。
  • ARC(自适应缓存替换):同时维护 LRU 和 LFU,动态调整比例,性能更优但实现复杂。

七、写在最后:LRU 教会我们的不只是算法

写完这 200 多行代码,你会发现 LRU 其实就干了两件事:用 Map 保证速度,用链表保证顺序。它背后蕴含的是一种非常普世的资源管理哲学——有限空间里,把资源留给最有价值(最近被需要)的数据。

这不光适用于缓存,也适用于我们的生活:时间有限,精力有限,把注意力留给最近重要的事;手机桌面有限,把最常用的 App 放在最顺手的位置。LRU 不只是一个算法,更是一种关于“取舍”的智慧。

所以下次当你写缓存的时候,不妨想想这个简单的原则——最近用过的,别丢;很久没碰的,该放就放。

你的代码如此,你的人生亦如此。


附录:完整代码(无注释版)

class ListNode {
    constructor(key, value) {
        this.key = key
        this.value = value
        this.prev = null
        this.next = null
    }
}

class LRUCache {
    constructor(capacity) {
        this.capacity = capacity
        this.map = new Map()
        this.head = new ListNode(null, null)
        this.tail = new ListNode(null, null)
        this.head.next = this.tail
        this.tail.prev = this.head
    }

    get(key) {
        if (!this.map.has(key)) return -1
        const node = this.map.get(key)
        this.moveToTail(node)
        return node.value
    }

    put(key, value) {
        if (this.map.has(key)) {
            const node = this.map.get(key)
            node.value = value
            this.moveToTail(node)
        } else {
            const node = new ListNode(key, value)
            this.map.set(key, node)
            this.addToTail(node)
            if (this.map.size > this.capacity) {
                this.removeHead()
            }
        }
    }

    removeNode(node) {
        node.prev.next = node.next
        node.next.prev = node.prev
    }

    addToTail(node) {
        node.prev = this.tail.prev
        node.next = this.tail
        this.tail.prev.next = node
        this.tail.prev = node
    }

    moveToTail(node) {
        this.removeNode(node)
        this.addToTail(node)
    }

    removeHead() {
        const node = this.head.next
        this.removeNode(node)
        this.map.delete(node.key)
    }
}

如果这篇文章帮你彻底搞懂了 LRU,欢迎点赞收藏,也欢迎在评论区聊聊你在项目中是如何使用缓存的 —— 我们下篇见!🚀