LRU 是 Least Recently Used 的缩写,意思是最近最少使用。当缓存容量满了,需要淘汰最久没有被访问的数据。

面试里手写 LRU 很高频,因为它把哈希表和双向链表结合在一起:哈希表负责 O(1) 查找节点,双向链表负责 O(1) 移动节点和删除尾节点。

O(1)
O(1)

文章内容概览:

什么是 LRU 缓存?

LRU 为什么适合做缓存淘汰?

为什么需要哈希表 + 双向链表?

如何手写 get 和 put?

get
put

Java LinkedHashMap 如何实现 LRU?

LinkedHashMap

真实工程里的 LRU 还要考虑什么?

LRU 缓存通过哈希表和双向链表维护最近访问顺序

什么是 LRU 缓存?

什么是 LRU 缓存?

缓存的核心矛盾是:空间有限,但希望尽量把“未来还会被访问”的数据留在内存里。

问题是,程序并不知道未来。LRU 的做法是用“最近访问过”去近似预测“接下来还可能访问”。如果一个数据刚刚被访问过,它很可能还会再被访问;如果一个数据很久没被访问,缓存满的时候就优先淘汰它。

举个例子,容量为 2 的缓存按顺序访问:

put(1, 1)
put(2, 2)
get(1)
put(3, 3)
put(1, 1)
put(2, 2)
get(1)
put(3, 3)

在 put(3, 3) 之前,缓存里有 1 和 2。虽然 1 更早插入,但它刚被 get(1) 访问过,所以最近最少使用的是 2,最终应该淘汰 2。

put(3, 3)
1
2
1
get(1)
2
2

这个例子也说明了一点:LRU 看的不是“谁最早插入”,而是“谁最久没被访问”。

为什么需要缓存淘汰策略?

为什么需要缓存淘汰策略?

缓存不是无限大的。无论是本地内存缓存、Redis、数据库 Buffer Pool,还是操作系统里的页面缓存,都要面对容量上限。

容量满了之后,如果没有淘汰策略,就只能拒绝新数据或者随机删数据。随机删当然简单,但可能把刚好很热的数据删掉,导致命中率变差。LRU 则利用访问时间顺序做了一个经验判断:长期没被碰过的数据,短期内再次访问的概率通常更低。

常见淘汰策略可以简单对比一下:

策略淘汰依据特点FIFO最早进入缓存的数据实现简单,但不关心数据后来是否被频繁访问LRU最久没有被访问的数据适合有时间局部性的访问模式LFU访问次数最少的数据适合长期热点明显的场景,但需要维护频率信息TTL过期时间适合数据有明确有效期的场景,不等同于容量淘汰

策略淘汰依据特点

策略

淘汰依据

特点

FIFO最早进入缓存的数据实现简单,但不关心数据后来是否被频繁访问

FIFO

最早进入缓存的数据

实现简单,但不关心数据后来是否被频繁访问

LRU最久没有被访问的数据适合有时间局部性的访问模式

LRU

最久没有被访问的数据

适合有时间局部性的访问模式

LFU访问次数最少的数据适合长期热点明显的场景,但需要维护频率信息

LFU

访问次数最少的数据

适合长期热点明显的场景,但需要维护频率信息

TTL过期时间适合数据有明确有效期的场景,不等同于容量淘汰

TTL

过期时间

适合数据有明确有效期的场景,不等同于容量淘汰

LRU 之所以常被拿来面试,是因为它既有真实工程背景,又能很好地考察数据结构组合。

面试考察重点

面试考察重点

能说清为什么只用哈希表或只用链表都不够。

能写 get 和 put。

get
put

能解释双向链表头尾分别代表什么。

能处理容量满、更新已有 key、删除尾节点等边界。

能知道 Java LinkedHashMap 可以实现 LRU。

LinkedHashMap

LRU 的访问顺序怎么维护?

LRU 的访问顺序怎么维护?

LRU 缓存里,每一次访问都会改变数据的新旧程度。

通常我们会约定:

链表头部表示最近使用的数据。

链表尾部表示最久未使用的数据。

get(key) 命中后,把对应节点移动到头部。

get(key)

put(key, value) 如果是新 key,把新节点插入头部。

put(key, value)

put(key, value) 如果是已有 key,更新 value 后也要移动到头部。

put(key, value)

