跳到主要内容

LinkedHashMap 与 LRU

LinkedHashMap 在 HashMap 的桶结构之外,为所有条目维护一条双向顺序链。它可以保留插入顺序,也可以在每次访问后把条目移到链尾,从而实现简单的有界 LRU。

1. 哈希查找与顺序链同时存在

HashMap 负责按键定位,顺序链负责遍历次序:

table buckets encounter order
0 -> [B] [A] ⇄ [B] ⇄ [C]
1 -> [A] -> [C]

条目同时属于某个桶和全局顺序链。getput 平均仍接近 O(1),但每个条目多保存前后顺序引用,写入和访问时也可能需要维护链。

2. 插入顺序模式

默认构造保留键首次插入的顺序:

Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("A", 1);
counts.put("B", 2);
counts.put("A", 3);

System.out.println(counts.keySet()); // [A, B]

更新已有键的值不会把它当成新插入的条目。适合:

  • 稳定输出配置和聚合结果。
  • 去重后保留输入顺序。
  • API 需要可预测遍历顺序。

如果只为了稳定顺序,不需要排序,LinkedHashMap 比 TreeMap 少了对数比较成本。

3. 访问顺序模式

构造参数 accessOrder 设为 true 后,成功访问条目会把它移动到链尾:

Map<String, String> cache = new LinkedHashMap<>(16, 0.75f, true);
cache.put("A", "value-a");
cache.put("B", "value-b");

cache.get("A");
System.out.println(cache.keySet()); // [B, A]

链首是最久未访问条目,链尾是最近访问条目。哪些 Map 方法算作访问由 LinkedHashMap API 明确定义,不能只凭方法名猜测。

访问顺序下,get 会修改内部顺序,因此它不再是结构上纯只读操作。迭代期间调用会改变顺序的方法可能触发 fail-fast,跨线程访问也必须同步。

4. 用 removeEldestEntry 建立有界 LRU

public final class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maximumSize;

public LruCache(int maximumSize) {
super(Math.max(16, maximumSize), 0.75f, true);
if (maximumSize < 0) {
throw new IllegalArgumentException("maximumSize must be >= 0");
}
this.maximumSize = maximumSize;
}

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maximumSize;
}
}

put 加入新条目后,LinkedHashMap 调用 removeEldestEntry,返回 true 时移除链首。这个实现限制条目数量,并按最近访问顺序淘汰。

4.1 它没有解决的缓存问题

这个简单 LRU 不提供:

  • 并发访问控制。
  • 按内存重量限制。
  • 过期时间和刷新。
  • 加载去重,避免缓存击穿。
  • 命中率和淘汰监控。
  • 淘汰回调的失败隔离。

生产缓存通常使用 Caffeine 等成熟库,或远程缓存系统。LinkedHashMap 适合教学、小规模局部缓存和约束简单的工具,不应因为代码短就承担完整缓存职责。

5. LRU 与其他淘汰规则

LRU 假设最近访问过的数据更可能再次访问。它可能被一次大范围扫描污染:大量只访问一次的键会挤出真正热点。

其他策略包括:

  • FIFO:按写入时间淘汰,不考虑访问。
  • LFU:倾向保留访问频率高的条目。
  • 基于窗口和频率估计的混合策略。
  • TTL:按时间过期,与容量淘汰是不同维度。

选择缓存策略要看访问分布、对象大小和可接受陈旧度,不能把所有“最近”需求都等同于 LRU。

6. 迭代与内存成本

LinkedHashMap 沿顺序链遍历,成本与 size 成正比,不需要扫描大量空桶。这在容量预留较大而又频繁完整遍历时有优势。

代价是每个条目增加链接字段,访问顺序模式的读取还要修改链。内存敏感时,应按真实对象布局和数据量测量,而不是只比较接口复杂度。

7. 常见问题

7.1 get 为什么可能触发 ConcurrentModificationException

访问顺序模式下,get 会改变条目顺序,属于结构修改。迭代器创建后调用 get,可能改变修改计数并触发 fail-fast。插入顺序模式下没有同样的访问重排。

7.2 removeEldestEntry 在什么时候执行

新增映射完成后调用,用于决定是否移除最旧条目。它不是后台清理器,也不会因为时间流逝主动执行,因此不能单独实现 TTL。

7.3 LinkedHashMap 是线程安全的吗

不是。即使多个线程只调用 get,访问顺序模式也会修改链。需要同步包装时,迭代仍要在同一锁内;复杂并发缓存更适合专用实现。

7.4 LRU 的容量应该按条目数还是字节数

取决于对象大小分布。条目大小接近时数量上限简单有效;大小差异很大时,一个大值可能占据大部分内存,应使用权重估算并监控实际堆占用。

8. 面试题

8.1 怎样使用 HashMap 和双向链表实现 LRU

出现公司:字节跳动、快手

考察重点

  • HashMap O(1) 定位与双向链表 O(1) 重排怎样组合。
  • get、put、更新、淘汰和容量为零等边界。
  • LinkedHashMap 的访问顺序模式已经提供什么。

相关内容:第 1 节“哈希查找与顺序链同时存在”至第 4 节“用 removeEldestEntry 建立有界 LRU”。

参考回答

用 HashMap 从键定位双向链表节点,链首表示最久未使用,链尾表示最近使用。get 命中后把节点移到链尾;put 更新已有节点时同时移动,新增节点放链尾,超过容量就删除链首并从 Map 移除。

所有节点移动和删除都必须正确维护头尾边界,容量为零也要覆盖。Java 的 LinkedHashMap 在 accessOrder 模式下已经组合了哈希表和顺序链,可以通过 removeEldestEntry 实现简单版本,但并发、TTL 和按重量淘汰仍需额外机制。

8.2 LinkedHashMap 的插入顺序与访问顺序有什么区别

出现公司:上海卫瓴信息科技

考察重点

  • 更新已有键是否改变插入顺序。
  • 哪些访问会在 accessOrder 模式中重排。
  • 读取修改结构对迭代和并发有什么影响。

相关内容:第 2 节“插入顺序模式”、第 3 节“访问顺序模式”。

参考回答

默认模式按键首次插入的顺序遍历,更新已有值不会把键移动到末尾。访问顺序模式会在成功访问后把条目移到链尾,因此链首可作为最久未访问项。

访问顺序下 get 会改变内部结构,所以迭代期间可能触发 fail-fast,多线程读取也不能当作无写入操作。这个模式适合构造简单 LRU,但仍不是线程安全缓存。