面试官:你知道 LRU算法 —— 缓存淘汰算法吗?
常用缓存提升数据查询速度,由于缓存容量有限,当缓存容量到达上限,就需要删除部分数据挪出空间,这样新数据才可以添加进来。缓存数据不能随机删除,一般情况下我们需要根据某种算法删除缓存数据。常用淘汰算法有 LRU
,LFU
,FIFO
。这篇文章我们聊聊 LRU
算法。
一、LRU 简介
LRU
是( Least Recently Used
) 的缩写,这种算法认为最近使用的数据是热门数据,下一次很大概率将会再次被使用。而最近很少被使用的数据,很大概率下一次不再用到。当缓存容量的满时候,优先淘汰最近很少使用的数据。其在Redis、Guava等工具中也有非常广泛的应用,甚至是最核心的思想之一。
假设现在缓存内部数据如图所示:
这里我们将列表第一个节点称为头结点,最后一个节点为尾结点。
当调用缓存获取 key=1
的数据,LRU
算法需要将 1
这个节点移动到头结点,其余节点不变,如图所示。
然后我们插入一个 key=8 节点,此时缓存容量到达上限,所以加入之前需要先删除数据。由于每次查询都会将数据移动到头结点,未被查询的数据就将会下沉到尾部节点,尾部的数据就可以认为是最少被访问的数据,所以删除尾结点的数据。
然后我们直接将数据添加到头结点。
所以,LRU 算法具体步骤:
- 新数据直接插入到列表头部
- 缓存数据被命中,将数据移动到列表头部
- 缓存已满的时候,移除列表尾部数据。
二、LRU 算法实现
LRU
使用双向链表加散列表(哈希表)结合体,数据结构如图所示:
在双向链表中特意增加两个『哨兵』节点,不用来存储任何数据。使用哨兵节点,增加/删除节点的时候就可以不用考虑边界节点不存在情况,简化编程难度,降低代码复杂度。
LRU
算法实现代码如下,为了简化 key ,val 都认为 int 类型。
public class LRUCache {
Entry head, tail;
int capacity;
int size;
Map<Integer, Entry> cache;
public LRUCache(int capacity) {
this.capacity = capacity;
// 初始化链表
initLinkedList();
size = 0;
cache = new HashMap<>(capacity + 2);
}
/**
* 如果节点不存在,返回 -1.如果存在,将节点移动到头结点,并返回节点的数据。
*/
public int get(int key) {
Entry node = cache.get(key);
if (node == null) {
return -1;
}
// 存在移动节点
moveToHead(node);
return node.value;
}
/**
* 将节点加入到头结点,如果容量已满,将会删除尾结点
*/
public void put(int key, int value) {
Entry node = cache.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
return;
}
// 不存在。先加进去,再移除尾结点
// 此时容量已满 删除尾结点
if (size == capacity) {
Entry lastNode = tail.pre;
deleteNode(lastNode);
cache.remove(lastNode.key);
size--;
}
// 加入头结点
Entry newNode = new Entry();
newNode.key = key;
newNode.value = value;
addNode(newNode);
cache.put(key, newNode);
size++;
}
private void moveToHead(Entry node) {
// 首先删除原来节点的关系
deleteNode(node);
addNode(node);
}
private void addNode(Entry node) {
head.next.pre = node;
node.next = head.next;
node.pre = head;
head.next = node;
}
private void deleteNode(Entry node) {
node.pre.next = node.next;
node.next.pre = node.pre;
}
public static class Entry {
public Entry pre;
public Entry next;
public int key;
public int value;
public Entry(int key, int value) {
this.key = key;
this.value = value;
}
public Entry() {
}
}
private void initLinkedList() {
head = new Entry();
tail = new Entry();
head.next = tail;
tail.pre = head;
}
public static void main(String[] args) {
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
System.out.println(cache.get(1));
cache.put(3, 3);
System.out.println(cache.get(2));
}
}
三、LRU 算法分析
缓存命中率是缓存系统的非常重要指标,如果缓存系统的缓存命中率过低,将会导致查询回流到数据库,导致数据库的压力升高。
结合以上分析 LRU 算法优缺点。
- LRU 算法优势在于算法实现难度不大,对于对于热点数据, LRU 效率会很好。
- LRU 算法劣势在于对于偶发的批量操作,比如说批量查询历史数据,就有可能使缓存中热门数据被这些历史数据替换,造成缓存污染,导致缓存命中率下降,减慢了正常数据查询。
四、LRU 算法改进
4.1 从LRU-1
到LRU-K
的优化
上面的LRU算法还有一些不足:当热点数据较多时,有较高的命中率,但是如果有偶发性的批量操作,会使得热点数据被非热点数据挤出容器。
那就出现了LRU-K
,它是对上面LRU算法的改进,可以说上面的基础LRU是LRU-1
,LRU-K
是将原先进入缓存队列的评判标准从访问一次改为访问K次。
LRU-K算法有两个队列,一个是缓存队列
,一个是数据访问历史队列
。当访问一个数据时,首先先在访问历史队列中累加访问次数,当历史访问记录超过K次后,才将数据缓存至缓存队列,从而避免缓存队列被污染。同时访问历史队列中的数据可以按照LRU的规则进行淘汰。
LRU-K缓存实现:
// 直接继承我们前面写好的LRUCache
public class LRUKCache extends LRUCache {
private int k; // 进入缓存队列的评判标准
private LRUCache historyList; // 访问数据历史记录
public LRUKCache(int cacheSize, int historyCapacity, int k) {
super(cacheSize);
this.k = k;
this.historyList = new LRUCache(historyCapacity);
}
@Override
public Integer get(Integer key) {
// 记录数据访问次数
Integer historyCount = historyList.get(key);
historyCount = historyCount == null ? 0 : historyCount;
historyList.put(key, ++historyCount);
return super.get(key);
}
@Override
public Integer put(Integer key, Integer value) {
if (value == null) {
return null;
}
// 如果已经在缓存里则直接返回缓存中的数据
if (super.get(key) != null) {
return super.put(key, value);;
}
// 如果数据历史访问次数达到上限,则加入缓存
Integer historyCount = historyList.get(key);
historyCount = historyCount == null ? 0 : historyCount;
if (historyCount >= k) {
// 移除历史访问记录
historyList.remove(key);
return super.put(key, value);
}
}
}
一般来讲,当K的值越大,则缓存的命中率越高,但是也会使得缓存难以被淘汰。综合来说,使用LRU-2的性能最优。
4.2 冷热分区优化
将链表拆分成两部分,分为热数据区,与冷数据区,如图所示。
改进之后算法流程将会变成下面一样:
- 访问数据如果位于热数据区,与之前 LRU 算法一样,移动到热数据区的头结点。
- 插入数据时,若缓存已满,淘汰尾结点的数据。然后将数据插入冷数据区的头结点。
- 处于冷数据区的数据每次被访问需要做如下判断:若该数据已在缓存中超过指定时间,比如说 1 s,则移动到热数据区的头结点。若该数据存在在时间小于指定的时间,则位置保持不变。
对于偶发的批量查询,数据仅仅只会落入冷数据区,然后很快就会被淘汰出去。热门数据区的数据将不会受到影响,这样就解决了 LRU 算法缓存命中率下降的问题。其他改进方法还有 LRU-K,2Q,LIRS 算法,感兴趣同学可以自行查阅。
五、Java中的LRU实现思路
根据LRU算法,在Java中实现需要这些条件:
- 底层数据使用双向链表,方便在链表的任意位置进行删除,在链表尾进行添加
- 需要将链表按照访问(使用)顺序排序
- 数据量超过一定阈值后,需要删除
Least Recently Used
数据
java.util.LinkedHashMap
天然支持上面的思路,因为它的构造方法提供了accessOrder
选项,控制get方法的链表顺序是按访问顺序还是插入顺序,且它的底层就是利用的双向链表结构,覆盖了父类HashMap的newNode方法和newTreeNode方法,这两个方法在HashMap中只是创建Node用的,而在LinkedHashMap中不但创建Node,还将Node放在链表末尾。
实现如下:
// 继承LinkedHashMap
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int MAX_CACHE_SIZE;
public LRUCache(int cacheSize) {
// 使用构造方法 public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
// initialCapacity、loadFactor都不重要
// accessOrder要设置为true,按访问排序
super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
MAX_CACHE_SIZE = cacheSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
// 超过阈值时返回true,进行LRU淘汰
return size() > MAX_CACHE_SIZE;
}
}
六、Redis中的LRU
Redis中的数据量通常很庞大,如果每次对全量数据进行排序,势必将对服务吞吐量造成影响。因此,Redis在LRU淘汰部分key时,使用的是采样并计算近似LRU的,因此淘汰的是局部LRU数据。