容量超过上限时,删除尾部节点。

这里最容易漏的是 get()。很多同学会觉得 get() 只是读数据,不该改结构。但对 LRU 来说,读也是一次访问;只要命中缓存,这个 key 的“最近使用时间”就变新了。

get()
get()

数据结构设计

数据结构设计

组件作用HashMap<Integer, Node>根据 key 快速找到链表节点双向链表按访问时间排序,头部是最近使用,尾部是最久未使用虚拟头尾节点简化插入和删除边界

组件作用

组件

作用

HashMap<Integer, Node>根据 key 快速找到链表节点

HashMap<Integer, Node>

HashMap<Integer, Node>

根据 key 快速找到链表节点

双向链表按访问时间排序,头部是最近使用,尾部是最久未使用

双向链表

按访问时间排序,头部是最近使用,尾部是最久未使用

虚拟头尾节点简化插入和删除边界

虚拟头尾节点

简化插入和删除边界

访问一个 key 后,需要把它移动到链表头部。插入新 key 时,也放到头部。容量超限时,删除尾部前一个节点。

为什么必须两个结构配合?

只用哈希表,可以 O(1) 找到 value,但不知道哪个 key 最久没用。你还得额外维护访问顺序。

O(1)

只用链表,可以维护访问顺序,尾部就是该淘汰的节点。但每次根据 key 查找节点都要从头扫到尾,复杂度是 O(n)。

O(n)

哈希表 + 双向链表刚好补齐彼此短板:

哈希表让我们能根据 key 直接定位链表节点。

双向链表让我们能快速移动节点、删除尾部节点。

节点里同时存 key 和 value,是为了淘汰尾节点时能从哈希表里删除对应 key。

面试手写路径

面试手写路径

LRU 的代码细节多,建议不要一上来就写完整类。面试时可以先把操作拆开:

先定义链表顺序:头部表示最近使用,尾部表示最久未使用。

再定义 get:查不到返回 -1,查到后移动到头部。

get
-1

再定义 put:已有 key 更新值并移动到头部;新 key 插入头部。

put

最后处理淘汰:超过容量后删除尾部前一个节点,并从哈希表删除。

把链表操作封装成 addToHead、remove、moveToHead、removeTail。

addToHead
remove
moveToHead
removeTail

这样写的好处是,get 和 put 都只组合几个基础链表操作,不会在主流程里反复改指针,出错概率低很多。

get
put

为什么用双向链表?

为什么用双向链表?

把一个节点移动到头部,需要先把它从原位置摘下来,再插到头节点后面。

如果用单向链表,删除当前节点时必须知道它的前驱节点。哈希表里即使能直接找到当前节点,也找不到它前面的节点,还是要从头遍历。

双向链表的节点同时有 prev 和 next,删除任意节点只需要改四个指针:

prev
next
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev.next = node.next;
node.next.prev = node.prev;

这就是 LRU 要用双向链表的关键原因:它不只是要删除尾节点,还要在 get() 命中和 put() 更新已有 key 时,把任意节点移动到头部。

get()
put()

虚拟头尾节点也很重要。有了 head 和 tail 两个哨兵节点,插入头部、删除尾部、处理空链表时都能走同一套代码,不需要到处写 null 判断。

head
tail
null

手写 LRU

手写 LRU

class LRUCache {
    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();
    private final Node head = new Node(0, 0);
    private final Node tail = new Node(0, 0);

    LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    int get(int key) {
        Node node = map.get(key);
        if (node == null) {
            return -1;
        }
        moveToHead(node);
        return node.value;
    }

    void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToHead(node);
            return;
        }
        Node newNode = new Node(key, value);
        map.put(key, newNode);
        addToHead(newNode);
        if (map.size() > capacity) {
            Node removed = removeTail();
            map.remove(removed.key);
        }
    }

    private void moveToHead(Node node) {
        remove(node);
        addToHead(node);
    }

    private void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private Node removeTail() {
        Node node = tail.prev;
        remove(node);
        return node;
    }

    private static class Node {
        int key;
        int value;
        Node prev;
        Node next;

        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }
}
class LRUCache {
    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();
    private final Node head = new Node(0, 0);
    private final Node tail = new Node(0, 0);

    LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    int get(int key) {
        Node node = map.get(key);
        if (node == null) {
            return -1;
        }
        moveToHead(node);
        return node.value;
    }

