← 开发笔记

Java LRU 缓存:使用 LinkedHashMap 的访问顺序

LRU 缓存淘汰最久未被访问的条目。Java 中按访问顺序排列的 LinkedHashMap 会将被访问的条目移至最近访问的一端,因此可以按请求顺序计算命中与成本。

Programmers 缓存题目忽略英文城市名的大小写,命中成本为 1,未命中成本为 5。容量范围是 0 到 30,请求最多 100,000 次。这些是题目规定的成本,并非测得的数据库响应时间。

处理流程

容量为零时立即返回。否则 get/put 刷新最近访问顺序,容量溢出时淘汰最久未访问的键。

容量为零时立即返回。否则 get/put 刷新最近访问顺序,容量溢出时淘汰最久未访问的键。

在访问时更新顺序

将完整类保存为 Solution.java。目标版本为 Java 11 或更高;原文的本地执行按 Java 11 目标编译,使用 JDK 17。方法假设输入满足题目约束。

import java.util.LinkedHashMap;
import java.util.Locale;

public class Solution {
    public int solution(int cacheSize, String[] cities) {
        if (cacheSize == 0) return 5 * cities.length;
        var cache = new LinkedHashMap<String, Boolean>(16, 0.75f, true);
        int cost = 0;
        for (String city : cities) {
            String key = city.toLowerCase(Locale.ROOT);
            cost += cache.get(key) == null ? 5 : 1;
            cache.put(key, Boolean.TRUE);
            if (cache.size() > cacheSize) {
                String oldest = cache.keySet().iterator().next();
                cache.remove(oldest);
            }
        }
        return cost;
    }
}

构造函数最后的 true 参数很重要。与默认的插入顺序不同,访问顺序 LinkedHashMap会在 get、put 等访问操作后更新顺序。此实现从不存储 null 值,因此可通过 get(key) == null 判断未命中。

超出容量时,删除迭代顺序中的第一个键,即最久未被访问的条目。这不同于按访问频次选择的 LFU。Locale.ROOT 避免英文大小写规范化依赖机器的默认区域设置。

为什么命中会改变淘汰结果

容量为 2 时,以下请求的总成本为 21。缓存内容按最旧者在前显示。

请求 结果 请求后的缓存 累计成本
Jeju 未命中 jeju 5
Pangyo 未命中 jeju, pangyo 10
Jeju 命中 pangyo, jeju 11
Seoul 未命中 jeju, seoul 16
Pangyo 未命中 seoul, pangyo 21

如果第三个请求没有刷新 Jeju 的位置,下一次淘汰就会改变。容量 0 无法保留条目,因此方法立即返回 5 * cities.length。Jeju 与 JEJU 也必须规范化为同一个键。

假设哈希操作平均为常数时间,则 M 个请求、名称长度 L、容量 C 的时间为 O(M×L),包括规范化;存储空间为 O(C×L)。请注意题目对 L 和 C 的限制。此实现并不能证明并发访问行为或实际测得的缓存性能。

数值范围可继续阅读 Java 内积与 int 边界;有序字符串转换可参阅 ID 规范化。

讨论