面试造火箭,工作拧螺丝?今天带你从造轮子开始,理解工业级缓存到底强在哪。
写在前面:这道题你到底会几分?
面试官:"手写一个LRU缓存。"
你心里一喜,LeetCode 146刚刷过,LinkedHashMap一行搞定。
面试官:"如果让你手撕双向链表呢?"
你开始冒汗。
面试官:"那你知道Caffeine的W-TinyLFU吗?和LRU有什么不同?"
你沉默了。
这篇文章,就是帮你把这中间的坑填上。从面试题到工业实现,咱们一层层扒开看。
一、【面试版LRU】双链表 + 哈希表
1.1 一行实现的"天才解法"
java
// LeetCode 146 一行解法(面试慎用,会被追问)
public class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder=true
this.capacity = capacity;
}
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity;
}
}
LinkedHashMap的三个参数:初始容量、负载因子、访问顺序(true表示按访问排序,刚访问的移到最后)。
原理:底层维护了一个双向链表,每次get/put都会把节点挪到链表尾部,头部就是最久未访问的。
1.2 面试官想要的"手撕版"
当你不能依赖LinkedHashMap时,核心结构就是:HashMap + 双向链表
java
class LRUCache {
// 双向链表节点
class Node {
int key, value;
Node prev, next;
Node(int k, int v) { key = k; value = v; }
}
private HashMap<Integer, Node> map = new HashMap<>();
private Node head, tail; // 虚拟头尾节点
private int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
}
public int get(int key) {
if (!map.containsKey(key)) return -1;
Node node = map.get(key);
removeNode(node); // 从原位置删除
addToTail(node); // 移到尾部(表示最近使用)
return node.value;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
Node node = map.get(key);
node.value = value;
removeNode(node);
addToTail(node);
} else {
if (map.size() == capacity) {
// 淘汰头部下一个(最久未使用)
Node lru = head.next;
removeNode(lru);
map.remove(lru.key);
}
Node newNode = new Node(key, value);
map.put(key, newNode);
addToTail(newNode);
}
}
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void addToTail(Node node) {
node.prev = tail.prev;
node.next = tail;
tail.prev.next = node;
tail.prev = node;
}
}
核心思路:
-
get时:把节点移到链表尾
-
put时:新节点放链表尾,满则删链表头
-
用HashMap保证O(1)查找
时间复杂度:所有操作O(1)
二、【工程痛点】面试版LRU在真实项目里哪不行?
面试版的LRU做玩具够了,但上线三分钟你可能就被叫起来改bug:
痛点1:并发问题
真实环境里多个线程同时读写缓存,HashMap + 双向链表直接GG。你需要ConcurrentHashMap + 细粒度锁,或者干脆用ReadWriteLock。
痛点2:没有过期时间
LRU只管"最近有没有被访问",不管"活多久"。比如session token 30分钟过期,LRU根本不管,除非你一直访问。
痛点3:命中率不够高
LRU容易被"一次性的批量查询"把热点数据挤出去。
举个例子:你本来经常查用户A、B、C的信息,缓存里全是这些。突然跑了个报表任务,一次性查了1000个其他用户,LRU会把A、B、C全踢出去。等用户请求来了,缓存全miss,数据库瞬间被打崩。
痛点4:没有统计数据
生产环境你想看命中率、平均加载时间?面试版本统统没有。
三、【Caffeine科普】W-TinyLFU到底是啥?
Caffeine是现在Java生态最强的本地缓存,性能比Guava Cache高好几倍,核心就是W-TinyLFU算法。
名字拆开:TinyLFU + Window
3.1 先理解LFU的痛
LFU(最不经常使用)淘汰访问次数最少的数据。听起来比LRU合理对吧?
但LFU有个致命问题:一个数据如果早年很热门,哪怕现在没人用了,它的"历史访问次数"很高,永远不会被淘汰。这叫"历史残留问题"。
3.2 TinyLFU的解决方案
近似计数 + 衰减
TinyLFU不存精确的访问次数,而是用Count-Min Sketch(一种概率性计数器),只占很小内存。
然后定期把所有计数除以2(衰减),让"过气网红"自动退位。
3.3 W-TinyLFU的完整逻辑(用一个比喻)
想象你是酒吧老板,门口有个热门榜单 (主缓存)和冷门过滤器(准入队列)。
工作流程:
-
一个新数据来了,先进冷门过滤器(不直接进主缓存)
-
在过滤器里被访问多次,证明自己是"潜力股",才晋升到热门榜单
-
热门榜单里也用LRU管理(这是"Window"部分,给新热点留窗口期)
-
淘汰时,不在榜单里的数据直接被干掉,榜单里的比较"热度分"
一句话总结:W-TinyLFU = 一个准入机制(防低频污染)+ 一个带衰减的计数器(防历史残留)+ 一个LRU窗口(给新数据机会)
这也是为什么Caffeine能做到近乎最优的命中率。
四、【手写简易缓存】带过期时间的实现
我们来写一个轻量级本地缓存,支持:
-
过期时间(TTL)
-
最大容量
-
简单的轮询清理
java
public class SimpleCache<K, V> {
// 缓存条目
private static class CacheEntry<V> {
V value;
long expireTime; // 过期时间戳(毫秒)
CacheEntry(V value, long ttlMillis) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttlMillis;
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
private final Map<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final int maxCapacity;
private final long defaultTtlMillis;
public SimpleCache(int maxCapacity, long defaultTtlSeconds) {
this.maxCapacity = maxCapacity;
this.defaultTtlMillis = defaultTtlSeconds * 1000;
// 启动清理线程(每10秒扫一次)
startCleaner();
}
public void put(K key, V value) {
put(key, value, defaultTtlMillis);
}
public void put(K key, V value, long ttlMillis) {
// 超过容量就先清理一次
if (cache.size() >= maxCapacity) {
cleanExpiredEntries();
// 如果清理完还是满,淘汰最早的(简化版LRU)
if (cache.size() >= maxCapacity) {
evictEldest();
}
}
cache.put(key, new CacheEntry<>(value, ttlMillis));
}
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry == null || entry.isExpired()) {
cache.remove(key);
return null;
}
return entry.value;
}
// 轮询清理过期条目
private void cleanExpiredEntries() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
// 简化淘汰:删第一个(实际应该维护访问顺序)
private void evictEldest() {
if (!cache.isEmpty()) {
K firstKey = cache.keySet().iterator().next();
cache.remove(firstKey);
}
}
// 后台清理线程
private void startCleaner() {
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(this::cleanExpiredEntries, 10, 10, TimeUnit.SECONDS);
}
}
核心要点:
-
用
ConcurrentHashMap保证线程安全 -
每个Entry带过期时间戳
-
轮询清理:get时惰性删除 + 后台定时批量删除
-
容量控制:超标时先删过期,再删最老(需要可配淘汰策略)
五、【总结】什么时候用现成轮子,什么时候自己写?
用现成轮子(99%的情况)
| 场景 | 推荐方案 |
|---|---|
| 单机应用、通用场景 | Caffeine(命中率、性能都是天花板) |
| 需要持久化、集群 | Redis |
| Android开发 | 系统自带的LruCache |
| 读多写少、接受定期刷新的配置缓存 | Guava Cache(简单够用) |
自己写(1%的边缘场景)
-
面试考察:手撕LRU/LFU
-
极度简单的场景:不想引入依赖,几十行代码够用
-
特殊淘汰策略:比如按数据大小淘汰(缓存文件分块)
-
嵌入式系统:内存极小,需要定制
一句话建议:生产环境无脑上Caffeine,别自己造轮子。但理解原理能让你在缓存穿透/雪崩时快速定位问题。
写在最后
从面试题到工业级,差的不是算法本身,而是对真实约束的理解:并发、过期、命中率、可观测性。
下次面试官再问LRU,讲完双链表后补一句:"工程上我会用Caffeine,因为它用了W-TinyLFU,能防一次性查询污染缓存。"------这就从"刷题选手"变成"有工程sense的开发者"了。