高阶数据结构——LRU Cache

1.什么是LRU Cache

LRU是Least Recently Used的缩写,意思是最近最少使用,它是一种Cache替换算法。 什么是Cache?狭义的Cache指的是位于CPU和主存间的快速RAM, 通常它不像系统主存那样使用DRAM技术,而使用昂贵但较快速的SRAM技术。 广义上的Cache指的是位于速度相差较大的两种硬件之间, 用于协调两者数据传输速度差异的结构。除了CPU与主存之间有Cache, 内存与硬盘之间也有Cache,乃至在硬盘与网络之间也有某种意义上的Cache── 称为Internet临时文件夹或网络内容缓存等。

例如:我们现在要打开微信,操作系统会从赋存中将微信相关的代码数据加载到内存中,CPU读取内存中的数据即可,但是CPU的速度是非常快的,相对于CPU来说,内存的速度就很慢了。

为了解决这个问题,会在CPU与内存之间增加一个Cache来协调两者的速度差异。当我们在使用微信的视频聊天功能时,CPU下次读取的指令大概率还是视频聊天,所以可以预先加载一部分视频聊天的数据或者代码到Cache中去。Cache要比内存快得多,能更好的配合CPU工作。
空间局部性原理:现在正在使用的数据和将来要使用的数据在存储位置上可能是相邻的。

而Cache就是建立在局部性原理上工作的。

2. Cache的替换算法

计算机的硬件设计上,速度越快的,造价越高,容量越小,Cache的速度虽然快,但是容量较小,如果Cache满了,现在需要将新的内容加载到Cache中,则需要进行替换,有四种比较常见的Cache替换算法。

  • 1.随机算法(RAND)
  • 2.先进先出算法(FIFO)
  • 3.近期最少使用(LRU)
  • 4.最近不经常使用(LFU)

2.1 随机算法(Rand)

在Cache满了时,随机选择一块进行替换。

假设现在一共有4个Cache块,以下是CPU访问的主存块号顺序。{1,2,3,4,1,2,5,1,2,3,4,5}

1,2,3,4号主存块直接插入即可,因为此时Cache还没有满,接下来的1,2号主存块因为Cache中已经存在,所以直接访问Cache中的即可。

到访问5号内存块时,因为Cache中没有,所以要进行替换,由于当前是随机替换,这里就替换3号Cache。

再接下来的1,2号都可以直接命中。

3号主存块未命中,所以需要替换一个Cache块,我们这里替换4号。

4号主存块也是一样,未命中随机替换一个Cache块

最后一个5命中了,不需要替换。

随机算法很简单,但是不稳定,没有考虑到局部性原理,命中率较低。

2.2 先进先出算法(FIFO)

顾名思义,就是先进入Cache的内存块先被替换掉。

还是和上面一样,将主存块号为{1,2,3,4,1,2,5,1,2,3,4,5}依次插入Cache中。

前面的1,2,3,4可以直接插入,因为此时的Cache还未满。

接下来的1,2号主存块命中了。

访问5号主存块时,5号主存块未命中,所以此时需要进行替换,这里的算法是先进先出,所以5号主存块将1号Cache块替换掉了。

接下来的2号结点,因为也未命中Cache,所以需要进行替换,目前最新进入Cache的是3号,所以我们将3号替换成2号。

接下来和前面一样,这里不过多赘述。

和随机算法一样,实现简单,没有考虑局部性原理,最先进入的也有可能是最频繁访问的。

2.3 近期最少使用算法(LRU)

为每一个Cache设置一个计数器,记录每个Cache多久没有被访问了,替换时优先选择计数值最大的那个Cache。

每次访问Cache时,有以下三种情况。

  • 命中时,所命中的行的计算器清零,比其低的计算器加1,其余不变
  • 未命中且还有空闲行时,新装入的计数器为0,其余非空闲行全加1;
  • 未命中且无空闲行时,将计数值最大的行淘汰,新装行的计数器置为0,其余全加1

CPU依次访问主存块号为{1,2,3,4,1,2,5,1,2,3,4,5}。

前面4个主存块直接插入即可,因为Cache未满,并且还要更新计数器。

接下俩访问的是1号主存块,由于Cache中已经存在1号主存块,所以不需要替换,我们再来看看当命中时需要怎么做。

命中时,所命中的行的计算器清零,比其低的计算器加1,其余不变

所以我们需要更改计数器的值,如上图所示。

接下来访问的是2号主存块,和前面一样,Cache命中,将对应的计数器清零,其他计数器加1。

访问5号主存块时,Cache未命中,需要进行替换,找到计数器最大的Cache进行替换,也就是将3号替换成5,并将对应的计数器清零,其他的加1。

访问1号主存块,Cache命中,将对应计数器置为0,并将比他低的计数器加1,所以4号主存块的计数器不需要加1。

我们当然可以将4号主存块也加1,但是没有必要,因为4号主存块的计数器已经是最大了,没有必要再加1,这样子设计有个好处,就是如果一共有2^n个Cache块,那么只需要n位技术器就可以,在顶层设计硬件时更加简单。

此时访问2号主存块也是一样,Cache命中,将对应计数器置0,将比2号小的计数器值加1。

访问3号时,Cache没有命中,将计数器最大的主存块,也就是4号替换成3号,其余的计数器加1.

