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

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

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

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

先看最朴素的错误做法

不少人第一版是这样:普通 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:访问顺序 。每次 getput 一个已存在的 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=trueget 也会改结构 ,所以它不是线程安全的,并发场景要 synchronizedMap(含迭代加锁)或直接用 Caffeine。

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

相关推荐
是隼人1 小时前
buuctf-pwn wdb_2018_3rd_soEasy(ret2shellcode)题解(学习过程持续更新)
c语言·学习·安全·pwn入门·ctf入门
谢亮_vipxieliang1 小时前
ValidX vs Google Guava Preconditions:验证 vs 断言
java·spring boot·后端·spring cloud·hibernate·guava
手握风云-1 小时前
Redis:不只是缓存那么简单(十六)
缓存
吴声子夜歌1 小时前
ApacheCommons——commons-configuration2(多数据源配置统一管理与热加载)
java·开发语言·算法·apache
星星落进兜里2 小时前
Java虚拟机面试题-补充
java·开发语言
吴声子夜歌5 小时前
ApacheCommons——commons-pool2(高性能通用对象池框架)
java·开发语言·apache
mldong9 小时前
C# 开发者也有自己的轻量工作流引擎了:NuGet 装包,5 分钟跑通一条审批流
后端·c#·.net
梁云亮10 小时前
spring整合Junit4
spring
xcl092510 小时前
幼儿托育系统开发实战:从需求分析到上线全流程指南
java·大数据·需求分析