在计算机系统中,缓存是提升性能的关键组件。当缓存空间不足时,我们需要决定哪些数据应该被保留,哪些可以被淘汰。这就是缓存淘汰策略要解决的问题。LRU(Least Recently Used)和LFU(Least Frequently Used)是两种最常用的缓存淘汰算法,它们各有特点,适用于不同的应用场景。
提示:选择缓存淘汰策略时,需要根据业务数据的访问模式来决定,没有放之四海而皆准的完美方案。
LRU算法的核心思想是"最近最少使用"。它假设最近被访问过的数据,在不久的将来更有可能再次被访问。因此,当需要淘汰数据时,LRU会选择最久未被访问的数据进行淘汰。
实现LRU通常需要以下组件:
每次访问一个键时,LRU会将该键移动到链表的头部(表示最近使用),而淘汰时则从链表尾部移除数据(表示最久未使用)。
LFU算法的核心思想是"最不经常使用"。它统计每个数据的访问频率,当需要淘汰数据时,LFU会选择访问频率最低的数据进行淘汰。如果多个数据具有相同的访问频率,则通常会结合LRU策略,淘汰其中最久未使用的。
LFU的实现通常需要:
| 特性 | LRU | LFU |
|---|---|---|
| 淘汰依据 | 最近访问时间 | 访问频率 |
| 实现复杂度 | 中等 | 较高 |
| 内存开销 | 较小 | 较大 |
| 适用场景 | 有明显时间局部性 | 有稳定访问模式 |
| 典型应用 | 新闻网站、社交媒体 | 电商商品、热门视频 |
LRU特别适合那些访问模式有明显时间局部性的场景,比如:
LFU则更适合访问模式相对稳定的场景:
要实现一个高效的LRU缓存,我们需要精心设计数据结构。以下是核心组件:
java复制class DListNode {
int key; // 键
int val; // 值
DListNode prev; // 前驱节点
DListNode next; // 后继节点
// 无参构造函数
public DListNode() {}
// 带参构造函数
public DListNode(int key, int val) {
this.key = key;
this.val = val;
}
}
java复制class LRUCache {
private Map<Integer, DListNode> cache; // 哈希表,存储键到节点的映射
private int capacity; // 缓存容量
private int size; // 当前缓存大小
private DListNode head, tail; // 虚拟头尾节点
public LRUCache(int capacity) {
this.cache = new HashMap<>();
this.capacity = capacity;
this.size = 0;
// 初始化虚拟头尾节点
this.head = new DListNode();
this.tail = new DListNode();
head.next = tail;
tail.prev = head;
}
// 其他方法...
}
java复制public int get(int key) {
DListNode node = cache.get(key);
if (node == null) {
return -1; // 键不存在
}
// 将访问的节点移动到链表头部
moveToHead(node);
return node.val;
}
java复制public void put(int key, int value) {
DListNode node = cache.get(key);
if (node == null) {
// 创建新节点
DListNode newNode = new DListNode(key, value);
// 添加到哈希表
cache.put(key, newNode);
// 添加到链表头部
addToHead(newNode);
size++;
// 如果超出容量,移除尾部节点
if (size > capacity) {
DListNode tail = removeTail();
cache.remove(tail.key);
size--;
}
} else {
// 更新值并移动到头部
node.val = value;
moveToHead(node);
}
}
java复制// 将节点添加到链表头部
private void addToHead(DListNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
// 移除指定节点
private void removeNode(DListNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 将节点移动到头部
private void moveToHead(DListNode node) {
removeNode(node);
addToHead(node);
}
// 移除尾部节点
private DListNode removeTail() {
DListNode res = tail.prev;
removeNode(res);
return res;
}
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| get | O(1) | 哈希表查找 + 链表移动 |
| put | O(1) | 哈希表操作 + 链表操作 |
| 添加节点 | O(1) | 链表头部插入 |
| 删除节点 | O(1) | 链表节点移除 |
这种实现方式保证了所有操作都在常数时间内完成,非常高效。
上述实现是非线程安全的。如果在多线程环境下使用,需要考虑以下问题:
解决方案:
synchronized关键字保护关键部分ConcurrentHashMap替代HashMap标准实现中每个节点需要存储前后指针,对于小对象来说内存开销较大。可以考虑:
在实际系统中,LRU有多种变体:
当键的哈希冲突较多时,会影响性能。可以考虑:
某些场景下,LRU可能被恶意或异常访问模式污染:
解决方案:
Redis作为流行的内存数据库,实现了多种缓存淘汰策略:
Redis的LRU实现是近似的,出于性能考虑,它不会维护精确的访问顺序链表,而是采用抽样方式选择淘汰候选键。
在实际使用Redis时,选择淘汰策略需要考虑:
选择缓存策略时,建议考虑以下因素:
在某些场景下,结合LRU和LFU的混合策略可能更有效:
在分布式系统中,缓存策略还需要考虑:
在实际项目中,我通常会先使用LRU作为默认策略,然后根据监控数据调整。对于电商系统,某些核心商品数据可能会采用LFU或特殊缓存,确保长期热销商品始终可用。同时,合理的缓存过期策略和内存限制设置同样重要,不能只依赖淘汰策略。