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 新数据 :链表头插入 + 哈希表登记;若超容量,删掉链表尾部节点(那就是最久没用的)+ 哈希表移除。
两个"为什么"是面试高频:
- 为什么用双向链表不用单链表? 因为删除一个已知节点,需要拿到它的前驱------单链表找前驱要 O(n),双向链表 O(1)。
- 为什么还要哈希表? 链表定位节点是 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 用的)来缓解。
七、总结
- LRU = 淘汰最久未使用的,直觉依据是"刚用过的会被再用"。
- 结构 = 哈希表 + 双向链表,两者合体才能 get/put 全 O(1)。
- Java 现成实现 :
LinkedHashMap(accessOrder=true)+removeEldestEntry。 - 手写考点:哨兵头尾节点简化边界、双向指针的搬运顺序、淘汰永远删尾部。
一句口诀:
哈希定位,链表排序;新来去头部,淘汰删尾部。
如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~
关注我,后续分享更多 Java 核心知识点和实战技巧!