什么是LRU算法

Posted 王新焱

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了什么是LRU算法相关的知识,希望对你有一定的参考价值。

什么是LRU

LRU 英文全称(Least recently used,最近最少使用)属于典型的内存管理算法。
内存管理的一种页面置换算法,对于在内存中但又不用的数据块(内存块)叫做LRU,操作系统会根据哪些数据属于LRU而将其移出内存而腾出空间来加载另外的数据。用通俗的话来说就是最近被频繁访问的数据会具备更高的留存,淘汰那些不常被访问的数据。 

LRU算法又叫淘汰算法,根据数据历史访问记录进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

LRU: Least Recently Used, 最近最少使用,主要应用场景是缓存,缓存规则如下。
①.最近被使用或访问的数据放置在最前面;
②.没当缓存命中(即缓存数据被访问)则将数据移到头部;
③.当缓存数量达到最大值时,将最近最少访问的数据剔除;   

LRU应用场景

①.vue 中 keep-alive 内置组件,组件切换过程中将状态保留在内存中,防止重复渲染DOM,减少加载时间及性能消耗,提高用户体验。 
②.底层的内存管理,页面置换算法。
③.一般的缓存服务,memcache\\redis之类。

虚拟内存

关于操作系统的内存管理,如何节省利用容量不大的内存为最多的进程提供资源,一直是研究的重要方向。而内存的虚拟存储管理,是现在最通用,最成功的方式—— 在内存有限的情况下,扩展一部分外存作为虚拟内存,真正的内存只存储当前运行时所用得到信息。这无疑极大地扩充了内存的功能,极大地提高了计算机的并发度。虚拟页式存储管理,则是将进程所需空间划分为多个页面,内存中只存放当前所需页面,其余页面放入外存的管理方式。 

然而,有利就有弊,虚拟页式存储管理减少了进程所需的内存空间,却也带来了运行时间变长这一缺点:进程运行过程中,不可避免地要把在外存中存放的一些信息和内存中已有的进行交换,由于外存的低速,这一步骤所花费的时间不可忽略。因而,采取尽量好的算法以减少读取外存的次数。

缓存

缓存是一种提高数据读取性能的技术,在硬件设计,软件开发中都有非常广泛的作用,常见的CPU缓存,数据库缓存,浏览器缓存等。
缓存大小是有限的,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。

常见的策略有三种:
先进先出策略(FIFO,First In,First Out)
最少使用策略(LFU,Least Rrequently Used)
最近最少使用策略(LRU,Least Recently Used)

数组与链表

从底层的存储结构看,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个100MB大小的数组,当内存中没有连续的,足够大的存储空间,即便内存的剩余可用空间大于100MB,仍然会申请失败!

而链表不一样,它并不需要一块连续的内存空间,它通过指针将一组零散的内存块串联起来使用,所以我们如果申请的是100MB大小的链表,是不会出现问题的!

数组和链表性能对比 

数组和链表是两种不同的数据结构。它们的内存存储方式有区别,所以在插入、删除、访问操作的时间复杂度是不一样的,如下所示: 

数组和链表的对比,并不能局限于时间复杂度,在实际的程序开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。

数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不是很好,不能有效的预读。

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”;如果声明的数组过小,则可能出现不够用的情况,这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常耗时。而链表没有大小的限制,支持动态扩容,这也是它与数组最大的区别。

如果你的程序对内存使用比较苛刻,则可以考虑选择使用数组,因为链表中的每个结点都需要额外的存储空间去存储指向下一个结点的指针,所以链表的内存消耗会大点,而且对链表进行频繁的插入、删除操作会导致频繁的内存申请和释放,容易造成内存碎片,如果是Go语言,就可能会导致频繁的GC。所以,在我们实际的开发中,要根据具体的开发场景,来权衡选择使用哪种数据结构。

FIFO和LRU