    void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToHead(node);
            return;
        }
        Node newNode = new Node(key, value);
        map.put(key, newNode);
        addToHead(newNode);
        if (map.size() > capacity) {
            Node removed = removeTail();
            map.remove(removed.key);
        }
    }

    private void moveToHead(Node node) {
        remove(node);
        addToHead(node);
    }

    private void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private Node removeTail() {
        Node node = tail.prev;
        remove(node);
        return node;
    }

    private static class Node {
        int key;
        int value;
        Node prev;
        Node next;

        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }
}

get 和 put 的时间复杂度都是 O(1),空间复杂度是 O(capacity)。

get
put
O(1)
O(capacity)

操作过程示意

操作过程示意

假设容量为 2,按顺序执行:

put(1, 1)
put(2, 2)
get(1)
put(3, 3)
put(1, 1)
put(2, 2)
get(1)
put(3, 3)

链表状态变化如下,左侧表示最近使用:

操作链表状态说明初始状态空虚拟头尾相连,缓存为空put(1, 1)1新节点插入头部put(2, 2)2 -> 12 是最近使用get(1)1 -> 2访问 1 后移动到头部put(3, 3)3 -> 1容量超限,淘汰尾部的 2

操作链表状态说明

操作

链表状态

说明

初始状态空虚拟头尾相连,缓存为空

初始状态

虚拟头尾相连,缓存为空

put(1, 1)1新节点插入头部

put(1, 1)

put(1, 1)

1

1

新节点插入头部

put(2, 2)2 -> 12 是最近使用

put(2, 2)

put(2, 2)

2 -> 1

2 -> 1

2 是最近使用

2

get(1)1 -> 2访问 1 后移动到头部

get(1)

get(1)

1 -> 2

1 -> 2

访问 1 后移动到头部

1

put(3, 3)3 -> 1容量超限,淘汰尾部的 2

put(3, 3)

put(3, 3)

3 -> 1

3 -> 1

容量超限,淘汰尾部的 2

2

这张表能帮助检查两个点:访问已有节点要更新使用顺序;淘汰节点时,删除的是最久未使用的尾部节点,而不是刚插入的新节点。

LinkedHashMap 实现

LinkedHashMap 实现

Java 的 LinkedHashMap 支持按访问顺序维护元素:

LinkedHashMap
class LRUCacheWithLinkedHashMap extends LinkedHashMap<Integer, Integer> {
    private final int capacity;

    LRUCacheWithLinkedHashMap(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;
    }
}
class LRUCacheWithLinkedHashMap extends LinkedHashMap<Integer, Integer> {
    private final int capacity;

    LRUCacheWithLinkedHashMap(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;
    }
}

构造函数里的第三个参数 accessOrder 设置为 true,表示按照访问顺序排序,而不是插入顺序。

accessOrder
true

LinkedHashMap 官方文档里也专门提到过这种 access-order 模式适合构建 LRU 缓存。这里有两个点要记住:

LinkedHashMap

accessOrder = false 时,遍历顺序是插入顺序;accessOrder = true 时,遍历顺序是访问顺序。

accessOrder = false
accessOrder = true

removeEldestEntry() 会在插入新映射后被调用,返回 true 时删除最旧的 entry。

removeEldestEntry()
true

不过,LinkedHashMap 不是线程安全的。如果要在多线程环境里直接用它做本地缓存,需要自己加锁,或者选择成熟缓存库。

LinkedHashMap

LRU 和 Redis 有什么关系?

LRU 和 Redis 有什么关系?

Redis 作为缓存使用时,也需要在内存达到 maxmemory 上限后执行淘汰策略。Redis 支持 allkeys-lru、volatile-lru 等策略:

maxmemory
allkeys-lru
volatile-lru

allkeys-lru:从所有 key 里淘汰最近最少使用的 key。

allkeys-lru

volatile-lru:只从设置了过期时间的 key 里淘汰最近最少使用的 key。

volatile-lru

不过 Redis 的 LRU 不是面试手写题里的“精确 LRU”。为了节省内存和 CPU,Redis 使用的是近似 LRU:随机采样一小批 key,从中挑出最久没访问的 key 淘汰。采样数量可以通过 maxmemory-samples 调整。

maxmemory-samples

