缓存算法介绍
LRU (Least Recently Used)
算法思想
每次内存溢出时,把最长时间未被访问的数据置换出去。这种算法是完全从最近使用的时间角度去考虑的。
维护每个数据上一次被访问的 timestamp,每次移除 timestamp 最早的数据。
工作原理
我们需要实现 LRU
的 put 和 get 的 O(1) 复杂度操作 ——
需要的数据结构:list、unordered_map。
- list 链表使插入和删除操作的复杂度达到 O(1),list结点存储访问的数据。
- unordered_map 映射 key 到 list 结点,使查找的复杂度达到O(1)。

执行过程:
- get 数据时从缓存中查找,若缓存(map)命中,则将数据从 list 中取出,并加入到 list 头部
- 若没有命中,则表明缓存穿透,后续需要从磁盘中获取要访问的数据加入缓存中
- 将数据加入缓存中时,若缓存满了,则淘汰 list 尾部的数据,然后在 list 头部加入新数据
存在的问题:
sequential flooding (顺序溢出) 造成缓存污染 —— 最近被访问的数据实际是最不可能需要的数据。
- 顺序读取所有数据,则缓存会被只读了一次之后再也不会读取的数据污染。即在某些特定的workload下,我们想移出的数据是那些最近被使用的,而不是最近最少被使用的。
代码实现
class LRUCache {
public:
LRUCache(int capacity) : cap(capacity), size(0) {}
int get(int key) {
if (map.find(key) == map.end()) return -1;
auto item = *map[key];
cache.erase(map[key]);
cache.push_front(item);
map[key] = cache.begin();
return item.second;
}
void put(int key, int value) {
if (map.find(key) == map.end()) {
if (size == cap) {
map.erase(cache.back().first);
cache.pop_back();
size--;
}
}
else {
cache.erase(map[key]);
size--;
}
cache.push_front({key, value});
map[key] = cache.begin();
size++;
}
private:
int cap;
int size;
list<pair<int, int>> cache;
unordered_map<int, list<pair<int, int>>::iterator> map;
};
LRU-K
算法思想
LRU-K
中的 K 代表最近使用的次数,因此 LRU
可以认为是 LRU-1
。LRU-K
的主要目的是为了解决 LRU
算法仅访问一次就能替换造成的“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过 K 次”。
工作原理
相比 LRU
,LRU-K
需要多维护一个队列 (使用 list 实现),用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到 K 次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K
会淘汰第 K 次访问时间距当前时间最大的数据。
历史队列
:保存着每次访问的数据,当数据访问次数达到了k次,删除该数据并保存至缓存队列
;若尚未达到 K 次则继续保存,直至历史队列
也满了,那就根据一定的缓存策略 (FIFO、LRU、LFU) 进行淘汰。缓存队列
:保存已经访问 K 次的数据,当该队列满了之后,则淘汰最后一个数据,也就是第k次访问距离现在最久
的那个数据。

执行过程:
- 数据第一次被访问,加入到访问历史队列;
- 若数据在访问历史列表里后没有达到 K 次访问,则按照一定规则(FIFO,LRU)淘汰;
- 当访问历史队列中的数据访问次数达到 K 次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序;
- 缓存数据队列中被再次访问后,重新排序;
- 需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第 K 次访问离现在最久”的数据。
LRU-K 具有 LRU 的优点,同时能够避免 LRU 的缺点。它的命中率要比 LRU 高,但因为要维护一个历史队列,因此内存消耗会比 LRU 多。
实际应用中 LRU-2 是综合各种因素后最优的选择,LRU-3 或者更大的 K 值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。
Two queues(2Q)
算法思想
该算法类似于 LRU-2
,不同点在于 2Q
将 LRU-2
算法中的访问历史队列(注意这不是缓存数据的)改为一个 FIFO
缓存队列,即:2Q
算法有两个缓存队列,一个是 FIFO
队列,一个是 LRU
队列。
工作原理
当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。

- 新访问的数据插入到FIFO队列;
- 如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
- 如果数据在FIFO队列中被再次访问,则将数据移到LRU队列头部;
- 如果数据在LRU队列中再次被访问,则将数据移到LRU队列头部;
- LRU队列淘汰末尾的数据。
缺点跟LRU-K一致,其实2Q算法就是LRU-2,并且历史队列采用了FIFO的淘汰策略。
Muti Queue(MQ)
MQ
算法其实是 2Q
算法的一个扩展。
2Q
算法维护两个队列,而 MQ
算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级。
其核心思想是:优先缓存访问次数多的数据
。
举个例子🌰:如果在高优先级队列中一个页面长时间没有被访问,当新页面进栈,没位置存放时,该长时间未被访问的页面就出栈,进入低一级优先级的队列中。
MQ
算法需要维护多个队列以及多个页面的数据,成本较大。
其他缓存策略
最佳置换算法(OPT)
是一种理想情况下的页面置换算法,但实际上是不可能实现的。
该算法的基本思想是:发生缺页时,有些页面在内存中,其中有一页将很快被访问(也包含紧接着的下一条指令的那页),而其他页面则可能要到10、100或者1000条指令后才会被访问,每个页面都可以用在该页面首次被访问前所要执行的指令数进行标记。最佳页面置换算法只是简单地规定:标记最大的页应该被置换。
这个算法唯一的一个问题就是它无法实现。当缺页发生时,操作系统无法知道各个页面下一次是在什么时候被访问。虽然这个算法不可能实现,但是最佳页面置换算法可以用于对可实现算法的性能进行衡量比较。
先进先出置换算法(FIFO)
是最简单的页面置换算法。
该算法的实质是:总是选择在主存中停留时间最长(即最老)的一页置换,即先进入内存的页,先退出内存。理由是:最早调入内存的页,其不再被使用的可能性比刚调入内存的可能性大。建立一个 FIFO 队列,收容所有在内存中的页。被置换页面总是在队列头上进行。当一个页面被放入内存时,就把它插在队尾上。
最近最少使用算法(LFU: Least Frequently Used)
核心思想是:
最近使用频率高的数据很大概率将会再次被使用,而最近使用频率低的数据,很大概率不会再使用。
做法:把使用频率最小的数据置换出去。这种算法是完全从使用频率的角度去考虑的。
LFU 🆚 LRU:
LRU
的淘汰规则是基于访问时间,而LFU
是基于访问次数的。举个例子 🌰
假设缓存大小为3,数据访问序列为:
set(2,2) set(1,1) get(2) get(1) get(2) set(3,3) set(4,4)
则在 set(4,4) 时对于
LFU
算法应该淘汰 (3,3),而LRU
应该淘汰 (1,1)。
因为根据LFU
的核心,在堆栈满载之后,1访问了1次,2访问了2次,虽然3是最后才加进来的,但是访问次数为0,最少访问,所以LFU
淘汰的是 (3,3)。
Clock 算法
是 LRU 的近似策略(二者性能接近),它不需追踪每个 page 上次被访问的时间戳,而是为每个 page 保存一个标志位 reference bit,它告诉你自从上次检查完该page后,此page是否被访问了。
- 每当 page 被访问时,reference bit 设置为 1
- 每当需要移除 page 时,从上次访问的位置开始,按顺序轮询每个 page 的 reference bit,若该 bit 为 1,则重置为 0;若该 bit 为 0,则移除该 page
本文作者:Joey-Wang
本文链接:https://www.cnblogs.com/joey-wang/p/17189608.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步