1. LRU缓存算法核心原理
在计算机系统中,缓存技术无处不在。从CPU的多级缓存到浏览器的页面缓存,再到分布式系统中的Redis缓存,它们都面临一个共同问题:当缓存空间不足时,应该优先淘汰哪些数据?LRU(Least Recently Used)算法就是解决这个问题的经典方案。
1.1 为什么需要缓存淘汰策略
想象你有一个容量有限的背包(缓存),每次出门只能带有限数量的物品(数据)。你会如何选择要带的物品?最合理的做法是:
- 经常使用的物品(高频访问数据)始终放在包里
- 最近很少使用的物品(低频访问数据)可以暂时拿出
- 几乎不用的物品(冷数据)直接淘汰
这就是LRU算法的现实类比。在工程实践中,缓存淘汰策略直接影响系统性能:
- 数据库访问优化:MySQL的Query Cache、Redis的键淘汰策略
- 操作系统资源管理:内存页面置换算法
- CDN节点缓存:热点内容优先缓存
1.2 LRU的工作原理
LRU的核心思想可以用一个简单的例子说明:
假设缓存容量为3,访问顺序为:A → B → C → A → D
- 初始状态:[ ]
- 访问A后:[A]
- 访问B后:[B, A](最新访问在前)
- 访问C后:[C, B, A]
- 再次访问A:[A, C, B](A被提到最前)
- 访问D时缓存已满,淘汰最久未使用的B:[D, A, C]
这个例子展示了LRU的两个关键操作:
- 访问命中:将数据移动到"最近使用"位置
- 淘汰机制:当空间不足时移除"最久未使用"的数据
关键特性:LRU认为最近被访问过的数据,未来被再次访问的概率更高
2. LRU的手动实现方案
2.1 数据结构设计
要实现O(1)时间复杂度的LRU,需要组合使用两种数据结构:
- 双向链表:维护数据的访问顺序
- 链表头部表示最近访问
- 链表尾部表示最久未访问
- 哈希表:提供快速查找能力
- key到链表节点的映射
- 实现O(1)的查找速度
java复制class LRUCache {
// 双向链表节点定义
class DLinkedList {
int key, value;
DLinkedList prev, next;
public DLinkedList() {}
public DLinkedList(int key, int value) {
this.key = key;
this.value = value;
}
}
private Map<Integer, DLinkedList> cache = new HashMap<>();
private DLinkedList head, tail;
private int capacity, size;
public LRUCache(int capacity) {
this.capacity = capacity;
head = new DLinkedList();
tail = new DLinkedList();
head.next = tail;
tail.prev = head;
}
}
2.2 关键操作实现
2.2.1 访问数据(get操作)
java复制public int get(int key) {
DLinkedList node = cache.get(key);
if (node == null) return -1;
// 将访问的节点移动到头部
moveToHead(node);
return node.value;
}
2.2.2 写入数据(put操作)
java复制public void put(int key, int value) {
DLinkedList node = cache.get(key);
if (node == null) {
// 创建新节点
DLinkedList newNode = new DLinkedList(key, value);
cache.put(key, newNode);
addToHead(newNode);
size++;
if (size > capacity) {
// 淘汰尾部节点
DLinkedList tail = removeTail();
cache.remove(tail.key);
size--;
}
} else {
// 更新已有节点
node.value = value;
moveToHead(node);
}
}
2.2.3 链表操作辅助方法
java复制private void addToHead(DLinkedList node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedList node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(DLinkedList node) {
removeNode(node);
addToHead(node);
}
private DLinkedList removeTail() {
DLinkedList res = tail.prev;
removeNode(res);
return res;
}
2.3 时间复杂度分析
| 操作 | 时间复杂度 | 实现方式 |
|---|---|---|
| get(key) | O(1) | 哈希表直接定位节点 |
| put(key) | O(1) | 哈希表查询+链表操作 |
| 淘汰策略 | O(1) | 直接移除链表尾部节点 |
3. 基于LinkedHashMap的实现
3.1 LinkedHashMap的特性
Java的LinkedHashMap在HashMap基础上增加了双向链表维护插入顺序或访问顺序:
java复制// 第三个参数accessOrder=true表示按访问顺序排序
Map<Integer, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
当accessOrder为true时,每次get/put操作都会将对应节点移动到链表末尾(相当于LRU的最近使用位置)。
3.2 完整LRU实现
java复制public class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}
3.3 两种实现对比
| 特性 | 手动实现 | LinkedHashMap实现 |
|---|---|---|
| 代码复杂度 | 高(需维护链表) | 低(继承即可) |
| 灵活性 | 高(可定制各种逻辑) | 低(固定行为) |
| 内存开销 | 略高(额外指针) | 与手动实现相当 |
| 适用场景 | 需要特殊定制的缓存 | 标准LRU需求 |
4. LRU的工程实践与优化
4.1 实际应用场景
- MySQL查询缓存:使用类似LRU的机制管理缓存空间
- Redis内存淘汰:volatile-lru/allkeys-lru策略
- CPU缓存行:处理器使用类LRU算法管理缓存
4.2 常见问题与解决方案
问题1:哈希冲突导致性能下降
- 解决方案:调整负载因子或使用更好的哈希函数
问题2:链表操作线程不安全
- 解决方案:使用ConcurrentHashMap配合同步机制
问题3:偶发批量操作污染缓存
- 解决方案:实现LRU-K(考虑最近K次访问)
4.3 性能优化技巧
- 预分配节点:避免频繁GC影响性能
- 批量操作优化:合并连续操作减少锁竞争
- 监控统计:记录命中率指导容量调整
java复制// 预分配节点示例
class NodePool {
private Queue<DLinkedList> pool = new LinkedList<>();
DLinkedList getNode(int key, int value) {
DLinkedList node = pool.poll();
if (node == null) return new DLinkedList(key, value);
node.key = key;
node.value = value;
return node;
}
void returnNode(DLinkedList node) {
pool.offer(node);
}
}
5. LRU变种与进阶算法
5.1 LRU-K算法
传统LRU只考虑最近一次访问时间,而LRU-K会考虑最近K次访问:
- 维护两个数据结构:
- 历史访问记录(保存最近K-1次访问)
- 主缓存(保存访问次数≥K的数据)
- 只有访问达到K次才会进入主缓存
- 有效避免偶发访问污染缓存
5.2 2Q算法
Two Queue算法结合了FIFO和LRU:
- 新数据进入FIFO队列
- 被再次访问时移动到LRU队列
- 淘汰时优先从FIFO队列移除
5.3 LFU算法
Least Frequently Used基于访问频率:
- 维护访问计数
- 优先淘汰访问次数少的数据
- 适合有明显热点数据的场景
6. 面试常见问题解析
6.1 典型面试题
-
基础原理:
- LRU的核心思想是什么?
- 为什么需要缓存淘汰策略?
-
实现细节:
- 如何保证get/put操作都是O(1)?
- 双向链表的作用是什么?
-
工程实践:
- 如何实现线程安全的LRU?
- LRU在哪些系统中应用?
6.2 解题思路示例
题目:设计一个支持过期时间的LRU缓存
解决方案:
java复制class TTLNode {
int key, value;
long expireTime;
// 其他链表指针...
}
class TTL_LRUCache {
private Map<Integer, TTLNode> cache;
private long defaultTTL;
public int get(int key) {
TTLNode node = cache.get(key);
if (node == null) return -1;
// 检查是否过期
if (System.currentTimeMillis() > node.expireTime) {
removeNode(node);
return -1;
}
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
// 实现类似基础LRU,但设置过期时间
}
}
在实际开发中,处理过期键通常有两种方式:
- 惰性删除:只在访问时检查是否过期
- 定期清理:后台线程定期扫描过期键