工程算法实战 | 从LRU到手写本地缓存:LinkedHashMap → 双向链表+哈希表 → Caffeine 原理

面试造火箭,工作拧螺丝?今天带你从造轮子开始,理解工业级缓存到底强在哪。


写在前面:这道题你到底会几分?

面试官:"手写一个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的完整逻辑(用一个比喻)

想象你是酒吧老板,门口有个热门榜单 (主缓存)和冷门过滤器(准入队列)。

工作流程

  1. 一个新数据来了,先进冷门过滤器(不直接进主缓存)

  2. 在过滤器里被访问多次,证明自己是"潜力股",才晋升到热门榜单

  3. 热门榜单里也用LRU管理(这是"Window"部分,给新热点留窗口期)

  4. 淘汰时,不在榜单里的数据直接被干掉,榜单里的比较"热度分"

一句话总结: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%的边缘场景)

  1. 面试考察:手撕LRU/LFU

  2. 极度简单的场景:不想引入依赖,几十行代码够用

  3. 特殊淘汰策略:比如按数据大小淘汰(缓存文件分块)

  4. 嵌入式系统:内存极小,需要定制

一句话建议:生产环境无脑上Caffeine,别自己造轮子。但理解原理能让你在缓存穿透/雪崩时快速定位问题。


写在最后

从面试题到工业级,差的不是算法本身,而是对真实约束的理解:并发、过期、命中率、可观测性。

下次面试官再问LRU,讲完双链表后补一句:"工程上我会用Caffeine,因为它用了W-TinyLFU,能防一次性查询污染缓存。"------这就从"刷题选手"变成"有工程sense的开发者"了。

相关推荐
Ting-yu1 小时前
SpringCloud快速入门(4)---- nacos安装与使用
java·spring·spring cloud
van久1 小时前
Day30:Redis 缓存策略 + 菜单实战缓存 + 三大缓存问题(穿透 / 击穿 / 雪崩)
数据库·redis·缓存
无尽冬.1 小时前
个人八股之三层架构
java·经验分享·后端·架构·异世界
数智工坊1 小时前
【Offline RL1】离线强化学习全景:从基础理论到前沿算法与工业落地
算法
流年如夢1 小时前
二叉树(LeetCode)
数据结构·算法·leetcode·职场和发展
贫民窟的勇敢爷们1 小时前
SpringBoot多环境配置全解+配置优先级管控
java·spring boot·后端
tellmewhoisi2 小时前
单独抽取用户服务(请求不通):feign添加拦截器(添加token)
java·开发语言
与数据交流的路上2 小时前
Redis-jedis连接池配置错误导致Redis CPU飙高
数据库·redis·缓存
数据皮皮侠2 小时前
上市公司内源与债权股权融资协同数据(2009-2025)
大数据·人工智能·算法·microsoft·百度