常见缓存策略(一):LRU(最近最少使用)原理与实现
缓存的核心就一句话:把热数据留在内存里。但问题来了——内存有限,放不下全部数据。谁该留下?谁该滚蛋?
LRU 就是最常见的淘汰策略。它的逻辑很简单:如果一个数据最近被访问过,那么将来被访问的概率也更高。每次访问就把数据“提到”最前面,淘汰时从尾巴踢人。嗯,听上去就像排队的插队机制。
LRU 的工作原理
我习惯用一个比喻来理解 LRU。想象一个书架,只能放 5 本书。
- 你新买了一本书,放在最显眼的位置(最左边)。
- 每次你看完一本书,就把它放回最左边。
- 如果书架满了,最右边那本长时间没翻过的书,就被扔掉。
这就是 LRU——最近最少使用的书被淘汰。你想想看,这个策略背后的直觉其实挺符合人性的:常用的东西就该留着,不常用的就别占地方。
我曾经在项目中遇到过一个大坑:缓存雪崩的导火索,就是因为 LRU 实现有 bug,导致热点数据被频繁淘汰。那次事故后,我再也不敢轻视缓存策略的实现细节了。
两种常见的 LRU 实现方案
| 实现方式 | 核心数据结构 | 访问时间复杂度 | 淘汰时间复杂度 | 内存开销 |
|---|---|---|---|---|
| 双向链表 + 哈希表 | LinkedList + HashMap | O(1) | O(1) | 较高(节点指针) |
| 数组 + 时间戳 | Array + Timestamp | O(n) | O(n) | 较低 |
为什么要 O(1)?因为缓存本身就是为了高性能。如果每次访问还要遍历一遍数组,那就别谈性能优化了。所以,工业级实现基本都用双向链表 + 哈希表的组合。
核心思路:哈希表负责 O(1) 查找节点在链表中的位置,双向链表负责 O(1) 移动和删除节点。两者配合,完美。
自己动手实现一个 LRU 缓存
下面是我写的一个简化版实现。注意,为了让你看懂核心逻辑,我特意去掉了泛型和并发控制。生产环境千万别这么写。
class Node {
int key;
String value;
Node prev;
Node next;
public Node(int key, String value) {
this.key = key;
this.value = value;
}
}
public class LRUCache {
private int capacity;
private HashMap<Integer, Node> map;
private Node head, tail;
public LRUCache(int capacity) {
this.capacity = capacity;
map = new HashMap<>();
head = new Node(-1, ""); // 哨兵节点,简化边界处理
tail = new Node(-1, "");
head.next = tail;
tail.prev = head;
}
public String get(int key) {
// 没找到,返回 null
if (!map.containsKey(key)) {
return null;
}
// 找到了,移到头部
Node node = map.get(key);
removeNode(node);
addToHead(node);
return node.value;
}
public void put(int key, String value) {
// 已存在,更新值并移到头部
if (map.containsKey(key)) {
Node node = map.get(key);
node.value = value;
removeNode(node);
addToHead(node);
return;
}
// 缓存满了,淘汰尾部
if (map.size() >= capacity) {
Node toRemove = tail.prev;
removeNode(toRemove);
map.remove(toRemove.key);
}
// 插入新节点
Node newNode = new Node(key, value);
map.put(key, newNode);
addToHead(newNode);
}
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
}
小技巧:用两个哨兵节点(head 和 tail)来避免处理空指针。我刚开始写时总在边界条件上翻车,加哨兵后一下清爽了。
核心操作的 O(1) 分析
为什么这三个操作都是 O(1)?我们逐个拆开看。
- 查找:直接走 HashMap 的 get,O(1) 找到节点引用。
- 移动到头部:拆成两步——先从链表中摘除节点(O(1)),再插入到头部(O(1))。链表操作都是改几个指针。
- 淘汰尾部:tail.prev 就是最久未使用的节点,直接删除(O(1)),再在 HashMap 里移除(O(1))。
说白了,O(1) 的代价是你必须维护额外的指针和哈希表结构。这叫用空间换时间。
避坑指南:我曾经写了一个版本,在 removeNode 时只更新了 next 指针,忘了更新 prev 指针。结果链表断链,数据乱飞,调试了整整一个下午。切记:双向链表改指针时一定成对更新。
LRU 的并发安全问题
上面的代码在单线程下跑没问题,但生产环境几乎都是高并发。多线程同时读写 HashMap 和链表,会出什么乱子?
最常见的三种:
- HashMap 死循环:JDK 1.7 的 HashMap 在并发扩容时会导致死循环,CPU 直接飙到 100%。JDK 1.8 修复了这个问题,改为红黑树,但 ConcurrentHashMap 仍是首选。
- 链表指针混乱:两个线程同时执行 removeNode 和 addToHead,可能导致节点丢失或成环。
- 数据不一致:读线程看到 map 有 key,但链表里其实已经删了——虽然概率很小,但足够让你头疼。
解决方案也不复杂:简单场景上 synchronized 锁住整个方法;高并发场景用 ConcurrentHashMap + ReentrantReadWriteLock,读读不互斥,读写才互斥。我建议你先用 synchronized 实现一版,压测发现瓶颈再优化。
什么时候该用 LRU
LRU 不是万能的。它适合以下几种场景:
- 访问模式有明显的局部性:比如最近打开的文档、刚浏览的商品。
- 数据被重复访问的概率高:热点数据会持续被命中。
- 缓存容量不大:几万到几百万级别,太大的话哈希表和链表的指针开销会占不少内存。
我记得有一次给一个监控系统做缓存优化。他们的设备数据访问模式非常符合 LRU——最近上报的设备最常被查询。我们用 LRU 把内存占用降了 60%,查询延迟从 50ms 降到 2ms。效果立竿见影。
小总结:LRU 是基础,也是硬核面试题。你看懂上面这个实现,JDK 的 LinkedHashMap 源码基本就能秒懂了——它内部就是双向链表 + 哈希表,只是把 LRU 的逻辑封装成 removeEldestEntry 钩子方法。
最后说一句:LRU 是基础中的基础。你把这个彻底吃透,后面学 LFU、ARC、2Q 这些高级策略时,就轻松多了。先把 LRU 的代码默写出来,再谈优化。嗯,我当年就是这么干的。