缓存淘汰机制实现-LRU-LFU——legend050709

本文深入解析缓存的基本概念及其在提升数据读取性能中的关键作用,详细阐述了缓存淘汰策略,特别是LRU(最近最久未使用)算法的原理与实现,包括其在硬件与软件设计中的应用,以及如何结合哈希表和双向链表实现高效的数据管理和访问。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

(1)缓存是什么

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。

(2)为什么需要缓存

CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(cacheline)并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。

对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。

Cache Line 是 Cache 与 DRAM 同步的最小单位. 典型的虚拟内存页面大小为 4KB,而典型的 Cache line 通常的大小为 32 或 64 字节

(3)缓存淘汰策略分类

  缓存的大小是有限的,当缓存被用满时,性需要进行数据清理。哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。

3.1)先进先出策略FIFO(First In,First Out)

思路:
实现思路:

3.2)最近最少频率使用策略(最不经常使用)LFU(Least Frenquently Used)

LRU(The Least Recently Used,最近最久未使用算法)是一种常见的缓存算法,在很多分布式缓存系统(如Redis, Memcached)中都有广泛使用。

思想:最不经常使用策略,在一段时间内,数据被使用频次最少的,优先被淘汰。最少使用LFU)是一种用于管理计算机内存的缓存算法。主要是记录和追踪内存块的使用次数,当缓存已满并且需要更多空间时,系统将以最低内存块使用频率清除内存.采用LFU算法的最简单方法是为每个加载到缓存的块分配一个计数器。每次引用该块时,计数器将增加一。当缓存达到容量并有一个新的内存块等待插入时,系统将搜索计数器最低的块并将其从缓存中删除;

 

3.3)最近最久未使用策略LRU(Least Recently Used)

思想:LRU是最近最少使用策略的缩写,是根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。同样,如果一个数据在最近一段时间没有被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最久没有访问的数据最先被置换(淘汰)

(4)实现LRU

(4.1) 要求

缓存的大小有限,当缓存被用满时,使用LRU缓存淘汰策略,清理的是很久未使用的缓存数据;

(4.2)思路

1》如果此数据之前已经被缓存在链表中了,通过遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。

2》如果此数据没有在缓存链表中,可以分为两种情况:

  • 如果此时缓存未满,则将此结点直接插入到链表的头部;
  • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

说明:

如果缓存空间足够大,那么存储的数据也就足够多,通过缓存中命中数据的概率就越大,也就提高了代码的执行速度。这就是空间换时间的设计思想

对于程序开发来说,时间复杂度和空间复杂度是可以相互转化的。说通俗一点,就是:

  • 对于执行的慢的程序,可以通过消耗内存(即构造新的数据结构)来进行优化;

  • 而消耗内存的程序,可以通过消耗时间来降低内存的消耗。

(4.3)数据结构

如何设计一个LRU缓存,使得放入和移除都是 O(1) 的;

我们可以总结出 cache 这个数据结构必要的条件:查找快,插入快,删除快,有顺序之分

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

那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表。

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

(4.3.1)基于 HashMap 和 双向链表实现 LRU

4.3.1.1)使用 HashMap :

HashMap 存储 key,这样可以做到 save 和 get key的时间都是 O(1);

而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。

单独使用hashMap,则不太知道哪些数据是最近刚使用过的,所以也不太方便;

注:此中的hashmap后连接的不是一个链表,而是一个节点;和拉链法的hash表有些不太一样;

        所以HashMap应该是用开地址法实现的hash表,保证hash表的每个元素都不是一个链表,都只有一个节点;

2)使用双向链表

如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。

ps: 链表结构需要存储:链表头head节点,链表tail 节点;即链表结构和链表节点结构分别定义;

存储链表tail节点是为了在缓存满时删除尾节点,由于是双向链表,可以根据尾节点得知尾节点的前一个节点,然后删除尾节点,更新保持的尾即可;

如下所示:
struct DNode {

int data;

struct Node* pre;

struct Node* nxt;

};

Struct DList{

int cur_size; //链表的长度;方便判断缓存是否满;

int max_size; // 缓存的最大的节点个数;

sturct DNode* head;

struct DNode* tail; //方便缓存满时,删除尾部节点;

};

流程:

首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。

如果不存在,需要构造新的节点,并且尝试把节点塞到队头;

如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。

(4.4)伪代码实现

class Node {
    public int key, val;
    public Node next, prev;
    public Node(int k, int v) {
        this.key = k;
        this.val = v;
    }
}

 

class DoubleList {  
    private Node head, tail; // 头尾虚节点
    private int size; // 链表元素数

    public DoubleList() {
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
        size = 0;
    }

    // 在链表头部添加节点 x
    public void addFirst(Node x) {
        x.next = head.next;
        x.prev = head;
        head.next.prev = x;
        head.next = x;
        size++;
    }

    // 删除链表中的 x 节点(x 一定存在)
    public void remove(Node x) {
        x.prev.next = x.next;
        x.next.prev = x.prev;
        size--;
    }
    
    // 删除链表中最后一个节点,并返回该节点
    public Node removeLast() {
        if (tail.prev == head)
            return null;
        Node last = tail.prev;
        remove(last);
        return last;
    }
    
    // 返回链表长度
    public int size() { return size; }
}

// key 映射到 Node(key, val)
HashMap<Integer, Node> map;
// Node(k1, v1) <-> Node(k2, v2)...
DoubleList cache;

int get(int key) {
    if (key 不存在) {
        return -1;
    } else {        
        将数据 (key, val) 提到开头;
        return val;
    }
}

void put(int key, int val) {
    Node x = new Node(key, val);
    if (key 已存在) {
        把旧的数据删除;
        将新节点 x 插入到开头;
    } else {
        if (cache 已满) {
            删除链表的最后一个数据腾位置;
            删除 map 中映射到该数据的键;
        } 
        将新节点 x 插入到开头;
        map 中新建 key 对新节点 x 的映射;
    }
}

class LRUCache {
    // key -> Node(key, val)
    private HashMap<Integer, Node> map;
    // Node(k1, v1) <-> Node(k2, v2)...
    private DoubleList cache;
    // 最大容量
    private int cap;
    
    public LRUCache(int capacity) {
        this.cap = capacity;
        map = new HashMap<>();
        cache = new DoubleList();
    }
    
    public int get(int key) {
        if (!map.containsKey(key))
            return -1;
        int val = map.get(key).val;
        // 利用 put 方法把该数据提前
        put(key, val);
        return val;
    }
    
    public void put(int key, int val) {
        // 先把新节点 x 做出来
        Node x = new Node(key, val);
        
        if (map.containsKey(key)) {
            // 删除旧的节点,新的插到头部
            cache.remove(map.get(key));
            cache.addFirst(x);
            // 更新 map 中对应的数据
            map.put(key, x);
        } else {
            if (cap == cache.size()) {
                // 删除链表最后一个数据
                Node last = cache.removeLast();
                map.remove(last.key);
            }
            // 直接添加到头部
            cache.addFirst(x);
            map.put(key, x);
        }
    }
}

评价:

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

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值