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