LRU缓存

有人从网络读数据,有人从磁盘读数据,机智的人懂得合理利用缓存加速数据的读取效率,提升程序的性能,搏得上司的赏识,赢得白富美的青睐,进一步走向人生巅峰~

LRU假说

LRU缓存(Least Recently Used Cache)即最近最少使用缓存算法,是一种常用的缓存淘汰策略,它基于这样一个假设:

如果数据最近被访问过,那么它在未来被访问的可能性也更高。

因此,当缓存空间不足时,LRU缓存会优先移除最长时间未被访问的数据项。

LRU是怎么干活的

新访问的数据添加到缓存

当一个数据项被访问时,它会被添加到缓存中。如果该数据项已经在缓存中,它会被更新,并且移动到缓存的最前面,表示最近被访问过。

缓存满时移除最老的数据

如果缓存已满(达到预设的容量限制),最久未被访问的数据项(位于缓存的最后面)会被移除,以便为新的数据项腾出空间。

维护访问顺序

缓存需要维护数据项的访问顺序,以便快速确定哪些数据项是最近被访问的,哪些是最久未被访问的。

为了有效地实现LRU缓存,通常需要以下两种数据结构:

双向链表:用于维护数据项的访问顺序。最近访问的数据项位于链表一头,最久未访问的数据项位于链表另一头。当数据项被访问时,它会被移动到链表最近访问那一头。当需要移除数据项时,最久未访问的末尾数据项会被移除。

哈希表:用于存储键和指向双向链表中相应节点的指针,以便快速定位缓存中的数据项。这样可以在O(1)时间复杂度内访问缓存项。

LRU的简单示例

如下是一个简单的LRU实现

复制代码
#include <iostream>
#include <list>
#include <string>
#include <unordered_map>
#include <vector>

using namespace std;

template <typename K, typename V>
class LRUCache {
public:
    LRUCache(int capacity) {
        cap = capacity;
    }

    V get(const K& key) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            return V();
        }

        auto val = it->second->second;
        put(key, val);
        return val;
    }

    void put(const K& key, const V& value) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            if (hash.size() >= cap) {
                auto d_it = data_list.begin();
                auto h_it = d_it->first;
                data_list.erase(d_it);
                hash.erase(h_it);
            }
        } else {
            auto d_it = it->second;
            data_list.erase(d_it);
            hash.erase(it);
        }

        data_list.emplace_back(key, value);
        hash[key] = --data_list.end();
    }

private:
    int cap;
    list<pair<K, V> > data_list;

    using LIST_IT = typename list<pair<K, V> >::iterator;
    unordered_map<K, LIST_IT> hash;
};

int main() {
    LRUCache<int, int> lru(2);

    vector<pair<string, vector<int> > > test_case = {
        {"put", {1, 1}},
        {"put", {2, 2}},
        {"get", {1}},
        {"put", {3, 3}},
        {"get", {2}},
        {"put", {4, 4}},
        {"get", {1}},
        {"get", {3}},
        {"get", {4}},
    };

    for (const auto& [opt, param] : test_case) {
        if (opt == "get") {
            auto val = lru.get(param.front());
            cout << val << endl;
        } else {
            lru.put(param.front(), param.back());
        }
    }

    return 0;
}

运行测试用例可以得到如下结果:

复制代码
code % g++ lru.cpp -std=c++17
code % ./a.out       
1
0
0
3
4
code % 

如上,实现一个LRU的代码量并不算多,并且简单易懂,性能也很不错,毕竟时间复杂度为O(1)。但LRU也有其缺点,例如它没有考虑数据的访问频率。这可能会导致一些不经常使用的数据被缓存,而一些经常使用的数据被淘汰

LRU的改进-LFU

LFU(Least Frequently Used),即最少使用频率缓存,考虑到访问频率,而不是最近一次访问时间。其可以与LRU结合,形成其他变种,以更好地适应不同的数据访问模式。

LFU的简单示例

例如,可以通过给LRU缓存数据项加上访问频率,当缓存满需要淘汰时,取尾部的数据选一个访问频次最低的来淘汰

复制代码
#include <iostream>
#include <list>
#include <string>
#include <unordered_map>
#include <vector>

using namespace std;

template <typename K, typename V>
class LRUCache {
public:
    LRUCache(int capacity) {
        cap = capacity;
    }

    V get(const K& key) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            return V();
        }

        const auto& data_tuple = *(it->second);
        auto val = std::get<1>(data_tuple);
        auto cnt = std::get<2>(data_tuple);
        put(key, val, cnt + 1);
        return val;
    }

    void put(const K& key, const V& value, int cnt = 1) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            if (hash.size() >= cap) {
                remove_one_elem();
            }
        } else {
            auto d_it = it->second;
            data_list.erase(d_it);
            hash.erase(it);
        }

        data_list.emplace_back(key, value, cnt);
        hash[key] = --data_list.end();
    }