这也能帮助我们理解真实工程和面试题的区别:面试题通常要求用哈希表 + 双向链表实现精确 LRU;工程系统会根据内存、吞吐、并发和命中率做折中。

工程场景

工程场景

本地缓存淘汰。

操作系统页面置换。

热点数据缓存。

网关、客户端 SDK 或中间件里的小容量结果缓存。

真实工程里还要考虑线程安全、过期时间、最大内存、统计指标和淘汰回调。面试手写 LRU 主要考数据结构组合,不需要把这些都写进代码。

如果是 Java 项目里的本地缓存,很多时候不会自己手写 LRU,而是直接用 Caffeine 这类缓存库。原因也很现实:工程缓存不只要容量淘汰,还要处理过期、并发、加载、统计、异步刷新、不同 entry 的权重等问题。Caffeine 官方文档里就把淘汰分成基于大小、基于时间、基于引用等多类。

面试追问

面试追问

追问回答重点为什么不用单独的 HashMap?HashMap 能查值,但不知道谁最久没被使用为什么不用单独的链表?链表能维护顺序,但按 key 查找节点需要 O(n)为什么要用双向链表?删除任意节点时需要同时连接前驱和后继,单向链表无法 O(1) 找到前驱为什么要用虚拟头尾节点?统一空链表、头节点、尾节点的插入删除逻辑,减少分支判断LinkedHashMap 怎么实现 LRU?构造时开启 accessOrder,重写 removeEldestEntry 控制容量真实缓存还要考虑什么?线程安全、过期时间、最大内存、淘汰回调、命中率统计和缓存击穿等工程问题

追问回答重点

追问

回答重点

为什么不用单独的 HashMap?HashMap 能查值,但不知道谁最久没被使用

为什么不用单独的 HashMap?

HashMap

HashMap 能查值,但不知道谁最久没被使用

HashMap

为什么不用单独的链表?链表能维护顺序,但按 key 查找节点需要 O(n)

为什么不用单独的链表?

链表能维护顺序,但按 key 查找节点需要 O(n)

O(n)

为什么要用双向链表?删除任意节点时需要同时连接前驱和后继,单向链表无法 O(1) 找到前驱

为什么要用双向链表?

删除任意节点时需要同时连接前驱和后继,单向链表无法 O(1) 找到前驱

O(1)

为什么要用虚拟头尾节点?统一空链表、头节点、尾节点的插入删除逻辑,减少分支判断

为什么要用虚拟头尾节点?

统一空链表、头节点、尾节点的插入删除逻辑,减少分支判断

LinkedHashMap 怎么实现 LRU?构造时开启 accessOrder,重写 removeEldestEntry 控制容量

LinkedHashMap 怎么实现 LRU?

LinkedHashMap

构造时开启 accessOrder,重写 removeEldestEntry 控制容量

accessOrder
removeEldestEntry

真实缓存还要考虑什么?线程安全、过期时间、最大内存、淘汰回调、命中率统计和缓存击穿等工程问题

真实缓存还要考虑什么?

线程安全、过期时间、最大内存、淘汰回调、命中率统计和缓存击穿等工程问题

易错点

易错点

更新已有 key 时,也要移动到头部。

删除尾节点后,别忘了从哈希表里删除 key。

双向链表删除节点时要同时改前后两个指针。

虚拟头尾节点可以减少空链表边界判断。

LinkedHashMap 的 accessOrder 要设为 true。

LinkedHashMap
accessOrder
true

高频问题自测

高频问题自测

LRU 为什么需要哈希表和双向链表配合?

get 操作为什么也要移动节点?

get

更新已有 key 时,为什么不能只改 value?

淘汰尾节点后,为什么还要从哈希表删除 key?

LinkedHashMap 的插入顺序和访问顺序有什么区别?

LinkedHashMap

推荐练习题

推荐练习题

  1. LRU 缓存

146. LRU 缓存

  1. LFU 缓存

460. LFU 缓存

参考资料

参考资料

Java SE 17 API:LinkedHashMap

Java SE 17 API:LinkedHashMap

Redis Docs:Key eviction

Redis Docs:Key eviction

Operating Systems: Three Easy Pieces

Operating Systems: Three Easy Pieces

Caffeine Wiki:Eviction

Caffeine Wiki:Eviction

写在最后

写在最后

如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。

GitHub

Gitee

JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。

如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。

知识星球