访问4号主存块,Cache未命中,将计数器最大的5号替换成4号,其余计数器加1

访问5号主存块,Cache未命中,将计数器最大的1号换成5号,其余的计数器加1。

LRU算法是基于局部性原理的,近期被访问过的主存块,在将来很有可能再次被访问。LRU算法实际运行的效果优秀,Cache命中率高。

但是如果频繁访问的主存块数量 > Cache行的数量时,可能会发生"抖动",例如Cache行的数量只要4时,遇到如下{1,2,3,4,5,1,2,3,4,5,1,2,3,4,5....}。虽然每个内存块都会频繁访问,但是Cache行的数量较小,所以也会被替换。

2.4 最不经常使用算法(LFU)

为每一个Cache设置一个计数器,记录每个Cache被访问的次数,满了后替换计数器最小的。

设一共有4个Cache块,依次访问主存块{1,2,3,4,1,2,5,1,2,3,4,5}。

前面几个还是一样。

访问5号主存块时,Cache未命中需要进行替换,找到计数器值最小的进行替换,也就是将3号替换成5号。

接下来的1,2都会命中,直接将计数器+1即可。

访问3号主存块时,Cache未命中需要进行替换,找到计数器值最小的进行替换,也就是将5号替换成3号。

Cache命中,计数器+1

访问5号主存块时,Cache未命中需要进行替换,找到计数器值最小的进行替换,也就是将3号替换成5号。

LFU是基于曾经被经常访问,在未来不一定会用到,没有很好遵循局部性原理,所以实际效率不如LRU。

3. LRU Cache的实现

实现LRU Cache的方法和思路很多,但是要保持高效实现O(1)的put和get,那么使用双向链表和哈希表的搭配是最高效和经典的。使用双向链表是因为双向链表可以实现任意位置O(1)的插入和删除,使用哈希表是因为哈希表的增删查改也是O(1)。

下面以一道oj题带大家自己实现一个Cache。

题目链接:146. LRU 缓存 - 力扣(LeetCode)

要让get和put都在O(1)时间内完成,我们可以借助哈希表来完成,因为哈希表的查找和插入效率都是O(1),也就是说get时可以做到O(1),而put的大多数场景也可以做到,之所以说大多数就是因为put时可能Cache未命中,此时需要替换出计数器最大的那个数,如果只有一个哈希表,是无法在O(1)的时间内找到计数器最大的值,所以我们添加一个链表。

链表的尾部保存的是最近最少使用的key,如果一个数被使用了,那么我们就将这个数提到链表的头部,哈希表保存的是key和key在链表中的位置。

代码如下:

class LRUCache {
public:
    LRUCache(int size) {
        capacity = size;
    }
    
    int get(int key) {
        auto find = hashMap.find(key);
        if (find == hashMap.end())
        {
            return -1;
        }
        else 
        {
            //更新key的位置。
            Iterator it = find->second;
            //将结点挪到链表头部
            LRUList.splice(LRUList.begin(), LRUList, it);
            return it->second;
        }
    }
    
    void put(int key, int value) {
        auto find = hashMap.find(key);
        if (find == hashMap.end())
        {
            //没找到,将这个结点插入Cache中。
            if (LRUList.size() == capacity)
            {
                //如果Cache满了,就将使用最少的删除。
                pair<int, int> end = LRUList.back();
                LRUList.pop_back();
                hashMap.erase(end.first);
            }

            //将key插入Cache中
            LRUList.push_front({key, value});
            hashMap.insert({key, LRUList.begin()});
        }
        else 
        {
            //找到了,更新他的值
            auto it = find->second;
            it->second = value;

            //将该结点放至链表开头
            LRUList.splice(LRUList.begin(), LRUList, it);
        }
    }

    typedef list<pair<int, int>>::iterator Iterator;
    unordered_map<int, Iterator> hashMap;
    list<pair<int, int>> LRUList;
    size_t capacity;
};

LRU Cache是一种常见的缓存淘汰策略,LRU代表最近最少使用。在Java中,可以使用不同的方式来实现LRU Cache。 引用\[1\]中的代码展示了一种自定义的LRU Cache实现,使用了一个自定义的LRUCache类,并在main方法中进行了测试。在这个实现中,LRUCache类继承了LinkedHashMap,并重写了removeEldestEntry方法来实现缓存的淘汰策略。 引用\[2\]中的代码展示了另一种自定义的LRU Cache实现,同样使用了一个自定义的LRUCache类,并在main方法中进行了测试。这个实现中,LRUCache类同样继承了LinkedHashMap,并重写了removeEldestEntry方法来实现缓存的淘汰策略。 引用\[3\]中的代码展示了使用ArrayList实现LRU Cache的方式。在这个实现中,LRUCache类使用了一个ArrayList来存储缓存数据,并通过get和put方法来操作缓存。 总结来说,LRU Cache的Java实现可以使用自定义的类继承LinkedHashMap并重写removeEldestEntry方法,或者使用ArrayList来存储缓存数据。具体的实现方式可以根据需求和偏好选择。 #### 引用[.reference_title] - *1* *2* [Java——LRUCache](https://blog.csdn.net/m0_60867520/article/details/128361042)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [LRUCache的Java实现](https://blog.csdn.net/qq_39383118/article/details/103535985)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值