在Java面试和日常开发中,LRU 和LFU 是出现频率最高的两种缓存淘汰算法。它们的核心区别在于淘汰依据:LRU看"时间"(最近谁没用过),LFU看"频率"(谁最不常用)。
1. LRU(Least Recently Used,最近最少使用)
核心思想
淘汰最长时间未被访问的数据。假设"如果数据最近被访问过,那么将来被访问的概率也更高"。
Java实现方式
方式一:LinkedHashMap 一行搞定(最推荐)
java
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
public LRUCache(int capacity) {
// accessOrder=true 表示按访问顺序排序
super(capacity, 0.75f, true);
this.maxCapacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxCapacity; // 容量超标时移除最老元素
}
}
使用示例:
java
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "A");
cache.put(2, "B");
cache.put(3, "C");
cache.get(1); // 访问1,使其变为最新
cache.put(4, "D"); // 触发淘汰,移除最久未用的2
System.out.println(cache); // {3=C, 1=A, 4=D}
方式二:手写双向链表 + HashMap(面试常考)
java
class LRUCacheManual {
class DLinkedNode {
int key, value;
DLinkedNode prev, next;
DLinkedNode() {}
DLinkedNode(int key, int value) { this.key = key; this.value = value; }
}
private Map<Integer, DLinkedNode> cache = new HashMap<>();
private int size, capacity;
private DLinkedNode head, tail;
public LRUCacheManual(int capacity) {
this.capacity = capacity;
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) return -1;
moveToHead(node); // 移到链表头部(表示最近使用)
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
} else {
DLinkedNode newNode = new DLinkedNode(key, value);
cache.put(key, newNode);
addToHead(newNode);
size++;
if (size > capacity) {
DLinkedNode tail = removeTail();
cache.remove(tail.key);
size--;
}
}
}
// 省略 addToHead, removeTail, moveToHead 等链表操作...
}
2. LFU(Least Frequently Used,最不经常使用)
核心思想
淘汰访问次数最少的数据。如果访问次数相同,则淘汰最久未使用的(需要结合时间)。
实现难点
LFU比LRU复杂得多,因为需要同时维护频率 和时间两个维度。LeetCode 460题就是典型。
经典实现(双哈希表 + 频率链表)
java
class LFUCache {
private Map<Integer, Node> keyToNode; // key -> 数据节点
private Map<Integer, LinkedHashSet<Node>> freqToKeys; // 频率 -> 该频率下的key集合(用LinkedHashSet保证顺序)
private int minFreq; // 当前最小频率
private int capacity;
class Node {
int key, value, freq;
Node(int key, int value) {
this.key = key;
this.value = value;
this.freq = 1; // 初始频率为1
}
}
public LFUCache(int capacity) {
this.capacity = capacity;
keyToNode = new HashMap<>();
freqToKeys = new HashMap<>();
minFreq = 0;
}
public int get(int key) {
if (!keyToNode.containsKey(key)) return -1;
Node node = keyToNode.get(key);
updateFreq(node); // 访问后频率+1
return node.value;
}
public void put(int key, int value) {
if (capacity == 0) return;
if (keyToNode.containsKey(key)) {
Node node = keyToNode.get(key);
node.value = value;
updateFreq(node);
} else {
if (keyToNode.size() >= capacity) {
// 淘汰最小频率中最久未使用的那个
LinkedHashSet<Node> set = freqToKeys.get(minFreq);
Node oldest = set.iterator().next();
set.remove(oldest);
keyToNode.remove(oldest.key);
if (set.isEmpty()) freqToKeys.remove(minFreq);
}
Node newNode = new Node(key, value);
keyToNode.put(key, newNode);
freqToKeys.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(newNode);
minFreq = 1; // 新插入的一定是频率最低
}
}
private void updateFreq(Node node) {
int oldFreq = node.freq;
// 从旧频率集合中移除
LinkedHashSet<Node> oldSet = freqToKeys.get(oldFreq);
oldSet.remove(node);
if (oldSet.isEmpty()) {
freqToKeys.remove(oldFreq);
if (minFreq == oldFreq) minFreq++;
}
// 放入新频率集合
node.freq++;
freqToKeys.computeIfAbsent(node.freq, k -> new LinkedHashSet<>()).add(node);
}
}
3. 应用场景对比
| 维度 | LRU | LFU |
|---|---|---|
| 适用场景 | 热点数据有明显时效性(如用户最近浏览) | 热点数据稳定且长期(如热门视频、全局配置) |
| 典型例子 | Redis内存淘汰(allkeys-lru)、Glide图片缓存、浏览器后退按钮 |
数据库查询缓存(统计高频SQL)、CDN内容缓存 |
| 优点 | 实现简单,性能高,符合局部性原理 | 能过滤掉偶发性突发流量,保留真正高频数据 |
| 缺点 | 容易被批量访问"污染"(一次全表扫描会把所有旧数据挤出去) | 实现复杂,维护成本高;新数据刚进来频率低,容易被立即淘汰(需加老化机制) |
4. 生产环境中的最佳实践
① 直接使用成熟缓存框架
不要自己写LFU,99%的场景用Google的Caffeine就够了:
java
// Caffeine 同时支持 LRU 和 LFU(实际是 TinyLFU,LFU的增强版)
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
Caffeine使用的TinyLFU(论文算法)结合了LFU和LRU的优点,并解决了新数据"饥饿"问题。
② Redis中的使用
-
LRU :Redis默认近似LRU(非精确,采样淘汰),配置
maxmemory-policy allkeys-lru -
LFU :Redis 4.0+支持,配置
allkeys-lfu,适合稳定的热点数据缓存
③ 数据库连接池或线程池
使用LinkedBlockingDeque实现简单LRU,淘汰长时间空闲的连接。
5. 面试高频追问
Q:为什么LinkedHashMap能实现LRU?
A:构造参数accessOrder=true使链表按访问顺序排序,每次get/put都会将节点移到尾部,头部就是最久未用的。重写removeEldestEntry控制淘汰时机。
Q:LRU和LFU哪个更优?
A:没有绝对优劣。突发流量场景 (如双11秒杀)用LRU更合适;稳态热点场景(如排行榜)用LFU更精准。工业界常用两者混合(如Arc算法、TinyLFU)。
Q:如何解决LFU中"新数据刚来就被淘汰"的问题?
A:给新数据初始频率赋一个较高的值(如初始频率=5),或使用窗口计数(只统计最近一段时间内的访问次数),Caffeine的TinyLFU就是这么做的。