LRU 缓存入门:它是什么、怎么用、面试怎么手写

LRU 缓存入门:它是什么、怎么用、面试怎么手写

📌 先说结论

LRU(Least Recently Used,最近最少使用)是一种缓存淘汰策略 :缓存满了装不下新数据时,淘汰最长时间没被使用过的那个。

它背后的直觉很朴素:刚用过的数据,大概率马上还会再用。所以"最近用过的"留下,"很久没碰的"请出去。

这篇讲三件事:它的核心思路(哈希表 + 双向链表)、怎么用一行配置实现、面试手写版怎么写。


一、为什么需要"淘汰策略"

缓存的空间是有限的,不可能把所有数据都存进来。当缓存满了又来了新数据,就得腾地方------腾谁? 这就是淘汰策略要回答的问题。

常见的几种策略:

策略 淘汰谁 一句话评价
FIFO 最先进入的 只看出场顺序,不管有没有被用过
LFU 使用频率最低的 要额外维护计数器,且老数据 frequency 高很难被淘汰
LRU 最久未被使用的 折中最优,工业界最常用

LRU 胜出的原因:它只用"上次使用时间"这一个信息,就能捕捉到"热点数据会反复被用"这个规律,而且实现可以做到 O(1)。


二、核心思路:哈希表 + 双向链表

LRU 要支持的两个操作,都要求 O(1):

  • get(key):查缓存,并且把这条数据标记为"刚被使用";
  • put(key, value):写入,满了就先淘汰最旧的。

单一数据结构做不到:哈希表查找快,但无法维护使用顺序;链表能维护顺序,但查找是 O(n)。

答案是把两个拼起来:

复制代码
哈希表:key → 链表节点          (负责 O(1) 定位)
双向链表:头 ─→ 新 ─→ 旧 ─→ 尾   (负责维护使用顺序)
  • get 命中:哈希表 O(1) 找到节点 → 把节点摘下来挂到链表头部(双向链表摘除/插入都是 O(1));
  • put 新数据 :链表头插入 + 哈希表登记;若超容量,删掉链表尾部节点(那就是最久没用的)+ 哈希表移除。

两个"为什么"是面试高频:

  1. 为什么用双向链表不用单链表? 因为删除一个已知节点,需要拿到它的前驱------单链表找前驱要 O(n),双向链表 O(1)。
  2. 为什么还要哈希表? 链表定位节点是 O(n),哈希表补上 O(1) 定位。

实测(容量 3 的 LRU):

复制代码
放入1,2,3 → 访问1 → 放入4
key1=A  key2=null(已淘汰) key3=C  key4=D

1 被访问过所以保住了,最久没用过的 2 被淘汰------行为完全符合定义。


三、Java 里一行配置的实现

Java 的 LinkedHashMap 天生支持按访问顺序排序,加一个回调就是现成的 LRU:

java 复制代码
public class LRUCache<K, V> {
    private final Map<K, V> map;

    public LRUCache(int capacity) {
        // 第三个参数 accessOrder=true:每次 get 都把条目移到"最新"端
        this.map = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > capacity;    // 超容量时自动淘汰最旧条目
            }
        };
    }

    public V get(K key) { return map.get(key); }
    public void put(K key, V value) { map.put(key, value); }
}

LinkedHashMap 内部就是哈希表 + 双向链表 的组合,和我们第二节的思路一模一样。业务代码直接用它,一点毛病没有。


四、面试手写版:脱掉 LinkedHashMap

面试常要求"不用 LinkedHashMap 手写",考察你是否真的懂结构。核心就是自己维护那两个部件:

java 复制代码
public class LRUCache<K, V> {

    private static class Node<K, V> {
        K key; V value;
        Node<K, V> prev, next;      // 双向指针
        Node(K key, V value) { this.key = key; this.value = value; }
    }

    private final int capacity;
    private final Map<K, V> index = new HashMap<>();   // key → value(简化版)
    private final Node<K, V> head = new Node<>(null, null);  // 哨兵头
    private final Node<K, V> tail = new Node<>(null, null);  // 哨兵尾

    public LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    public V get(K key) {
        Node<K, V> node = find(key);
        if (node == null) return null;
        moveToFront(node);            // 刚被用过 → 移到最前
        return node.value;
    }

