手撕lru

92 阅读4分钟

LRU(Least Recently Used,最近最少使用)缓存算法的核心思想是:优先淘汰那些“最久未被使用”的缓存数据。换句话说,当缓存容量达到上限时,系统会将最近最少使用的数据从缓存中移除,为新数据腾出空间。下面我将结合原理、实现细节和实际例子,深入讲解为什么LRU缓存会淘汰数据。

  1. 为什么要淘汰缓存数据? 缓存容量有限,不能无限制地存储所有数据。当缓存满了,必须有策略选择哪些数据被保留,哪些被淘汰。LRU算法基于“时间局部性”原理:

最近被访问的数据很可能在未来也会被访问(热数据);

很久没被访问的数据很可能不再被访问(冷数据)。

所以,淘汰最久未使用的数据,可以最大化缓存的命中率,提升系统性能。

  1. LRU缓存淘汰的具体原因和逻辑 LRU缓存淘汰的根本原因是缓存容量有限,且需要保证缓存中存储的是最有价值的数据。具体逻辑如下:

缓存维护一个数据访问顺序结构(通常是双向链表),头部是最近访问的数据,尾部是最久未访问的数据。

每当访问缓存中的数据时,将该数据移动到链表头部,表示它是最新使用的。

当插入新数据且缓存已满时,移除链表尾部的数据(即最久未使用的数据),腾出空间。

新数据插入链表头部,表示它是最新使用的。

通过这种机制,缓存始终保留最近访问的数据,淘汰那些长时间未被访问的数据。

  1. LRU缓存淘汰的实现示例(C++伪代码简述) LRU缓存通常用哈希表 + 双向链表实现:

哈希表用于快速定位缓存中的节点,保证O(1)时间复杂度查找。

双向链表维护访问顺序,支持O(1)时间复杂度的插入和删除操作。

struct Node {
    int key, value;
    Node* prev;
    Node* next;
    Node(int k, int v): key(k), value(v), prev(nullptr), next(nullptr) {}
};

class LRUCache {
    int capacity;
    unordered_map<int, Node*> cache; // key -> node
    Node* head; // 伪头节点
    Node* tail; // 伪尾节点

    void remove(Node* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    void insertToHead(Node* node) {
        node->next = head->next;
        node->prev = head;
        head->next->prev = node;
        head->next = node;
    }

public:
    LRUCache(int cap): capacity(cap) {
        head = new Node(-1, -1);
        tail = new Node(-1, -1);
        head->next = tail;
        tail->prev = head;
    }

    int get(int key) {
        if (cache.find(key) == cache.end()) return -1;
        Node* node = cache[key];
        remove(node);
        insertToHead(node);
        return node->value;
    }

    void put(int key, int value) {
        if (cache.find(key) != cache.end()) {
            Node* node = cache[key];
            node->value = value;
            remove(node);
            insertToHead(node);
        } else {
            if (cache.size() == capacity) {
                Node* toRemove = tail->prev;
                remove(toRemove);
                cache.erase(toRemove->key);
                delete toRemove;
            }
            Node* newNode = new Node(key, value);
            insertToHead(newNode);
            cache[key] = newNode;
        }
    }
};

当缓存满了,put操作会淘汰链表尾部节点(最久未使用的数据),保证缓存容量不超限。

  1. 举例说明 假设缓存容量为3,缓存内容和访问顺序如下:

初始缓存空。

put(1,1),缓存:{1}

put(2,2),缓存:{2,1}(2是最新)

put(3,3),缓存:{3,2,1}

get(1),访问1,缓存变为{1,3,2}(1被移动到头部)

put(4,4),缓存满了,需要淘汰尾部节点2,缓存变为{4,1,3}

这里淘汰的是“最久未使用”的数据2,因为它在最近的访问中没有被访问到,符合LRU算法的设计。

  1. LRU算法的局限与改进 LRU虽然简单有效,但在某些场景下会出现“缓存污染”或“预读失效”问题:

缓存污染:大量一次性访问的数据会把热点数据挤出缓存。

预读失效:预读数据被放到链表头部,但实际可能不会被再次访问,导致频繁淘汰有效数据。

为了解决这些问题,Linux和MySQL InnoDB对LRU做了改进:

Linux使用两个LRU链表(活跃链表和非活跃链表),只有访问两次以上的数据才升级为活跃链表。

MySQL InnoDB将LRU链表划分为“young区”和“old区”,新数据先进入old区,只有在old区存在超过一定时间且被再次访问才进入young区。

这些改进有效减少了缓存污染,提高了LRU算法的适应性。

综上,LRU缓存会淘汰数据是因为缓存容量有限,需要保证缓存中存储的是最近最常用的数据,淘汰那些长时间未被访问的数据,以提升缓存命中率和系统性能。实现上通过哈希表和双向链表快速定位和更新访问顺序,淘汰链表尾部节点。面对实际应用中的特殊访问模式,LRU也有相应的改进策略。

这不仅是LRU算法的设计初衷,也是它在工业界广泛应用的原因。面试中如果能结合代码实现和实际场景说明,会让面试官感受到你对LRU缓存淘汰机制的深入理解。