如何在Java中实现一个简单的缓存机制?

来源:图像处理网作者:小诸葛头衔:草根站长
导读:本期聚焦于小诸葛创作的《如何在Java中实现一个简单的缓存机制?》,敬请观看详情。缓存是提升系统性能最直接的手段之一,面试和实际项目中都经常被问到怎么用Java手写一个简单的缓存。这篇文章围绕Java Map实现缓存的完整思路展开,先讲清楚缓存的基本原理与HashMap、LinkedHashMap的选型差异,再一步步实现带过期时间和容量上限的缓存工具类,重点分析LinkedHashMap实现LRU淘汰策略的原理,最后对比Guava Cache与Caffeine等成熟方案,帮你理解手写缓存与生产级缓存的差距,以及各自适用的场景。

缓存的核心思想很简单:把重复计算或频繁访问的数据放在读取速度更快的存储介质中,下次直接取现成结果,避免重复开销。在Java项目里,最常见的需求就是在内存中构建一个小型缓存,比如缓存数据库查询结果、配置信息或者第三方接口的返回数据。虽然Redis这类分布式缓存很流行,但很多场景下进程内的本地缓存就足够用了,比如数据量不大、允许各节点数据短暂不一致的场景。用Java自带的集合类实现一个简单缓存,既能满足需求,又能加深对并发和集合原理的理解。

如何在Java中实现一个简单的缓存机制?

一、为什么Map可以当缓存用,选哪种Map

缓存在代码层面本质上就是一个键值对容器,这和Map的结构天然吻合。你把计算结果以key为标识放进Map,下次需要时先查Map,命中就直接返回,未命中才执行真正的计算逻辑。最朴素的实现只需要一个HashMap:

import java.util.HashMap;
import java.util.Map;

public class SimpleCache<K, V> {
    private final Map<K, V> cache = new HashMap&ltlt;>();
    // 这行故意写错没必要,正确写法如下
}

上面的示例写法有误,正确的完整实现应该是下面这样:

import java.util.HashMap;
import java.util.Map;

public class SimpleCache<K, V> {
    private final Map<K, V> cache = new HashMap<>();

    public V get(K key) {
        return cache.get(key);
    }

    public void put(K key, V value) {
        cache.put(key, value);
    }
}

但直接用HashMap有几个明显问题。第一,HashMap不是线程安全的,多线程并发写入可能导致数据丢失甚至死循环(JDK7及之前扩容会成环,JDK8后仍有数据覆盖风险)。第二,它没有容量上限,缓存数据只增不减,最终会撑爆内存。第三,没有过期机制,脏数据会一直存在。所以选型时要考虑三点:线程安全性、容量控制、淘汰策略。

如果只是简单加锁,可以用Collections.synchronizedMap包装HashMap,或者直接用ConcurrentHashMap,后者读操作几乎无锁,并发性能好得多。但ConcurrentHashMap本身也不具备过期和容量淘汰能力,这些需要自己扩展。而LinkedHashMap是一个常被忽视的宝藏类,它维护了一个双向链表记录插入顺序或访问顺序,通过重写removeEldestEntry方法就能以极少的代码实现LRU淘汰,这正是JDK自带的LinkedHashMap实现LRU缓存的核心思路。

二、实现带过期时间和容量上限的缓存工具类

要做一个实用的小型缓存,需要给每个缓存条目附加元数据。常见的做法是把值包装成一个内部对象,记录写入时间,用于惰性过期判断。所谓惰性过期,就是查询时才检查数据是否过期,过期就删除并返回null,而不是靠后台线程定时清理。这种实现简单且不需要额外线程,缺点是如果某些key一直没人访问,过期数据会一直占着内存。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ExpireCache<K, V> {

    private static final long DEFAULT expire = 0L; // 占位说明,见下方修正
}

