LRU
LRU (Least Recently Used),即“最近最少使用”算法,是一种常见的缓存淘汰策略。它的核心思想是:如果一个数据在最近一段时间没有被访问,那么在未来也很可能不会被访问。当缓存空间不足,需要淘汰数据时,就优先淘汰那些最近最少使用的数据。
这种策略基于“局部性原理”(Locality of Reference),包括时间局部性(如果一个数据项被访问,那么在短时间内它很可能再次被访问)和空间局部性(如果一个数据项被访问,那么它附近的其它数据项也可能很快被访问)。LRU 主要利用了时间局部性。
为什么需要缓存淘汰算法?
在许多应用中,缓存(Cache)被用来存储经常访问的数据,以提高数据访问速度。然而,缓存的容量通常是有限的。当缓存满时,新数据需要入缓存,就必须淘汰一些旧数据。此时,就需要一个缓存淘汰策略来决定淘汰哪些数据,以最大化缓存命中率(即从缓存中获取数据的次数占总访问次数的比例)。
LRU 算法的实现原理
为了实现 LRU 算法,我们需要跟踪每个数据项的“使用时间”或者“使用频率”。当数据被访问时,它的“使用时间”就需要更新到最新。当需要淘汰数据时,我们就找到那个“使用时间”最久远的数据。
最常见的 LRU 实现方式是结合使用哈希表 (Hash Map) 和双向链表 (Doubly Linked List)。
-
哈希表 (Hash Map):
- 用于存储缓存中的键值对 (key-value)。
- 哈希表的键 (key) 是数据项的标识,值 (value) 是指向双向链表中对应节点的指针。
- 作用:O(1) 时间复杂度快速查找缓存中的数据项,并快速获取其在链表中的位置。
-
双向链表 (Doubly Linked List):
- 用于维护缓存中数据项的访问顺序。
- 链表的节点存储实际的数据项(或者指向数据项的指针)。
- 链表的头部 (Head) 总是表示最近被使用的数据项。
- 链表的尾部 (Tail) 总是表示最近最少使用的数据项。
- 作用:O(1) 时间复杂度在链表头部插入新节点或将已有节点移到头部,O(1) 时间复杂度在链表尾部删除节点。
LRUCache 类结构
LRUCache 类的属性和方法可以归类如下:
属性归类
-
缓存相关属性:
private Map<K, Node> cache;
存储缓存的键值对,键为类型K,值为Node对象。
-
链表相关属性:
private Node head;
指向双向链表的头节点。private Node tail;
指向双向链表的尾节点。
-
容量和大小属性:
private int capacity;
缓存的最大容量。private int size;
当前缓存中存储的元素数量。
方法归类
-
构造方法:
public LRUCache(int capacity)
构造函数,初始化缓存的容量、大小和链表的头尾节点。
-
获取方法:
public V get(K key)
根据键获取缓存中的值。如果键不存在,返回null;如果存在,将节点移动到链表头部并返回其值。
-
插入/更新方法:
public void put(K key, V value)
插入或更新键值对。如果键已存在,更新其值并移动到链表头部;如果键不存在且缓存已满,淘汰最近最少使用的节点。
-
链表操作方法:
private void addToHead(Node node)
将指定节点添加到链表头部。private void removeNode(Node node)
从链表中移除指定节点。private void moveToHead(Node node)
将指定节点移动到链表头部,先从当前位置移除,再添加到头部。private Node removeTail()
移除链表尾部的节点,并返回被移除的节点。
-
主方法:
public static void main(String[] args)
示例用法,展示如何使用LRUCache类进行插入和获取操作。
流程
初始化最大容量为 2 。
-
put(1, 1):- 将键
1和值1插入缓存。 - 当前缓存状态:
{(1, 1)}。
- 将键
-
put(2, 2):- 将键
2和值2插入缓存。 - 当前缓存状态:
{(2, 2), (1, 1)}(2是最新插入的)。
- 将键
-
get(1):- 获取键
1的值。 - 返回值为
1,并将节点移动到链表头部。 - 当前缓存状态:
{(1, 1), (2, 2)}(1是最新的)。
- 获取键
-
put(3, 3):- 将键
3和值3插入缓存。 - 此时缓存已满(容量为 2),需要淘汰最近最少使用的节点(即键
2)。 - 当前缓存状态:
{(3, 3), (1, 1)}(3是最新的)。
- 将键
-
get(2):- 尝试获取键
2的值。 - 返回值为
null,因为键2已被淘汰。
- 尝试获取键
-
put(4, 4):- 将键
4和值4插入缓存。 - 此时缓存已满,需要淘汰最近最少使用的节点(即键
1)。 - 当前缓存状态:
{(4, 4), (3, 3)}(4是最新的)。
- 将键
-
get(1):- 尝试获取键
1的值。 - 返回值为
null,因为键1已被淘汰。
- 尝试获取键
-
get(3):- 获取键
3的值。 - 返回值为
3,并将节点移动到链表头部。 - 当前缓存状态:
{(3, 3), (4, 4)}(3是最新的)。
- 获取键
-
get(4):- 获取键
4的值。 - 返回值为
4,并将节点移动到链表头部。 - 当前缓存状态:
{(4, 4), (3, 3)}(4是最新的)。
- 获取键
Java代码示例
package cn.srw.EvictionAlgorithm;
import java.util.HashMap;
import java.util.Map;
// 基于双向链表和HashMap实现
public class LRUCache<K, V> {
private Map<K, Node> cache;
private Node head; // 链表头
private Node tail; // 链表尾
private int capacity; // 缓存的最大容量
private int size; // 缓存的当前容量
public LRUCache(int capacity) {
this.capacity = capacity;
this.size = 0;
this.cache = new HashMap<>();
head = new Node(null, null);
tail = new Node(null, null);
head.next = tail;
tail.prev = head;
}
/**
* 获取缓存中的值。
* 如果 key 不存在,返回 null。
* 如果 key 存在,则将节点移动到链表头部,并返回其值。
*/
public V get(K key) {
Node node = cache.get(key);
if (node == null) {
return null; // 未命中
}
// 命中,将节点移动到链表头部
moveToHead(node);
return (V) node.value;
}
/**
* 插入或更新 key-value 对。
* 如果 key 已存在,则更新其值并将其移动到链表头部。
* 如果 key 不存在:
* 如果缓存已满,则淘汰链表尾部的最近最少使用节点。
* 然后将新节点添加到链表头部。
*/
public void put(K key, V value) {
Node node = cache.get(key);
if (node != null) {
// key 已存在,更新值并移动到头部
node.value = value;
moveToHead(node);
} else {
// key 不存在
// 检查缓存是否已满
if (size == capacity) {
// 缓存已满,淘汰最近最少使用的节点(尾部节点)
Node tailNode = removeTail();
cache.remove(tailNode.key); // 从 HashMap 中移除
size--; // 减少当前大小
}
// 创建新节点并添加到头部
Node newNode = new Node(key, value);
cache.put(key, newNode); // 添加到 HashMap
addToHead(newNode); // 添加到链表头部
size++; // 增加当前大小
}
}
/**
* 将节点添加到链表头部(head 之后)。
*/
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
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);
}
/**
* 移除链表尾部的节点(在 tail 之前)。
* 返回被移除的节点。
*/
private Node removeTail() {
Node actualTail = tail.prev;
removeNode(actualTail);
return actualTail;
}
public static void main(String[] args) {
// 示例用法
LRUCache<Integer, Integer> lruCache = new LRUCache<>(2); // 容量为 2
System.out.println("put(1, 1)");
lruCache.put(1, 1); // 缓存: {(1,1)}
System.out.println("put(2, 2)");
lruCache.put(2, 2); // 缓存: {(2,2), (1,1)} (2是最新)
System.out.println("get(1) -> " + lruCache.get(1)); // 返回 1。缓存: {(1,1), (2,2)} (1是最新)
System.out.println("put(3, 3)"); // 缓存满,淘汰 (2,2)
lruCache.put(3, 3); // 缓存: {(3,3), (1,1)} (3是最新)
System.out.println("get(2) -> " + lruCache.get(2)); // 返回 -1 (2已被淘汰)
System.out.println("put(4, 4)"); // 缓存满,淘汰 (1,1)
lruCache.put(4, 4); // 缓存: {(4,4), (3,3)} (4是最新)
System.out.println("get(1) -> " + lruCache.get(1)); // 返回 -1 (1已被淘汰)
System.out.println("get(3) -> " + lruCache.get(3)); // 返回 3。缓存: {(3,3), (4,4)} (3是最新)
System.out.println("get(4) -> " + lruCache.get(4)); // 返回 4。缓存: {(4,4), (3,3)} (4是最新)
}
}
// 内部节点类
class Node<K, V> {
K key;
V value;
Node<K, V> prev; // <K,V>只能指向同类型,类型安全,如果删除
Node<K, V> next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
}