FIFO是最简单的一种缓存算法,设置缓存上限,当达到了缓存上限的时候,按照先进先出的策略进行淘汰,再增加进新的 k-v 。

使用了一个对象作为缓存,一个数组配合着记录添加进对象时的顺序,判断是否到达上限,若到达上限取数组中的第一个元素key,对应删除对象中的键值。

/**
 * FIFO队列算法实现缓存
 * 需要一个对象和一个数组作为辅助
 * 数组记录进入顺序
 */
class FifoCache
    constructor(limit)
        this.limit = limit || 10
        this.map = 
        this.keys = []
    
    set(key,value)
        let map = this.map
        let keys = this.keys
        if (!Object.prototype.hasOwnProperty.call(map,key)) 
            if (keys.length === this.limit) 
                delete map[keys.shift()]//先进先出,删除队列第一个元素
            
            keys.push(key)
        
        map[key] = value//无论存在与否都对map中的key赋值
    
    get(key)
        return this.map[key]
    


module.exports = FifoCache

LRU算法的观点是,最近被访问的数据那么它将来访问的概率就大,缓存满的时候,优先淘汰最无人问津者

算法实现思路:基于一个双链表的数据结构,在没有满员的情况下,新来的 k-v 放在链表的头部,以后每次获取缓存中的 k-v 时就将该k-v移到最前面,缓存满的时候优先淘汰末尾的。

双向链表的特点,具有头尾指针,每个节点都有 prev(前驱) 和 next(后继) 指针分别指向他的前一个和后一个节点。

关键点:在双链表的插入过程中要注意顺序问题,一定是在保持链表不断的情况下先处理指针,最后才将原头指针指向新插入的元素。

class LruCache 
    constructor(limit) 
        this.limit = limit || 10
        //head 指针指向表头元素,即为最常用的元素
        this.head = this.tail = undefined
        this.map = 
        this.size = 0
    
    get(key, IfreturnNode) 
        let node = this.map[key]
        // 如果查找不到含有`key`这个属性的缓存对象
        if (node === undefined) return
        // 如果查找到的缓存对象已经是 tail (最近使用过的)
        if (node === this.head)  //判断该节点是不是是第一个节点
            // 是的话,皆大欢喜,不用移动元素,直接返回
            return returnnode ?
                node :
                node.value
        
        // 不是头结点,铁定要移动元素了
        if (node.prev)  //首先要判断该节点是不是有前驱
            if (node === this.tail)  //有前驱,若是尾节点的话多一步,让尾指针指向当前节点的前驱
                this.tail = node.prev
            
            //把当前节点的后继交接给当前节点的前驱去指向。
            node.prev.next = node.next
        
        if (node.next)  //判断该节点是不是有后继
            //有后继的话直接让后继的前驱指向当前节点的前驱
            node.next.prev = node.prev
            //整个一个过程就是把当前节点拿出来,并且保证链表不断,下面开始移动当前节点了
        
        node.prev = undefined //移动到最前面,所以没了前驱
        node.next = this.head //注意!!! 这里要先把之前的排头给接到手!!!!让当前节点的后继指向原排头
        if (this.head) 
            this.head.prev = node //让之前的排头的前驱指向现在的节点
        
        this.head = node //完成了交接,才能执行此步!不然就找不到之前的排头啦!
        return IfreturnNode ?
            node :
            node.value
    
    set(key, value) 
        // 之前的算法可以直接存k-v但是现在要把简单的 k-v 封装成一个满足双链表的节点
        //1.查看是否已经有了该节点
        let node = this.get(key, true)
        if (!node) 
            if (this.size === this.limit)  //判断缓存是否达到上限
                //达到了,要删最后一个节点了。
                if (this.tail) 
                    this.tail = this.tail.prev
                    this.tail.prev.next = undefined
                    //平滑断链之后,销毁当前节点
                    this.tail.prev = this.tail.next = undefined
                    this.map[this.tail.key] = undefined
                    //当前缓存内存释放一个槽位
                    this.size--
                
                node = 
                    key: key
                
                this.map[key] = node
                if(this.head)//判断缓存里面是不是有节点
                    this.head.prev = node
                    node.next = this.head
                else
                    //缓存里没有值,皆大欢喜,直接让head指向新节点就行了
                    this.head = node
                    this.tail = node
                
                this.size++//减少一个缓存槽位
            
        
        //节点存不存在都要给他重新赋值啊
        node.value = value
    