    public void put(K key, V value) {
        Node<K, V> node = find(key);
        if (node != null) {
            node.value = value;
            moveToFront(node);
            return;
        }
        if (index.size() >= capacity) {
            removeLast();             // 淘汰尾部:最久未使用
        }
        Node<K, V> fresh = new Node<>(key, value);
        insertFront(fresh);
        index.put(key, value);
    }

    // ------ 链表操作(双向链表的指针搬运,纯体力活)------
    private void moveToFront(Node<K, V> node) {
        node.prev.next = node.next;   // 从原位置摘下
        node.next.prev = node.prev;
        insertFront(node);            // 插到最前
    }

    private void insertFront(Node<K, V> node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    private void removeLast() {
        Node<K, V> last = tail.prev;
        last.prev.next = tail;
        tail.prev = last.prev;
        index.remove(last.key);
    }

    private Node<K, V> find(K key) {
        // 简化演示:真实实现应是 哈希表 key→Node,这里用遍历示意
        for (Node<K, V> n = head.next; n != tail; n = n.next) {
            if (Objects.equals(n.key, key)) return n;
        }
        return null;
    }
}

💡 注意 :这个手写版的 find 为了篇幅用了遍历(O(n)),真实手写应该把哈希表的 value 设为 Node 引用 (Map<K, Node<K,V>>),get 时 O(1) 拿到节点再搬运。面试时如果被追问,要能说出这一层。


五、它用在哪

场景 说明
Redis 内存淘汰策略 allkeys-lru / volatile-lru(近似 LRU 实现)
LeetCode 146 LRU 缓存的经典原题,高频面试题
各种本地缓存 Guava Cache / Caffeine 都支持按 LRU 或其变种淘汰
操作系统页面置换 LRU 是经典页面置换算法之一(实现成本高,实际用时钟近似)

六、面试常问的 4 个问题

1. LRU 的核心数据结构?为什么?

答:哈希表 + 双向链表。哈希表负责 O(1) 定位节点,双向链表负责 O(1) 维护使用顺序(头部最新、尾部最旧)。链表必须双向,因为删除节点需要 O(1) 拿到前驱。

2. get 和 put 的时间复杂度?

答:都是 O(1)。get 命中后要把节点移到头部,摘除和插入都是 O(1) 的指针操作。

3. Java 里怎么快速实现?

答:LinkedHashMap 构造时传 accessOrder=true,并重写 removeEldestEntry() 在超容量时返回 true。它的内部结构本身就是哈希表 + 双向链表。

4. LRU 有什么缺点?

答:偶发性的一次冷数据访问会把热点数据挤出去(缓存污染);另外严格 LRU 每次访问都要移动节点。工业界常用近似 LRU (如 Redis 的抽样淘汰)或 LRU-K / W-TinyLFU(Caffeine 用的)来缓解。


七、总结

  1. LRU = 淘汰最久未使用的,直觉依据是"刚用过的会被再用"。
  2. 结构 = 哈希表 + 双向链表,两者合体才能 get/put 全 O(1)。
  3. Java 现成实现 :LinkedHashMap(accessOrder=true) + removeEldestEntry。
  4. 手写考点:哨兵头尾节点简化边界、双向指针的搬运顺序、淘汰永远删尾部。

一句口诀:

哈希定位,链表排序;新来去头部,淘汰删尾部。


如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~

关注我,后续分享更多 Java 核心知识点和实战技巧!

相关推荐
꧁细听勿语情꧂39 分钟前
直接选择排序,堆排序,冒泡排序,递归快速排序、hoare版本、挖坑法、lomuto前后指针,非递归快速排序
c语言·开发语言·数据结构·算法
文丑颜不良啊1 小时前
LeetCode2741~2760题目解析
java·leetcode
金銀銅鐵10 小时前
带您看懂字节码指令(二):空循环里有什么
java·后端·ai编程
吴名氏.12 小时前
Java转AI Agent教程(第十三周)混合检索与重排
java·数据库·人工智能·ai·ai agent
.痞老板.13 小时前
java ee--多线程(进阶)
java·java-ee
Pniubi13 小时前
关于算法用现成数据结构和手撕
数据结构
深入云栈14 小时前
Netty 4.2.x 源码深度解析 (二十三):ByteBuf 核心设计 —— 索引、类型与零拷贝
java·后端·架构
聪明的笨猪猪15 小时前
企业知识库AI架构选型:落地导向的架构决策与组件清单
经验分享·面试·架构·embedding·数据库架构
wang_shu_mo_ran16 小时前
MyBatis 从入门到进阶(一)
java·spring·mybatis