private:
    void remove_one_elem() {
        auto need_rm = data_list.begin();
        auto it = need_rm;
        for (int i = 1; i < 3 && it != data_list.end(); ++i, ++it) {
            if (std::get<2>(*it) < std::get<2>(*need_rm)) {
                need_rm = it;
            }
        }

        hash.erase(std::get<0>(*need_rm));
        data_list.erase(need_rm);
    }

private:
    int cap;
    list<tuple<K, V, int> > data_list;

    using LIST_IT = typename list<tuple<K, V, int> >::iterator;
    unordered_map<K, LIST_IT> hash;
};

int main() {
    LRUCache<int, int> lru(2);

    vector<pair<string, vector<int> > > test_case = {
        {"put", {1, 1}},
        {"put", {2, 2}},
        {"get", {1}},
        {"put", {3, 3}},
        {"get", {2}},
        {"put", {4, 4}},
        {"get", {1}},
        {"get", {3}},
        {"get", {4}},
    };

    for (const auto& [opt, param] : test_case) {
        if (opt == "get") {
            auto val = lru.get(param.front());
            cout << val << endl;
        } else {
            lru.put(param.front(), param.back());
        }
    }

    return 0;
}

运行测试用例可以得到如下结果:

复制代码
code % g++ lfu.cpp -std=c++17
code % ./a.out       
1
0
1
0
4
code % 

与LRU示例的差异点在于,当缓存满时,LFU为从最近未使用的一头,挑选一个访问频次最小的元素进行淘汰。值得注意的是,挑选最少频次并不需要遍历所有的数据,而是针对具体的业务场景,设定一个合适的值即可。

虽然LRU开销很小,时间复杂度又是O(1),但毕竟每次访问都需要调整链表,对于一些性能要求高的场景,负担还是有点重的,实际的使用场景中,又会根据具体的业务场景,做一些响应的改变。

衍生一下

Clock算法

Clock算法是一种用于页面置换的缓存淘汰策略,它是LRU算法的一种近似实现,旨在降低实现LRU的开销。Clock算法有时也被称为Second-Chance算法,因为它给了每个页面一个"第二次机会"来避免被置换。

Clock是怎么干活的

Clock算法维护一个循环链表:所有的页面都被组织成一个循环链表(或称为时钟结构),每个页面都有一个关联的访问位(通常是一个标志位),用于表示该页面自上次检查以来是否被访问过。

使用指针指向链表中的一个页面:有一个指针(称为时钟指针)指向循环链表中的某个页面。

维护访问位:当一个页面被访问时,其访问位被设置为1,表示该页面最近被使用过。

缓存满时检查访问位:当有新需要加载到缓存中,但缓存已满,算法会检查当前时钟指针指向的页面的访问位。如果访问位为1,则将其清零(给予第二次机会),并将时钟指针移动到下一个页面。如果访问位为0,则选择该页面进行置换

Clock算法的优点是实现简单,开销较低,因为它不需要像真正的LRU算法那样在每次页面访问时都对链表进行调整。它只需要在页面置换时检查和更新访问位。这使得Clock算法特别适合于大规模的缓存系统,如操作系统的页面缓存。

Clock算法的缺点是它不是完全精确的LRU实现,因为它可能会保留一些不太常用的页面(如果它们在时钟指针到达之前刚好被访问过)。然而,对于许多实际应用来说,Clock算法提供了一个很好的折中方案,既保留了LRU的大部分优点,又显著降低了实现的复杂性和开销。

相关推荐
梅孔立12 小时前
两种免费的翻译API调用方式详解 - Edge官方API与个人缓存服务
前端·缓存·edge
gwf21612 小时前
SSD读写速度深度解析:顺序读写vs随机读写、IOPS、延迟,你的硬盘性能到底怎么看?
git·嵌入式硬件·缓存·github·智能硬件
番茄炒鸡蛋加糖14 小时前
缓存三大问题
缓存
江晓鱼未暖19 小时前
十七、Redis 核心原理与架构详解
大数据·数据库·数据仓库·redis·缓存·架构
小罗水19 小时前
第13章 Redis 缓存、幂等锁与任务状态
数据库·redis·缓存
热心市民lcj20 小时前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
zx11545021 小时前
【热点 Key 探测与加长缓存 TTL 实战】
缓存
toooooop81 天前
如何用 ss + ps 精准定位本机 Redis 的“隐形”消费者?
linux·数据库·redis·缓存
软萌萌的11 天前
C#中的多级缓存架构设计与实现深度解析
缓存·c#·wpf
醉城夜风~2 天前
HTML表单域学习博客:表单标签、输入框、按钮详解
android·java·缓存