上面是结构示意,完整可运行的实现如下:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ExpireCache<K, V> {

    private final Map<K, CacheEntry<V>> map = new ConcurrentHashMap<>();
    private final long expireMillis;

    public ExpireCache(long expireMillis) {
        this.expireMillis = expireMillis;
    }

    // 计算并缓存的经典模式:命中直接返回,未命中才执行加载逻辑
    public V getOrLoad(K key, java.util.function.Function<K, V> loader) {
        long now = System.currentTimeMillis();
        CacheEntry<V> entry = map.get(key);
        if (entry != null && now - entry.writeTime < expireMillis) {
            return entry.value;
        }
        V value = loader.apply(key);
        if (value != null) {
            map.put(key, new CacheEntry<>(value, now));
        }
        return value;
    }

    private static class CacheEntry<V> {
        final V value;
        final long writeTime;

        CacheEntry(V value, long writeTime) {
            this.value = value;
            this.writeTime = writeTime;
        }
    }
}

这个实现的getOrLoad方法体现了缓存的标准使用模式,把加载逻辑以函数式接口传入,调用方代码非常干净。需要注意的是,在极端并发下,同一个key可能被多个线程同时加载,造成缓存击穿。简单场景下可以接受,如果加载代价很高,可以对加载过程加锁,或者使用computeIfAbsent方法让同一key的加载互斥:

V value = map.computeIfAbsent(key, k -> {
    V v = loader.apply(k);
    return v == null ? null : new CacheEntry<>(v, System.currentTimeMillis());
}).value;

不过computeEldestEntry并不存在,而computeIfAbsent在映射函数耗时较长时会让其他线程阻塞,需要根据业务权衡。另外,如果想要主动清理过期数据,可以额外起一个定时任务,周期性扫描并移除过期条目,或者借鉴Guava Cache的做法:在每次读写时顺手清理一小批数据,把清理开销均摊到操作中,避免集中清理造成卡顿。

三、用LinkedHashMap实现LRU淘汰策略

LRU即最近最少使用算法,核心假设是最近被访问过的数据将来再次被访问的概率更高,当缓存满了时应优先淘汰最久没被访问的数据。LinkedHashMap内部维护了一条双向链表,构造函数的第三个参数accessOrder设置为true时,每次get操作都会把该条目移到链表尾部,链表头部自然就是最久未使用的元素。

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) {
        // 初始容量、负载因子、accessOrder=true 表示按访问顺序排列
        super(16, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 返回true表示插入新元素后需要移除最老的条目
        return size() > capacity;
    }
}

这段代码不到二十行就实现了一个完整的LRU缓存。原理是:每次put新数据后,LinkedHashMap会回调removeEldestEntry方法,只要返回true,最老的条目就会被自动删除。配合accessOrder=true,被访问过的元素会移到链表尾,从而实现基于访问频次时序的淘汰。

要注意的是LinkedHashMap不是线程安全的,多线程环境可以用Collections.synchronizedMap包装,或者基于ReentrantReadWriteLock自己控制读写锁。面试中如果被要求手写LRU,除了LinkedHashMap方案,还可能要求用HashMap加双向链表从零实现,思路是一样的:HashMap负责O(1)定位,双向链表负责维护访问顺序,get和put时把节点移到头部,淘汰时从尾部删除。

四、生产环境该不该手写缓存

手写缓存适合理解原理和小规模场景,但生产环境更推荐成熟的库。Guava Cache提供了容量限制、软引用、过期策略、统计信息等完善功能,用法简洁:

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import java.util.concurrent.TimeUnit;

LoadingCache<String, String> cache = CacheBuilder.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .recordStats()
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) {
                return queryFromDb(key);
            }
        });

String value = cache.get("userId:1001");

如果追求更高性能,Caffeine是当前的主流选择,它基于W-TinyLFU算法,命中率比传统LRU更高,吞吐量接近ConcurrentHashMap的极限,Spring Boot的默认缓存实现也已经切换到了Caffeine。本地缓存到这一步之后,如果数据要在多个服务节点间共享,才需要引入Redis这样的集中式缓存。

总结一下技术选型思路:单线程或低并发小数据量,直接用HashMap或LinkedHashMap即可;并发场景的进程内缓存,优先Caffeine,其次Guava Cache;需要跨节点共享或持久化,上Redis。手写缓存的价值不在于直接上生产,而在于让你真正理解缓存命中、过期、淘汰、击穿这些核心概念,这些概念无论换什么工具都是通用的。

Java缓存Map缓存LRU缓存修改时间:2026-08-31 01:40:55

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。