module.exports = LruCache

带你学会 LRU 算法相关内容

文章目录

一、什么是 LRU

就是一种缓存淘汰策略。

计算机的缓存容量有限,如果缓存满了就要删除一些内容,给新内容腾位置。

但问题是,删除哪些内容呢?我们肯定希望删掉哪些没什么用的缓存,而把有用的数据继续留在缓存里,方便之后继续使用。

那么,什么样的数据,我们判定为「有用的」的数据呢?

LRU 缓存淘汰算法就是一种常用策略。LRU 的全称是 Least Recently Used,也就是说我们认为最近使用过的数据应该是是有用的,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据

当然还有其他缓存淘汰策略,比如不要按访问的时序来淘汰,而是按访问频率(LFU 策略)来淘汰等等,各有应用场景

二、LRU 算法描述

LRU 算法实际上是让你设计数据结构:首先要接收一个 capacity 参数作为缓存的最大容量,然后实现两个 API,一个是 put(key, val) 方法存入键值对,另一个是 get(key) 方法获取 key 对应的 val,如果 key 不存在则返回 -1。

注意哦,get 和 put 方法必须都是 O ( 1 ) O(1) O(1) 的时间复杂度,我们举个具体例子来看看 LRU 算法怎么工作。

/* 缓存容量为 2 */
LRUCache cache =newLRUCache(2);
// 你可以把 cache 理解成一个队列
// 假设左边是队头,右边是队尾
// 最近使用的排在队头,久未使用的排在队尾
// 圆括号表示键值对 (key, val)
cache.put(1,1);
// cache = [(1, 1)]
cache.put(2,2);
// cache = [(2, 2), (1, 1)]
cache.get(1);// 返回 1
// cache = [(1, 1), (2, 2)]
// 解释:因为最近访问了键 1,所以提前至队头
// 返回键 1 对应的值 1
cache.put(3,3);
// cache = [(3, 3), (1, 1)]
// 解释:缓存容量已满,需要删除内容空出位置
// 优先删除久未使用的数据,也就是队尾的数据
// 然后把新的数据插入队头
cache.get(2);// 返回 -1 (未找到)
// cache = [(3, 3), (1, 1)]
// 解释:cache 中不存在键为 2 的数据
cache.put(1,4);
// cache = [(1, 4), (3, 3)]
// 解释:键 1 已存在,把原始值 1 覆盖为 4
// 不要忘了也要将键值对提前到队头

三、LRU 算法设计

要让 put 和 get 方法的时间复杂度为 O(1),我们可以总结出 cache 这个数据结构必要的条件:查找快,插入快,删除快,有顺序之分

因为显然 cache 必须有顺序之分,以区分最近使用的和久未使用的数据;而且我们要在 cache 中查找键是否已存在;如果容量满了要删除最后一个数据;每次访问还要把数据插入到队头。

我们已经知道 哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表

LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。这个数据结构长这样:

整体的设计思路是,使用 HashMap 存储 key,这样可以做到 put 方法和 get 方法的时间复杂度都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点

之所以选择双向链表而不选用单链表,是因为删除操作时,需要借助前驱节点,双向链表支持直接查找前驱的操作

LRU 存储是基于双向链表实现的,其中 head 代表双向链表的表头,tail 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。

put 方法:首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果 LRU 空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。

get 方法:通过 HashMap 找到 LRU 链表节点,因为根据LRU 原理,这个节点是最新访问的,所以要把节点插入到队头,然后返回缓存的值。

