Java 用 LinkedHashMap 三行实现 LRU 缓存:accessOrder、removeEldestEntry 与线程安全

Java 用 LinkedHashMap 三行实现 LRU 缓存:accessOrder、removeEldestEntry 与线程安全

面试常问「手写一个 LRU 缓存」,很多人上来就用 HashMap + 双向链表 撸一大坨,自己维护节点的插入删除,几十行下来还容易在移动节点时写出 bug。其实 JDK 早就把这套「哈希表 + 双向链表」的组合塞进了 LinkedHashMap,你只要开一个开关、重写一个方法,三行就能得到一个正确的 LRU。

这篇讲清楚这三行背后的两个关键点:accessOrder 和 removeEldestEntry,以及它为什么默认不是线程安全的、怎么补。

先看最朴素的错误做法

不少人第一版是这样:普通 HashMap,超容量就随便删一个。

java 复制代码
// 错误示范:HashMap 没有顺序,删的不是「最久未用」的
Map<Integer, String> cache = new HashMap<>(16);
if (cache.size() >= capacity) {
    Integer anyKey = cache.keySet().iterator().next(); // 删的是「某一个」,不是 LRU
    cache.remove(anyKey);
}
cache.put(k, v);

问题很明显:HashMap 的迭代顺序是由 hash 决定的、不可控,iterator().next() 拿到的既不是最久插入、也不是最久访问的。这只是个「随机淘汰」缓存,不是 LRU。

关键 1:LinkedHashMap 的两种顺序

LinkedHashMap 继承自 HashMap,额外用一条双向链表 把所有 entry 串起来,维护迭代顺序。它有两种模式,由构造器第三个参数 accessOrder 决定:

  • accessOrder = false(默认):插入顺序。链表按 put 的先后排列,get 不改变顺序。
  • accessOrder = true:访问顺序 。每次 get 或 put 一个已存在的 key,都会把它挪到链表尾部。于是链表头部永远是「最久未被访问」的------这正是 LRU 要淘汰的那个。

看一眼差别:

java 复制代码
// accessOrder = true:访问过的会挪到末尾
LinkedHashMap<String, Integer> m = new LinkedHashMap<>(16, 0.75f, true);
m.put("a", 1);
m.put("b", 2);
m.put("c", 3);
m.get("a");           // 访问 a,a 被挪到末尾
System.out.println(m.keySet()); // [b, c, a]  ------ b 现在是最久未用的

关键 2:removeEldestEntry 决定何时淘汰

光有访问顺序还不会自动删东西。LinkedHashMap 在每次 put 之后,会调用一个钩子方法 removeEldestEntry(eldest)------eldest 就是链表头部那个最久未用的 entry。默认实现永远返回 false(即从不淘汰)。我们重写它:当元素个数超过容量时返回 true,JDK 就会自动把 eldest 删掉。

于是完整的 LRU 缓存就三行核心逻辑:

java 复制代码
import java.util.LinkedHashMap;
import java.util.Map;

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LRUCache(int capacity) {
        // 第三个参数 true = 按访问顺序;初始容量设大点减少扩容
        super(capacity + 1, 1.0f, true);
        this.capacity = capacity;
    }

    // put 之后 JDK 自动回调:返回 true 就把最老的 entry 删掉
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

用起来:

java 复制代码
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "a");
cache.put(2, "b");
cache.put(3, "c");
cache.get(1);          // 访问 1,1 变成最新
cache.put(4, "d");     // 超容量,淘汰最久未用的 2(不是 1)

System.out.println(cache.keySet()); // [3, 1, 4]
System.out.println(cache.get(2));   // null,2 已被淘汰

注意淘汰的是 2 而不是 1,因为我们在 put 4 之前 get 过 1,把它「续命」了。这就是 LRU(Least Recently Used)的语义。

为什么初始容量传 capacity+1、负载因子传 1.0

