使用LinkedHashMap实现LRU缓存需要重写哪些方法?

文章导读
使用 LinkedHashMap 实现 LRU 缓存,看起来只需要几行代码,但我遇到过不少因为方法重写不对而导致缓存策略失效或内存溢出的线上问题。如果你正准备这样实现,建议先确认下面几个关键点,再动手改代码。
📋 目录
  1. A 先确认要重写的方法:removeEldestEntry
  2. B 别忘了设置访问顺序参数
  3. C 常见陷阱:错误使用 threshold 作为容量
  4. D 线程安全需要额外处理
  5. E 验证与回滚边界
A A

使用 LinkedHashMap 实现 LRU 缓存,看起来只需要几行代码,但我遇到过不少因为方法重写不对而导致缓存策略失效或内存溢出的线上问题。如果你正准备这样实现,建议先确认下面几个关键点,再动手改代码。

使用LinkedHashMap实现LRU缓存的核心在于重写其`removeEldestEntry`方法。该方法在每次`put`或`putAll`操作后自动调用,并传入一个代表最老条目(即最早未被访问的条目)的`Map.Entry`对象。通过在该方法内判断当前缓存大小是否超过预设容量,如果超过则返回`true`,触发移除最老条目。例如,典型的实现是`return size() > capacity;`,其中`capacity`是在构造时传入的最大缓存容量。需要注意的是,`size()`返回的是当前映射中的条目数,而非阈值,因此要确保容量为正整数,避免因初始容量设置不当导致逻辑错误。

要实现LRU淘汰策略,必须将LinkedHashMap的构造参数`accessOrder`设置为`true`。该参数默认是`false`,表示按插入顺序维护条目;设为`true`后,每次通过`get()`或`put()`访问一个条目时,该条目会被移动到链表的末尾,从而形成按访问时间排序的效果。这样,链表头部的条目就是最近最少使用的。注意,如果忘记设置此参数,即使重写了`removeEldestEntry`,缓存也只会按插入顺序淘汰,无法正确反映访问频率,导致LRU失效。

先确认要重写的方法:removeEldestEntry

使用LinkedHashMap实现LRU缓存的核心在于重写其removeEldestEntry方法。该方法在每次putputAll操作后自动调用,并传入一个代表最老条目(即最早未被访问的条目)的Map.Entry对象。通过在该方法内判断当前缓存大小是否超过预设容量,如果超过则返回true,触发移除最老条目。例如,典型的实现是return size() > capacity;,其中capacity是在构造时传入的最大缓存容量。需要注意的是,size()返回的是当前映射中的条目数,而非阈值,因此要确保容量为正整数,避免因初始容量设置不当导致逻辑错误。

这里的 capacity 通常通过构造函数的参数传入,但你可能会遇到一个陷阱:刚初始化时 size() 为 0,直到第一次 put 后才判断。所以容量设置绝对不能为 0 或负数,否则缓存永远无法放入数据。建议在构造函数里对 capacity 做一次防御校验,比如 if (capacity <= 0) throw new IllegalArgumentException()

别忘了设置访问顺序参数

要实现LRU淘汰策略,必须将LinkedHashMap的构造参数accessOrder设置为true。该参数默认是false,表示按插入顺序维护条目;设为true后,每次通过get()put()访问一个条目时,该条目会被移动到链表的末尾,从而形成按访问时间排序的效果。这样,链表头部的条目就是最近最少使用的。注意,如果忘记设置此参数,即使重写了removeEldestEntry,缓存也只会按插入顺序淘汰,无法正确反映访问频率,导致LRU失效。

这个参数很容易被遗漏,尤其是在从旧代码复制时。验证方法很简单:在单线程测试中先 put 三个条目,然后 get 第二个,再 put 第四个,看被淘汰的是不是第一个(如果 accessOrder=false 则可能淘汰第二个)。如果发现淘汰顺序不对,优先检查构造函数是否传了 true

使用LinkedHashMap实现LRU缓存需要重写哪些方法?

常见陷阱:错误使用 threshold 作为容量

一个容易踩的坑是removeEldestEntry方法内错误地使用size() > threshold来判断,其中thresholdHashMap内部扩容阈值(capacity * loadFactor),而非用户设定的最大条目数。这会导致缓存条目数远超预期,因为阈值通常大于容量。正确的做法是直接与用户定义的容量上限比较。另外,如果重写removeEldestEntry时返回false,则永远不会移除条目,缓存会无限增长,最终导致内存溢出。务必在测试时验证size()是否被有效限制在容量以内。

另一个相关的陷阱是:如果你在重写方法里用了 size() > capacity,但 capacity 是 int 类型且你误传了 HashMap.DEFAULT_INITIAL_CAPACITY(16)之类,而实际预期容量是 100,那缓存就会在 16 之后就开始淘汰。建议把 capacity 作为成员变量存下来,并在测试用例中打印每次淘汰后的 size,确认它不会超过预设值。

线程安全需要额外处理

LinkedHashMap 本身不是线程安全的,直接在多线程环境下使用会导致数据不一致,比如两个线程同时 put 可能触发 removeEldestEntry 的竞态条件,或者 get 操作与移除操作交错导致链表顺序错乱。常见的做法是用 Collections.synchronizedMap() 包装,但这会降低并发性能,并且迭代时仍然需要外部同步。更推荐的做法是使用 java.util.concurrent.ConcurrentHashMap 配合自定义链表,或者直接引入 Caffeine 等专业缓存库。如果坚持用 LinkedHashMap,就必须在每次 put 和 get 操作上加锁,可以用 synchronized 块或者 ReentrantLock,并且注意不要锁整个类,而是锁缓存实例本身。

验证与回滚边界

上线前至少要验证三个信号:

  • 缓存大小是否被严格限制在 capacity 以内?可以在 put 方法末尾打印 size(),对比预期。
  • 访问顺序是否正确?用上面提到的 A->B->C->get(B)->put(D) 顺序测试淘汰对象是否为 A。
  • 多线程下是否出现 ConcurrentModificationException 或 size 超过 capacity?用 10 个线程并发 put/get 跑几分钟,观察日志。

如果发现缓存未按预期淘汰,先检查 accessOrder 是否为 true,再检查 removeEldestEntry 的判断条件。如果出现内存溢出,先确认方法是否返回了 false,或者错误使用了 threshold。回滚方案很简单:把 LinkedHashMap 缓存替换回普通的 HashMap 或者直接关掉缓存功能,对业务的影响通常只是性能下降,不会导致数据错误。建议在配置中心保留一个开关,方便快速切换。

另外,如果缓存条目是对象,注意不要因为引用关系导致 GC 无法回收——LinkedHashMap 的条目被移除后,如果外部还有引用,内存依然不会释放。可以在移除时显式将 value 置为 null,但更常见的是直接依赖 GC 的弱引用,这就不在本文讨论了。