一、前言
大家好,我是你们的Boom弟弟,天不怕,地不怕,就怕你不进来。
本篇文章记录了LRU缓存淘汰算法的学习过程,缓存在我们的日常工作中是随处可见,缓存淘汰算法还是有必要了解一下的。
悄悄话: 面试中经常会要你手写LRU!!!所以学不学,您看着办
那接下来我们就冲冲冲!!!
二、LRU简介
缓存数据是基于内存存储的,所以CRUD操作都非常快,而内存的空间又是非常宝贵的,当缓存数据到达一定容量。想当然的会淘汰一部分数据,而这个淘汰策略就是所谓的缓存淘汰算法。
缓存淘汰算法有很多种,最经典的当数LRU、LFU
LRU算法: 全称是 Least Recently Used,即最近最少访问。也就是说我们认为最近使用过的数据是有用的,很久都没用过的数据是无用的,内存满了就优先删那些很久没用过的数据。
用一句话概括就是喜新厌旧,它的关注点在于缓存命中的时间。
算法逻辑如下:
- 通过一个双向链表维护缓存的数据(K-V),根据时间的先后顺序,链表的每个结点都维护了一对前驱指针和后继指针。
- 每当发生put操作时,数据插入链表的尾部。即越靠近尾部的数据越新,越靠近头部的数据越老。
- 每当发生get操作时,遍历链表找到对应的结点,即命中了缓存,此时,会把改结点的数据移动到链表的尾部(即最近被访问的数据)。
- 当链表的容量达到我们设置的最大容量时,而此时又有数据发生put操作,则会先移除掉链表头部的老数据(即最少被访问的数据)。
注意 前方高能!!!
虽然put操作的时间复杂度为O(1)。 但是上面的第3步就有点问题,get操作遍历链表? 时间复杂度不是 O(n) 吗? 这好像不太符合缓存“快”的特性。那能不能把查询的时间复杂度再提升一下呢?
当然有,我的宝,那就是再引入一个哈希表的结构,即空间换时间的思想!!!
结构如下:
-
哈希表的key为链表中对应结点的key,。
-
哈希表的value指向链表中对应的结点。
咦,这样查询的时间复杂度是不是就变为O(1)了,相当符合我们缓存“快”的特性。
有了理论基础之后,我们来手写一个LRU算法
PS:理论搞清楚之后,写起来其实不难的。
三、手写LRU
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
/**
* 缓存淘汰算法--LRU--最近最少使用
* 喜新厌旧
*/
public class LRUTest {
// 双向链表的最大容量,当达到这个容量时,会移除最少使用的数据
private final int maxlen=20;
// 维护尾结点,方便获取
private LRULinked startLRULinked;
// 维护尾结点,方便获取
private LRULinked endLRULinked;
// hash表---用来提升查询的速度----时间复杂度O(1)
private Map<String, LRULinked> linkedHashMap = new ConcurrentHashMap<>();
/**
* 缓存的put操作
* @param key
* @param data
*/
public synchronized void put(String key, int data){
// 去hash表中判断缓存中是否存在该Key (第二个参数1的意思是控制台打印而已,可以略)
LRULinked dataLru = get(key, 1);
if (dataLru!=null){
// 如果存在,则更新对应缓存的data, 同时更新链表的结点,以及hash表
dataLru.data=data;
linkedHashMap.put(key, dataLru);
System.out.println("key: "+key+"命中了缓存, data:"+data+", 触发缓存更新");
return;
}
//当达到最大容量, 移除头结点
int size = linkedHashMap.size();
System.out.println("----当前链表长度----"+ size);
if (size==maxlen){
try {
System.err.println("链表达到最大长度, 移除头结点, key: "+startLRULinked.key+", value:"+startLRULinked.data);
remove(startLRULinked);
}catch (Exception e){
e.printStackTrace();
}
}
//如果头结点和尾结点都为null 则说明该链表是一个空链表 直接插入即可 此时头结点就是尾结点
if (startLRULinked==null && endLRULinked==null){
endLRULinked = startLRULinked = new LRULinked(key, data, null, null);
}else {
// 反之, 插入到链表的尾部,此时尾结点变为新插入的结点,新尾结点的前驱指针指向旧尾结点, 旧尾结点的后继指针指向新尾结点
LRULinked oldEndLRULinked = endLRULinked;
LRULinked lruLinked = new LRULinked(key, data, oldEndLRULinked, null);
oldEndLRULinked.next = lruLinked;
endLRULinked = lruLinked;
}
linkedHashMap.put(key, endLRULinked);
}
/**
* 移除缓存remove操作
* @param lruLinked
* @return
*/
public synchronized LRULinked remove(LRULinked lruLinked){
if (lruLinked==null){
return null;
}
// 先移除hash表的缓存
linkedHashMap.remove(lruLinked.key);
//获取该结点的前驱结点和后继结点
LRULinked next = lruLinked.next;
LRULinked pre = lruLinked.pre;
//如果后继结点不等于null 则后继结点的前驱结点指向 该结点的前驱结点
if (next!=null){
next.pre = pre;
}else {
//如果后继结点为null 则说明该结点就是尾结点 则该结点的前驱结点充当尾结点
endLRULinked=pre;
}
//和上面同理
if (pre!=null){
pre.next = next;
}else {
startLRULinked=next;
}
return lruLinked;
}
/**
* 获取缓存数据的get操作
* @param key
* @param confirmIsExit
* @return
*/
public synchronized LRULinked get(String key, int confirmIsExit){
if (endLRULinked==null && startLRULinked==null)
return null;
//去hash表查询对应key的数据 判断缓存是否存在
LRULinked lruLinked = linkedHashMap.get(key);
if (confirmIsExit!=1){
printAllPoint();
//命中缓存之后,把该结点的数据移动到尾结点(即最近访问的数据)
if (lruLinked!=null){
System.err.println("key: "+key+"命中缓存, data: "+ lruLinked.data);
LRULinked next = lruLinked.next;
LRULinked pre = lruLinked.pre;
if (next!=null)
next.pre=pre;
if (pre!=null)
pre.next=next;
linkedHashMap.remove(key);
put(lruLinked.key, lruLinked.data);
}
}
return lruLinked;
}
public void printAllPoint(){
if (endLRULinked==null && startLRULinked==null)
return ;
LRULinked lruLinkedParam = endLRULinked;
while (true){
if (lruLinkedParam.pre==null){
System.out.println("key: "+lruLinkedParam.key+"---value: "+lruLinkedParam.data+"");
return;
}else {
System.out.print("key: "+lruLinkedParam.key+"---value: "+lruLinkedParam.data+", ");
}
lruLinkedParam = lruLinkedParam.pre;
}
}
/**
* 模拟LRU缓存淘汰策略
* 1. 开一个查询线程, 随机查询【1-30】
* 2. 开一个插入线程,随机插入【1-30】
* 3. 设置链表长度,当长度超过N时,从头结点依次踢出。
* 4. 当缓存命中,把命中的结点移动到尾部插入
* @param args
*/
public static void main(String[] args) {
LRUTest test = new LRUTest();
Thread findThread = new Thread("查询线程") {
@Override
public void run() {
Random random = new Random();
while (true){
System.out.println("开始新一轮查询了....");
int num = random.nextInt(30)+1;
test.get(num+"", 0);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread addThread = new Thread("插入线程") {
@Override
public void run() {
Random random = new Random();
while (true){
System.out.println("开始新一轮插入了....");
int num = random.nextInt(30)+1;
test.put(num+"", random.nextInt(30)+1);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
findThread.start();
addThread.start();
}
/**
* 双向链表结点
*/
class LRULinked{
// 缓存的key
private String key;
// 缓存的data
private int data;
// 前驱结点
private LRULinked pre;
// 后继结点
private LRULinked next;
public LRULinked(String key, int data, LRULinked pre, LRULinked next) {
this.key = key;
this.data = data;
this.pre = pre;
this.next = next;
}
}
}
四、LRU的应用
Redis中的应用
这就要聊到Redis内存淘汰机制
在redis.conf中有一行参数用来配置内存淘汰策略的
maxmemory-policy volatile-LRU
-
volatile-LRU:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
-
volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
-
volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
-
allkeys-LRU:从数据集中挑选最近最少使用的数据淘汰
-
allkeys-random:从数据集中任意选择数据淘汰
-
no-enviction(驱逐):禁止驱逐数据,新写入操作会报错
可以看得出来如果使用了 volatile-LRU策略,则将会使用LRU数据结构来对内存中的数据进行清理。
Mysql中的应用
Mysql中Innodb的buffer pool也是用来加速查询的缓存,当buffer pool的容量被占满时,也需要淘汰数据,其中数据的淘汰也是基于LRU算法的。
所有从磁盘上读取的数据首先都会缓存在buffer pool中。当对存放大量冷数据的表进行查询时,会在短时间内将大量冷数据加载到buffer pool中,如果buffer pool被占满之后就会根据LRU算法淘汰数据,可能就把之间的热点数据淘汰了,从而导致的缓存命中率下降。
为了避免因为冷数据表的查询导致热点数据被淘汰的问题,Mysql对LRU算法进行了改进,将buffer pool分成young和old两个区域,所有数据被加载进buffer pool时都是先放在old区,当在old区待足够长的时间或被访问次数达到阈值时数据才会被放到young区。数据淘汰也是优先从old区淘汰。这样就能避免大量冷数据加载导致的热点数据淘汰问题。
五、总结
-
LRU缓存机制的核心:双向链表(保证元素有序,且能快速的插入和删除)+hash表(可以快速查询)
-
为什么使用双向链表?因为:对于删除操作,使用双向链表,我们可以在O(1)的时间复杂度下,找到被删除节点的前节点。
-
为什么要在链表中同时存键值,而不是只存值? 因为:当缓存容量满了之后,我们不仅要在双向链表中删除最后一个节点(即最久没有使用的节点),还要把hash表中对应的数据删除。如果尾结点中没有key,只有data的话,是没办法去hash表中找对对应的数据的。