Java LRU 与 LFU 算法及应用

在Java面试和日常开发中,LRULFU 是出现频率最高的两种缓存淘汰算法。它们的核心区别在于淘汰依据: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就是这么做的。

相关推荐
Jerry2 小时前
LeetCode 707. 设计链表
算法
C语言小火车3 小时前
C++ 堆排序深度精讲:基于完全二叉树的选择排序进化,最坏情况 O(n log n) 的稳定王者
开发语言·c++·算法·排序算法·堆排序
北冥you鱼3 小时前
abigen 最佳实践:从入门到精通,高效生成 Go 语言合约绑定
开发语言·golang·区块链
kebidaixu3 小时前
两轮BMS AFE SH367306 I2C 读写时序
算法
智能排队系统_头部供应商3 小时前
RK3588边缘网关改造银行排队系统实战
算法
Fabarta3 小时前
从 0 实现 ChatGPT 风格的流式对话 UI
算法·架构
手写码匠3 小时前
手写 AI 上下文压缩系统:从零实现 Prompt 压缩与选择性上下文管理
人工智能·深度学习·算法·aigc
ALex_zry4 小时前
C++26 std::complex 结构化绑定详解:auto [re, im] = c
c语言·开发语言·c++