四、Java 代码实现

// 定义双链表节点
class Node
    int key,val;
    Node preNode,nextNode;
    Node()
    Node(int key,int val)
        this.key = key;
        this.val = val;
    

// 定义双链表
class DoubleList
    Node head,tail;
    int size;
    // 头部增加元素
    public void addFirst(Node node)
        if(head==null)
            head = tail = node;
        else
            node.nextNode = head;
            head.preNode = node;
            head = node;
        
        size++;
    
    // 移出节点node
    public void remove(Node node)
        if(head==node && tail==node)
            tail = null;
            head = null;
        else if(tail==node)
            node.preNode.nextNode = null;
            tail = tail.preNode;
        else if(head==node)
            node.nextNode.preNode = null;
            head = head.nextNode;
        else
            node.nextNode.preNode = node.preNode;
            node.preNode.nextNode = node.nextNode;
        
        size--;
    
    // 移出最后一个节点
    public Node removeLast()
        Node node = tail;
        remove(tail);
        return node;
    
    public int size()
        return size;
    

class LRUCache 
    Map<Integer,Node> map;
    DoubleList cache;
    int capacity;
    public LRUCache(int capacity) 
        map = new HashMap<>();
        cache = new DoubleList();
        this.capacity = capacity;
    
    // 获取元素
    public int get(int key) 
        // 不存在返回 -1
        if(!map.containsKey(key))
            return -1;
        
        // 存在更新该节点的位置然后返回值
        Node node = map.get(key);
        put(key,node.val);
        return node.val;
    
    // 存储节点
    public void put(int key, int value) 
        Node node = new Node(key,value);
        // 如果已经存在,则更新即可
        if(map.containsKey(key))
            // 移出原始的元素
            cache.remove(map.get(key));
            // 将其更新为首位
            cache.addFirst(node);
            map.put(key,node);
        else
            // 添加时,判断是否达到容量上限
            if(cache.size()==capacity)
                Node lastNode = cache.removeLast();
                map.remove(lastNode.key);
            
            cache.addFirst(node);
            map.put(key,node);
        
    

五、Redis 中 LRU 的实现

如果按照HashMap和双向链表实现,需要额外的存储存放 next 和 prev 指针,牺牲比较大的存储空间,显然是不划算的。

所以Redis采用了一个近似的做法,就是随机取出若干个key,然后按照访问时间排序后,淘汰掉最不经常使用的

六、线程安全的LRU算法

public class LRUCache extends LinkedHashMap<String,String>
   
    private int cache_size;

    public LRUCache(int capacity)
      super(capacity, 0.75f, true);
      this.cache_size = capacity;

    

    public String get(String key)
      synchronized (LRUCache.class) 
        return super.getOrDefault(key, "");
      
      

    public String put(String key, String value)
      synchronized (LRUCache.class) 
        return super.put(key, value);
      
      

    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) 
      return this.size() > cache_size;
      

单例模式:

public class LRUCache extends LinkedHashMap<String, String> 

    private int cache_size;

    private static LRUCache instance;

    private LRUCache(int capacity) 
        super(capacity, 0.75f, true);
        this.cache_size = capacity;

    

    public static synchronized LRUCache getInstance() 
        if (instance == null) 
            instance = new LRUCache(100);
        
        return instance;
    

    public String get(String key) 
        synchronized (this) 
            return super.getOrDefault(key, "");
        
    

    public String put(String key, String value) 
        synchronized (this) 
            return super.put(key, value);
        
    

    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) 
        return this.size() > cache_size;
    

或者把Map改为CurrentHashMap<>();

以上是关于什么是LRU算法的主要内容,如果未能解决你的问题,请参考以下文章

带你学会 LRU 算法相关内容

图解LRU算法

LRU缓存算法与pylru

面试官说,听说你了解Redis,手写一个LRU算法吧

LRU算法简单实现

redis lru缓存清理算法详解和相关配置