LRU缓存的核心诉求很明确:当缓存容量满了之后,淘汰最久没有被访问的数据。要在一个数据结构上同时支持快速查找、快速插入和快速删除,单靠一种容器是做不到的,这就引出了经典的哈希表加双向链表的组合方案。本文会从原理讲起,给出完整的C++实现,再对比几种不同的实现思路在性能上的差异。

一、为什么是哈希表加双向链表
先分析每个操作对数据结构的要求。get(key)需要快速定位某个键是否存在,这要求O(1)查找,哈希表是天然选择。而LRU的淘汰逻辑要求维护数据的访问顺序,每次被访问的元素要挪到序列头部,容量满时删除尾部元素,这要求O(1)的任意位置删除和O(1)的头部插入,双向链表正好满足。
如果把两者分开用,只用哈希表无法维护顺序,只用链表查找是O(n)。所以标准做法是:哈希表的value存储双向链表的迭代器(或节点指针),链表节点存储完整的键值对。这样get时先通过哈希表找到链表节点,再把节点移动到链表头部;put时如果键存在就更新值并移到头部,不存在则插入头部,超容量就删除尾部节点,同时记得删除哈希表中对应的条目。
有一个容易被忽略的细节:链表节点里必须同时存key和value,而不能只存value。原因在于删除尾部节点时,你需要知道这个节点对应哪个key,才能去哈希表里删掉对应条目。不少面试者在白板上写LRU时栽在这里,最后删除哈希表条目时发现拿不到key,只能遍历整个哈希表,时间复杂度直接退化成O(n)。
二、基于std::list和unordered_map的完整实现
这是最常见也最实用的版本,代码量少,可读性好,性能对绝大多数场景已经够用。使用std::list<std::pair<int,int>>存键值对,std::unordered_map<int, std::list<std::pair<int,int>>::iterator>做索引。C++标准保证std::list的迭代器在插入删除其他元素时不会失效,这是整个方案成立的前提。
#include <list>
#include <unordered_map>
#include <cstddef>
class LRUCache {
public:
explicit LRUCache(size_t capacity) : capacity_(capacity) {}
int get(int key) {
auto it = map_.find(key);
if (it == map_.end()) return -1;
// 把刚访问的节点移到链表头部
list_.splice(list_.begin(), list_, it->second);
return it->second->second;
}
void put(int key, int value) {
auto it = map_.find(key);
if (it != map_.end()) {
it->second->second = value;
list_.splice(list_.begin(), list_, it->second);
return;
}
if (map_.size() >= capacity_) {
// 淘汰尾部节点,注意要拿到key去删哈希表条目
auto lastKey = list_.back().first;
list_.pop_back();
map_.erase(lastKey);
}
list_.emplace_front(key, value);
map_[key] = list_.begin();
}
private:
size_t capacity_;
std::list<std::pair<int,int>> list_;
std::unordered_map<int, std::list<std::pair<int,int>>::iterator> map_;
};
几个实现细节值得展开讲。第一,移动节点用的是splice而不是先erase再insert,splice只调整指针,不发生任何元素拷贝和内存分配,这是std::list相对std::vector的一个关键优势。第二,哈希表里存迭代器而非直接存节点指针,可以避免自己去管理链表节点的生命周期。第三,构造函数用explicit修饰,防止意外的隐式类型转换,这是好的工程习惯。
如果想减少哈希表的rehash次数,可以在构造时调用map_.reserve(capacity)预留桶数量,因为LRU缓存的哈希表条目数上限就是容量,提前预留可以避免运行中途的扩容抖动。对于延迟敏感的服务,这种一次性预分配非常值得做。
三、性能分析与替代方案对比
上面的实现虽然是O(1)复杂度,但常数因子并不小。每次put新元素都会触发一次链表节点分配和一次哈希表节点分配,也就是两次堆分配。在QPS很高的场景下,分配器的压力会成为瓶颈。
一种改进方案是手写侵入式双向链表,把prev和next指针直接嵌入节点结构体,再用自定义内存池或者std::allocator预分配所有节点。这样节点内存完全连续或分块连续,缓存局部性好,分配开销几乎为零。实测在容量一万、读写混合的负载下,内存池版本比std::list版本吞吐高出百分之三十到五十,主要收益就来自省掉了两次堆分配和链表节点内存不连续带来的缓存缺失。
另一种思路是完全换数据结构,比如用基数树或者跳表配合时间戳,但这些方案要么查找不是严格O(1),要么实现复杂度大幅上升,除非有特殊需求(比如需要范围查询),否则不建议偏离哈希加链表的主流方案。
关于命中率测试,建议用固定的访问序列做基准测试,比如Zipf分布生成的请求序列,分别对比容量为100、1000、10000时的命中率曲线,确认容量设置是否合理。缓存容量不是越大越好,容量接近全量数据时缓存意义急剧下降,要结合内存预算做权衡。
四、线程安全版本的实现思路
实际项目中LRU缓存往往被多线程并发访问,最直接的做法是在上述实现外面包一层std::mutex,get和put都加锁。这种写法正确性没问题,但在高并发下锁竞争严重,吞吐会随线程数增加先升后降。
优化方向有三个层次。第一是分片锁,把一个缓存按key哈希分成N个分片,每个分片一把锁,不同分片可以并行操作,竞争压力降为原来的N分之一,这是工程上最常用的方案。第二是读写锁,读多写少场景下用std::shared_mutex让读操作并行,但注意LRU的get会修改链表顺序,严格来说get也是写操作,直接用读写锁是不对的,需要配合定期批量更新顺序的策略做妥协。第三是无锁或细粒度锁方案,实现复杂度非常高,链表的CAS操作很难写对,除非有极端性能要求,否则不建议自己造这个轮子。
总结一下,单线程场景直接用std::list加unordered_map的版本,代码简洁且不易出错;对性能有要求时换成内存池加侵入式链表;多线程场景优先考虑分片锁。理解了哈希表与链表各自的职责划分,LRU的任何变体比如LFU、带过期时间的LRU,都可以在这个骨架上扩展出来。