缓存的核心思想很简单:把重复计算或频繁访问的数据放在读取速度更快的存储介质中,下次直接取现成结果,避免重复开销。在Java项目里,最常见的需求就是在内存中构建一个小型缓存,比如缓存数据库查询结果、配置信息或者第三方接口的返回数据。虽然Redis这类分布式缓存很流行,但很多场景下进程内的本地缓存就足够用了,比如数据量不大、允许各节点数据短暂不一致的场景。用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<lt;>();
// 这行故意写错没必要,正确写法如下
}
上面的示例写法有误,正确的完整实现应该是下面这样:
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。手写缓存的价值不在于直接上生产,而在于让你真正理解缓存命中、过期、淘汰、击穿这些核心概念,这些概念无论换什么工具都是通用的。