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的大部分优点,又显著降低了实现的复杂性和开销。

相关推荐
wjq++13 小时前
LRU 缓存机制
java·spring·缓存
小_北_辰21 小时前
使用Spring Boot和自定义缓存注解优化应用性能
java·spring boot·缓存
顶顶年华正版软件官方1 天前
Photoshop属于什么软件 Photoshop缓存文件清理 Mac清理PS缓存 苹果电脑ps内存满了怎么清理
经验分享·macos·缓存·性能优化·笔记本电脑·photoshop·mac清理暂存盘
2401_854391081 天前
缓存生命周期管理:探索Memcached的过期策略
数据库·缓存·memcached
三两肉1 天前
如何使用缓存提升SpringBoot性能(EhCache和Redis方式)
spring boot·redis·缓存
JacksonChen_1 天前
说一下浏览器中的强缓存和协商缓存的区别
缓存
鞥牧魂人2 天前
数据库——redis常见面试题
数据库·redis·缓存
一橘Java小菜鸟2 天前
利用Redis bitmap 实现签到案例
数据库·redis·缓存
青春丨猪头丨少年2 天前
用Redis实现排行榜的详细用例
数据库·redis·缓存
lizz6662 天前
Spring @Cacheable缓存注解用法说明
java·spring·缓存