使用 Java 实现 LRU(Least Recently Used,最近最少使用)缓存是一个非常经典的面试题,它考验了候选人对数据结构、面向对象设计以及 Java 集合框架的掌握程度。
LRU 缓存的核心思想
LRU 缓存的核心思想是"如果数据最近被访问过,那么它在将来被访问的概率也更高"。当缓存容量已满,需要插入新数据时,它会淘汰最久未被使用的数据。
为了实现这个逻辑,我们需要满足两个基本操作:
- 快速查找 :当访问一个数据时,需要能够快速判断它是否在缓存中,并且要能快速地更新它的"最近使用"状态。
HashMap
的平均 O(1) 时间复杂度的查找非常适合。 - 快速排序/维护顺序:我们需要一种结构来记录所有数据的"最近使用"顺序,并且当数据被访问或更新时,能快速地将其移动到"最新"的位置,当需要淘汰时,能快速地找到"最旧"的数据。双向链表非常适合这个场景,因为它可以在 O(1) 时间内完成节点的插入和删除。
方法一:使用 LinkedHashMap
实现(标准做法)
LinkedHashMap
是 Java 集合框架中一个特殊的类,它继承自 HashMap
,并在其基础上维护了一个双向链表,这个链表记录了元素的插入顺序或访问顺序。
我们可以利用这个"访问顺序"特性来非常轻松地实现 LRU 缓存。
实现步骤
- 继承
LinkedHashMap
:创建一个类LRUCache<K, V>
继承LinkedHashMap<K, V>
。 - 设置访问顺序 :在构造函数中,调用父类
LinkedHashMap
的构造函数,并将accessOrder
参数设置为true
。这会让链表按照元素的访问顺序(从最近访问到最久未访问)进行排序,而不是插入顺序。 - 重写
removeEldestEntry
方法 :LinkedHashMap
本身提供了一个回调方法removeEldestEntry(Map.Entry eldest)
。在每次向Map
中添加新元素后,此方法都会被调用。当此方法返回true
时,LinkedHashMap
会自动移除最旧的条目(即链表的头节点)。我们只需在这个方法中判断当前Map
的大小是否超过了我们设定的容量。
代码实现
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 使用 LinkedHashMap 实现的 LRU 缓存
* @param <K> 键类型
* @param <V> 值类型
*/
public class LRUCacheWithLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
/**
* 构造函数
* @param maxCapacity 缓存的最大容量
*/
public LRUCacheWithLinkedHashMap(int maxCapacity) {
// 调用父类构造函数
// initialCapacity: 初始容量,设置为 maxCapacity 可以避免扩容
// loadFactor: 加载因子,0.75 是默认值
// accessOrder: true 表示按访问顺序排序,这是实现 LRU 的关键
super(maxCapacity, 0.75f, true);
this.maxCapacity = maxCapacity;
}
/**
* 当插入新元素后,此方法会被调用。
* 如果返回 true,则会移除最旧的元素。
* @param eldest 最旧的元素(即将被移除的元素)
* @return true 如果当前大小超过了最大容量,则移除最旧元素
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当缓存大小超过最大容量时,返回 true,让 LinkedHashMap 自动移除最旧的条目
return size() > maxCapacity;
}
// 为了演示,我们添加一个 main 方法
public static void main(String[] args) {
LRUCacheWithLinkedHashMap<Integer, String> cache = new LRUCacheWithLinkedHashMap<>(3);
System.out.println("------ 初始插入 1, 2, 3 ------");
cache.put(1, "A");
cache.put(2, "B");
cache.put(3, "C");
System.out.println(cache); // 顺序应为 {1=A, 2=B, 3=C} (访问顺序)
System.out.println("\n------ 访问键 1,使其变为最新 ------");
cache.get(1);
System.out.println(cache); // 顺序变为 {2=B, 3=C, 1=A}
System.out.println("\n------ 插入键 4,此时应淘汰最旧的键 2 ------");
cache.put(4, "D");
System.out.println(cache); // 顺序应为 {3=C, 1=A, 4=D}
}
}
优点
- 代码极其简洁:只需要几行核心代码,利用了 Java 库的强大功能。
- 高效可靠 :
LinkedHashMap
是经过高度优化的,其性能和稳定性都有保障。 - 易于理解和维护:代码意图非常明确。
方法二:使用 HashMap
+ 双向链表 实现(从零开始)
这种方式完全由我们自己构建数据结构,能更深刻地理解 LRU 的原理。
实现步骤
- 定义双向链表节点 :创建一个
Node
类,包含key
,value
,prev
(前驱指针)和next
(后继指针)。 - 定义数据结构 :
- 一个
HashMap<Integer, Node>
用于存储键到节点的映射,实现 O(1) 时间复杂度的查找。 - 一个双向链表,用于维护节点的访问顺序。链表头部是最新访问的节点,尾部是最久未访问的节点。
- 两个哨兵节点:
head
和tail
,作为链表的头尾哨兵,可以简化边界条件的处理(如插入和删除时无需判断节点是否为 null)。
- 一个
- 实现核心方法 :
get(key)
:- 通过
HashMap
查找节点。 - 如果节点不存在,返回
null
或 -1。 - 如果节点存在,将该节点从链表中当前位置移除,并将其添加到链表头部(表示它刚刚被访问过)。最后返回节点的值。
- 通过
put(key, value)
:- 通过
HashMap
查找节点。 - 如果节点已存在(更新操作):
- 更新节点的
value
。 - 将该节点从链表中当前位置移除,并添加到链表头部。
- 更新节点的
- 如果节点不存在(新增操作):
- 创建一个新节点。
- 将新节点添加到链表头部。
- 将新节点存入
HashMap
。 - 检查缓存是否已满:如果
HashMap.size() > capacity
,则执行淘汰操作。
- 淘汰操作 (
evict
) :- 获取链表尾部的节点(最久未使用)。
- 从
HashMap
中移除该节点对应的键。 - 从链表中移除该节点。
- 通过
- 辅助方法 :为了代码清晰,可以封装一些辅助方法,如
addToHead(Node node)
、removeNode(Node node)
、removeTail()
。
代码实现
import java.util.HashMap;
import java.util.Map;
/**
* 使用 HashMap + 双向链表实现的 LRU 缓存
*/
public class LRUCacheWithHashMapAndList {
// 双向链表节点定义
class Node {
int key;
int value;
Node prev;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private final int capacity;
private final Map<Integer, Node> cacheMap;
private final Node head; // 虚拟头节点
private final Node tail; // 虚拟尾节点
public LRUCacheWithHashMapAndList(int capacity) {
this.capacity = capacity;
this.cacheMap = new HashMap<>(capacity);
// 初始化双向链表,使用虚拟头尾节点简化操作
this.head = new Node(-1, -1);
this.tail = new Node(-1, -1);
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = cacheMap.get(key);
if (node == null) {
return -1; // 未找到
}
// 节点存在,将其移动到链表头部,表示最近访问
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
Node node = cacheMap.get(key);
if (node != null) {
// 节点已存在,更新值并移动到头部
node.value = value;
moveToHead(node);
} else {
// 节点不存在,创建新节点
Node newNode = new Node(key, value);
cacheMap.put(key, newNode);
// 添加到链表头部
addToHead(newNode);
// 检查是否超出容量
if (cacheMap.size() > capacity) {
// 超出容量,移除尾部节点(最久未使用)
Node tailNode = removeTail();
cacheMap.remove(tailNode.key);
}
}
}
// --- 辅助方法 ---
/**
* 将节点添加到链表头部
*/
private void addToHead(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
/**
* 从链表中移除指定节点
*/
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
/**
* 将节点移动到链表头部
*/
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
/**
* 移除链表尾部节点,并返回该节点
*/
private Node removeTail() {
Node node = tail.prev;
removeNode(node);
return node;
}
// 为了演示,我们添加一个 main 方法
public static void main(String[] args) {
LRUCacheWithHashMapAndList cache = new LRUCacheWithHashMapAndList(3);
System.out.println("------ 初始插入 1, 2, 3 ------");
cache.put(1, 10);
cache.put(2, 20);
cache.put(3, 30);
System.out.println("Get 1: " + cache.get(1)); // 输出 10,此时 1 变为最新
System.out.println("\n------ 插入键 4,此时应淘汰最旧的键 2 ------");
cache.put(4, 40);
System.out.println("Get 1 (should be 10): " + cache.get(1)); // 1 还在
System.out.println("Get 2 (should be -1): " + cache.get(2)); // 2 已被淘汰
System.out.println("Get 3 (should be 30): " + cache.get(3)); // 3 还在
System.out.println("Get 4 (should be 40): " + cache.get(4)); // 4 还在
}
}
优点
- 深入理解原理:完全手写一遍能让你对 LRU 的工作机制有透彻的理解。
- 灵活性高:如果需求有变(例如实现 LFU),可以基于这个结构进行修改。
总结与对比
特性 | LinkedHashMap 实现 |
HashMap + 双向链表 实现 |
---|---|---|
代码量 | 非常少,核心逻辑只需几行 | 较多,需要定义节点和链表操作 |
实现难度 | 简单,只需了解 LinkedHashMap 特性 |
中等,需要熟练掌握链表和 HashMap |
性能 | 优秀,与手写实现相当 | 优秀,所有操作均为 O(1) |
适用场景 | 生产环境、日常开发 | 算法学习、技术面试 |
核心思想 | 利用 Java 库的现有功能 | 从零构建,展示底层原理 |