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,别自己撸链表。

相关推荐
子兮曰5 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
小羊没烦恼!5 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
子兮曰5 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
俊昭喜喜里5 天前
java中的继承和多态的区别
java
小羊没烦恼!5 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
爱勇宝5 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
譕痕5 天前
JSONObject与JSONArray封装数据格式区别
java·json
胡写代码5 天前
别再前后端各写一套表单校验了
java·后端
小鱼能吃糖5 天前
缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域
java·电商
此时不提桶,更待何时5 天前
01-06-A-JVM排查实战详解
java·jvm