企业数据查询网站又一病毒来了比新冠可怕
深入理解缓存淘汰算法:LRU与LFU实现原理与代码详解
一、缓存淘汰算法概述
缓存淘汰算法是计算机科学中用于管理有限缓存空间的重要策略。当缓存空间不足时,这些算法决定哪些数据应该被保留,哪些应该被淘汰。最常见的两种淘汰算法是:
LRU (Least Recently Used)
:最近最少使用算法LFU (Least Frequently Used)
:最不经常使用算法
二、LRU缓存实现详解
1. 核心思想
LRU基于"最近最少使用"原则淘汰数据,通过维护访问顺序来实现。
2. 完整实现代码
import java.util.LinkedList;
import java.util.HashMap;
import java.util.Map;class LRUCache {// 使用双向链表维护访问顺序(最近访问的键位于头部)private final LinkedList<Integer> accessOrder;// 使用HashMap存储键值对,实现O(1)时间复杂度的访问private final Map<Integer, Integer> cacheMap;// 缓存容量(final修饰,确保不可变)private final int capacity;/*** 构造函数,初始化缓存容量和数据结构* @param capacity 缓存最大容量*/public LRUCache(int capacity) {this.capacity = capacity;this.accessOrder = new LinkedList<>();this.cacheMap = new HashMap<>(capacity); // 初始化指定容量以提高效率}/*** 获取键对应的值,并更新访问顺序* @param key 目标键* @return 对应的值,若不存在则返回-1*/public int get(int key) {if (!cacheMap.containsKey(key)) {return -1; // 键不存在时返回-1}// 更新该键的访问顺序(移到链表头部)updateAccessOrder(key);return cacheMap.get(key);}/*** 插入或更新键值对,维护缓存容量* @param key 目标键* @param value 对应的值*/public void put(int key, int value) {if (capacity <= 0) {return; // 容量为0时不执行任何操作}if (cacheMap.containsKey(key)) {// 键已存在:更新值并调整访问顺序updateAccessOrder(key);} else {// 键不存在:检查是否需要淘汰旧数据if (accessOrder.size() >= capacity) {evictLeastRecentlyUsed(); // 淘汰最久未使用的键}// 将新键插入到访问顺序的头部accessOrder.addFirst(key);}// 更新或插入键值对(HashMap会自动处理更新逻辑)cacheMap.put(key, value);}/*** 将指定键移动到访问顺序的头部(最近访问)* @param key 目标键*/private void updateAccessOrder(int key) {accessOrder.remove((Integer) key); // 从链表中移除旧位置(需拆箱为Integer)accessOrder.addFirst(key); // 插入到头部}/*** 淘汰最久未使用的键(链表尾部元素)*/private void evictLeastRecentlyUsed() {int removedKey = accessOrder.removeLast(); // 移除链表尾部元素(最久未使用)cacheMap.remove(removedKey); // 同步从HashMap中删除}
}
3. 关键点解析
LinkedList
维护访问顺序,头部是最新访问的数据HashMap
提供O(1)时间的键值访问- 访问数据时通过
updateAccess
方法更新位置 - 容量满时从链表尾部淘汰最久未使用的数据
4. 复杂度分析
- 时间复杂度:
- get操作:O(1)
- put操作:O(1)
- 空间复杂度:O(capacity)
三、LFU缓存实现详解
1. 核心思想
LFU基于"使用频率最低"原则淘汰数据,同时考虑访问频率和访问时间。
2. 完整实现代码
import java.util.LinkedHashSet;
import java.util.HashMap;
import java.util.Map;class LFUCache {// 缓存容量(final修饰,确保不可变)private final int capacity;// 当前最小访问频率(用于快速定位需要淘汰的键)private int minFrequency;// 键到值的映射(存储实际数据)private final Map<Integer, Integer> keyToValue;// 键到访问频率的映射private final Map<Integer, Integer> keyToFrequency;// 频率到键集合的映射(使用LinkedHashSet维护相同频率下的访问顺序)private final Map<Integer, LinkedHashSet<Integer>> frequencyToKeys;/*** 构造函数,初始化缓存容量和数据结构* @param capacity 缓存最大容量*/public LFUCache(int capacity) {this.capacity = capacity;this.minFrequency = 0; // 初始最小频率为0(无数据时)this.keyToValue = new HashMap<>();this.keyToFrequency = new HashMap<>();this.frequencyToKeys = new HashMap<>();}/*** 获取键对应的值,并更新访问频率* @param key 目标键* @return 对应的值,若不存在则返回-1*/public int get(int key) {if (!keyToValue.containsKey(key)) {return -1; // 键不存在时返回-1}// 增加该键的访问频率increaseFrequency(key);return keyToValue.get(key);}/*** 插入或更新键值对,维护缓存容量和频率信息* @param key 目标键* @param value 对应的值*/public void put(int key, int value) {if (capacity <= 0) {return; // 容量为0时不执行任何操作}if (keyToValue.containsKey(key)) {// 键已存在:更新值并增加频率keyToValue.put(key, value);increaseFrequency(key);return;}// 键不存在:检查是否需要淘汰旧数据if (keyToValue.size() >= capacity) {evictLeastFrequent(); // 淘汰访问频率最低的键}// 插入新键值对(初始频率为1)keyToValue.put(key, value);keyToFrequency.put(key, 1);// 初始化频率为1的键集合(若不存在则创建)frequencyToKeys.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(key);// 新插入数据后,最小频率必定为1(初始插入或淘汰后重置)this.minFrequency = 1;}/*** 增加指定键的访问频率,并更新相关数据结构* @param key 目标键*/private void increaseFrequency(int key) {int currentFrequency = keyToFrequency.get(key);// 频率加1keyToFrequency.put(key, currentFrequency + 1);// 从原频率集合中移除该键LinkedHashSet<Integer> oldFrequencySet = frequencyToKeys.get(currentFrequency);oldFrequencySet.remove(key);// 如果原频率集合为空,移除该频率条目if (oldFrequencySet.isEmpty()) {frequencyToKeys.remove(currentFrequency);// 如果移除的是当前最小频率,需要更新minFrequencyif (currentFrequency == minFrequency) {minFrequency++;}}// 将键添加到新频率的集合中(自动创建集合若不存在)frequencyToKeys.computeIfAbsent(currentFrequency + 1, k -> new LinkedHashSet<>()).add(key);}/*** 淘汰访问频率最低的键(优先淘汰频率最低且最久未使用的键)*/private void evictLeastFrequent() {// 获取最小频率对应的键集合LinkedHashSet<Integer> keysToEvict = frequencyToKeys.get(minFrequency);// 获取该集合中最早插入的键(LinkedHashSet按插入顺序存储)int keyToRemove = keysToEvict.iterator().next();// 从集合中移除该键keysToEvict.remove(keyToRemove);// 如果集合为空,移除该频率条目(无需更新minFrequency,put操作会重置)if (keysToEvict.isEmpty()) {frequencyToKeys.remove(minFrequency);}// 从核心数据结构中移除该键keyToValue.remove(keyToRemove);keyToFrequency.remove(keyToRemove);}
}
3. 关键点解析
- 使用三个核心数据结构:
keyToValue
:存储键值对keyToFrequency
:记录访问频率frequencyToKeys
:维护相同频率下的访问顺序
LinkedHashSet
保证同频率下的时序性minFrequency
变量高效定位淘汰目标- 新插入数据的初始频率总是1
4. 复杂度分析
- 时间复杂度:
- get操作:O(1)平均
- put操作:O(1)平均
- 空间复杂度:O(capacity)
四、两种算法对比
特性 | LRU | LFU |
---|---|---|
淘汰原则 | 最近最少使用 | 使用频率最低 |
数据结构 | 哈希表+双向链表 | 三重映射结构 |
时间复杂度 | O(1)访问和插入 | O(1)平均时间复杂度 |
优势场景 | 突发流量、时间局部性强的访问模式 | 稳定流量、长期热点数据 |
实现复杂度 | 简单 | 较复杂 |
五、生产环境建议
-
选择依据:
LRU
适合时间局部性强的场景LFU
适合需要识别长期热点的场景
-
优化建议:
- 使用
Caffeine
等成熟缓存库 - 高并发场景采用分片缓存策略
- 使用
-
扩展思考:
- 可结合
TTL
过期策略 - 考虑
LRU-K
等混合算法
- 可结合