一个容易被忽略的细节。removeEldestEntry 是在 put 完成后 判断的,也就是说容器会短暂地达到 capacity + 1 个元素再删回 capacity。如果初始容量正好设成 capacity,负载因子 0.75,那么 map 会在装到 capacity * 0.75 时就触发一次 rehash 扩容------白白浪费。

把负载因子设成 1.0f、初始容量设成 capacity + 1,可以让底层数组刚好够用又不提前扩容。不是必须,但对固定容量的 LRU 是个稳妥的小优化。

坑:LinkedHashMap 不是线程安全的

上面这个 LRUCache 在单线程或有外部同步时完全没问题,但多线程并发读写会出问题。而且这里有个反直觉的点:

在 accessOrder = true 模式下,get 也会修改链表结构 (把访问的节点挪到尾部)。所以哪怕你只是并发地「读」,底层也在改双向链表,同样会破坏结构,甚至在旧版本 JDK 里触发 HashMap 扩容时的死循环/数据错乱。别以为「只读就安全」。

最简单的补法是包一层 Collections.synchronizedMap,并且连迭代都要手动加锁:

java 复制代码
Map<Integer, String> safe = Collections.synchronizedMap(new LRUCache<>(100));

// 单个 get/put 已经同步了,但复合操作和迭代要自己锁
synchronized (safe) {
    for (Map.Entry<Integer, String> e : safe.entrySet()) {
        // 遍历期间持锁,否则可能 ConcurrentModificationException
    }
}

如果并发量大、锁竞争明显,别硬扛 synchronizedMap,直接上成熟的本地缓存库,比如 Caffeine------它用了分段/无锁结构和更聪明的淘汰算法(W-TinyLFU),吞吐和命中率都比手写 LRU 好得多:

java 复制代码
// 生产环境更推荐:Caffeine,自带过期、容量淘汰、并发安全
Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .build();
cache.put(1, "a");
String v = cache.getIfPresent(1);

手写 LinkedHashMap 版 LRU 适合面试、适合确定单线程/已有外部同步的场景;高并发生产场景用 Caffeine 更省心。

小结

  • HashMap 无序,拿它做 LRU 只能得到随机淘汰;要 LRU 必须有「访问顺序」。
  • LinkedHashMap(capacity, loadFactor, true) 的第三个参数 accessOrder=true 让每次访问把节点挪到链表尾,头部即最久未用。
  • 重写 removeEldestEntry,size() > capacity 时返回 true,JDK 自动淘汰头部------核心就这三行。
  • accessOrder=true 下 get 也会改结构 ,所以它不是线程安全的,并发场景要 synchronizedMap(含迭代加锁)或直接用 Caffeine。

一句话记忆:LRU = LinkedHashMap 开 accessOrder + 重写 removeEldestEntry,别自己撸链表。

相关推荐
猿究院_夏燊8 小时前
Spring Bean 生命周期
java·后端·spring
Wang's Blog8 小时前
Java 项目实战: 交友项目:登录链路回顾与今日任务地图
java·开发语言·状态模式·交友
Chenyu_3108 小时前
DPDK、TC 和 XDP:Linux 高性能网络到底在优化什么
linux·服务器·网络·缓存
信誓旦旦的程序猿9 小时前
【Python量化实战宝典 #08】可转债与债券接口手册:转股价·溢价率·纯债价值(Python 可跑)
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
无障碍研究院9 小时前
2026TADC|圆桌对话:出海无障碍合规
安全·web安全
砚底藏山河9 小时前
python量化:行情数据回溯
java·开发语言·python·金融·maven
Jlzn88889 小时前
华东电芯分选通道数怎么配才不堵线
java·人工智能·架构·自动化·制造
流烟默9 小时前
MCP第 5 篇:用 Spring AI 写 MCP:注解、自动配置、ChatClient 集成
人工智能·spring·mcp封装
香吧香9 小时前
GRPC连接机制---长连接分析
java·网络与传输协议
CCblandy9 小时前
雪花 ID 四个边界实测:时钟回拨重号 500 个,前端 JSON.parse 直接丢精度
后端·python·算法·性能优化·架构