【设计题】如何实现一个线程安全的缓存?
实现线程安全的缓存需解决并发读写一致性(避免脏读、幻读)、缓存穿透 / 击穿 / 雪崩(提升稳定性)、性能与锁竞争平衡(减少开销开销)三大核心问题。以下是基于 Java 的完整实现方案,结合 ConcurrentHashMap、双重检查锁定和过期策略,兼顾安全性与高效性。
一、核心设计思路
- 底层容器选择:用
ConcurrentHashMap作为基础存储,其天然支持并发读写(分段锁优化,JDK 8 后为 CAS + synchronized),避免手动实现复杂同步逻辑。 - 缓存加载原子性:通过双重检查锁定(Double-Checked Locking)解决 “缓存击穿”(同一 key 并发请求穿透到数据库),确保同一 key 仅被一个线程加载数据。
- 过期策略:支持缓存过期自动清理,避免无效数据占用内存(基于定时任务 + 懒清理)。
- 缓存更新与失效:提供安全的
put、remove方法,确保并发场景下数据一致性。
二、代码实现
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.*;
import java.util.function.Function;/*** 线程安全缓存实现* 支持:并发读写安全、缓存加载原子性、过期清理、防缓存击穿*/
public class ThreadSafeCache<K, V> {// 底层存储:ConcurrentHashMap保证基础并发安全private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();// 加载数据的函数(如从数据库/远程服务获取数据)private final Function<K, V> loader;// 定时清理过期缓存的线程池private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();// 默认缓存过期时间(单位:秒)private final long defaultExpireSeconds;/*** 构造函数* @param loader 数据加载函数(缓存未命中时调用)* @param defaultExpireSeconds 默认过期时间(秒)*/public ThreadSafeCache(Function<K, V> loader, long defaultExpireSeconds) {this.loader = loader;this.defaultExpireSeconds = defaultExpireSeconds;// 启动定时清理任务(每30秒执行一次)this.cleaner.scheduleAtFixedRate(this::cleanExpiredEntries, 30, 30, TimeUnit.SECONDS);}/*** 获取缓存值(核心方法)* 1. 先快速检查缓存,命中则直接返回(无锁)* 2. 未命中则加锁(锁粒度为key),再次检查后加载数据*/public V get(K key) {// 第一重检查:无锁快速判断CacheEntry<V> entry = cache.get(key);if (isValid(entry)) {return entry.value;}// 未命中,加锁后再次检查(避免并发重复加载)synchronized (key) { // 锁粒度细化到key,减少竞争entry = cache.get(key);if (!isValid(entry)) { // 第二重检查:确认缓存确实未命中或已过期// 调用loader加载数据(如查库)V value = loader.apply(key);if (value == null) {// 避免缓存null值导致的缓存穿透(可根据业务调整)return null;}// 存入缓存(带过期时间)long expireTime = System.currentTimeMillis() + defaultExpireSeconds * 1000;entry = new CacheEntry<>(value, expireTime);cache.put(key, entry);}return entry.value;}}/*** 手动存入缓存*/public void put(K key, V value) {put(key, value, defaultExpireSeconds);}/*** 手动存入缓存(指定过期时间)*/public void put(K key, V value, long expireSeconds) {Objects.requireNonNull(key, "key cannot be null");Objects.requireNonNull(value, "value cannot be null");long expireTime = System.currentTimeMillis() + expireSeconds * 1000;cache.put(key, new CacheEntry<>(value, expireTime));}/*** 移除缓存*/public void remove(K key) {cache.remove(key);}/*** 清理所有缓存*/public void clear() {cache.clear();}/*** 定时清理过期缓存(懒清理补充,避免过期数据堆积)*/private void cleanExpiredEntries() {long now = System.currentTimeMillis();// 遍历并移除过期条目cache.entrySet().removeIf(entry -> {CacheEntry<V> cacheEntry = entry.getValue();return cacheEntry.expireTime < now;});}/*** 检查缓存条目是否有效(非空且未过期)*/private boolean isValid(CacheEntry<V> entry) {if (entry == null) {return false;}return System.currentTimeMillis() < entry.expireTime;}/*** 缓存条目内部类:存储值和过期时间*/private static class CacheEntry<V> {final V value; // 缓存值final long expireTime; // 过期时间(毫秒时间戳)CacheEntry(V value, long expireTime) {this.value = value;this.expireTime = expireTime;}}/*** 关闭缓存(释放资源)*/public void close() {cleaner.shutdown();}
}
三、关键设计解析
并发安全基础底层依赖
ConcurrentHashMap,其get方法无锁,put/remove方法通过 CAS 和 synchronized 实现线程安全,避免全局锁导致的性能瓶颈。防缓存击穿(双重检查锁定)
- 第一重检查:无锁快速判断缓存是否命中,减少锁竞争。
- 第二重检查:加锁(锁粒度为 key)后再次确认,确保同一 key 仅被一个线程加载数据(避免高并发下重复查库)。
- 锁粒度为
key而非整个缓存,大幅降低不同 key 间的锁竞争。
过期策略
- 懒清理:
get方法获取数据时,自动检查是否过期,过期则重新加载(避免无效数据返回)。 - 定时清理:后台线程定期删除过期条目,防止过期数据长期占用内存(补充懒清理的不足)。
- 懒清理:
避免缓存穿透代码中对
loader返回的null值不缓存(可根据业务调整,如缓存null并设置短过期时间,避免频繁穿透)。
四、使用示例
public class CacheDemo {public static void main(String[] args) {// 模拟从数据库加载数据的函数(此处用简单逻辑代替)Function<String, String> dbLoader = key -> {System.out.println("Loading data from DB for key: " + key);try {Thread.sleep(100); // 模拟DB查询耗时} catch (InterruptedException e) {Thread.currentThread().interrupt();}return "value_" + key;};// 创建缓存(默认过期时间30秒)ThreadSafeCache<String, String> cache = new ThreadSafeCache<>(dbLoader, 30);// 多线程并发测试ExecutorService executor = Executors.newFixedThreadPool(10);for (int i = 0; i < 10; i++) {final int num = i;executor.submit(() -> {String key = "key_" + (num % 3); // 模拟3个key的并发请求String value = cache.get(key);System.out.println("Thread " + Thread.currentThread().getId() + " get " + key + ": " + value);});}executor.shutdown();cache.close();}
}
输出说明:每个 key 仅会打印一次 Loading data from DB,证明并发请求下数据加载仅执行一次,缓存生效且线程安全。
五、扩展优化方向
- 缓存雪崩防护:对不同 key 设置随机过期时间(如
defaultExpireSeconds ± 5),避免大量缓存同时过期导致的数据库压力。 - 最大容量限制:结合 LRU(最近最少使用)算法,当缓存达到最大容量时淘汰不常用数据(可基于
LinkedHashMap实现)。 - 异步加载:对耗时较长的
loader,改用异步加载(如CompletableFuture),避免线程阻塞。 - 监控统计:增加缓存命中率、加载耗时等指标统计,便于性能调优。
总结
该实现通过 ConcurrentHashMap 保证基础并发安全,双重检查锁定解决缓存击穿,结合懒清理与定时清理实现过期策略,在安全性、性能和可用性之间取得平衡,适合高并发场景下的缓存需求。
