双重检查锁+ConcurrentHashMap不能防缓存穿透,因其无法拦截同一空key的并发查询;需按key维护占位对象(如CompletableFuture)并缓存空值标记,配合computeIfAbsent实现单次加载。

为什么双重检查锁 + ConcurrentHashMap 不能直接防缓存穿透
双重检查锁(DCL)本身只解决「多个线程同时初始化单例」的问题,它不感知业务语义。缓存穿透的核心是:null 值被大量请求反复击中,而 DCL 对 null 缓存项完全无感——只要 ConcurrentHashMap.get(key) 返回 null,所有线程都会各自走查库逻辑,根本没机会进锁块。
真正要拦住的是「对同一个空 key 的并发查询」,而不是「同一个缓存对象的初始化」。
- ConcurrentHashMap 的
computeIfAbsent在 key 不存在时才执行 mapping function,但若 DB 查不到返回null,下次调用仍会再进函数,无法阻塞后续请求 - DCL 的锁粒度在「整个缓存实例」或「某个静态字段」上,和具体 key 无关,对 key 级别的并发控制无效
- 常见错误是写成
if (map.get(key) == null) { synchronized(...) { ... } }—— 这里get和synchronized之间存在竞态窗口,多个线程可能同时通过判空
用 ConcurrentHashMap.computeIfAbsent + 单 key 锁(如 ReentrantLock)组合拦截空值请求
必须把「是否正在查这个 key」的状态按 key 维护,且保证创建/获取锁的过程本身线程安全。ConcurrentHashMap 适合做这层锁容器,但不能直接存 null,要用占位对象(如 new Object() 或 CompletableFuture)标记进行中状态。
- 先用
computeIfAbsent尝试放入一个临时占位锁对象(比如new ReentrantLock()),返回值即当前 key 对应的锁 - 拿到锁后,再次检查缓存(防止其他线程已写入),再查 DB,最后写入真实结果(非
null)或空值兜底(如Optional.empty()) - 不要在锁对象上直接
synchronized——ReentrantLock需显式lock()/unlock(),否则锁失效 - 务必在
finally块中unlock(),否则死锁;也不要在锁内抛异常后漏掉释放
// 示例:按 key 分布式锁的简化骨架
ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
ConcurrentHashMap<String, Optional<User>> cache = new ConcurrentHashMap<>();
public Optional<User> getUser(String userId) {
Optional<User> cached = cache.get(userId);
if (cached != null) return cached;
ReentrantLock lock = lockMap.computeIfAbsent(userId, k -> new ReentrantLock());
lock.lock();
try {
// 再查一次,防重复加载
cached = cache.get(userId);
if (cached != null) return cached;
User dbUser = queryFromDB(userId);
Optional<User> result = Optional.ofNullable(dbUser);
cache.put(userId, result);
return result;
} finally {
lock.unlock();
lockMap.remove(userId); // 用完即删,避免内存泄漏
}
}
更稳妥的做法:用 CompletableFuture 做异步占位 + computeIfAbsent
比起手动管理 ReentrantLock,用 CompletableFuture 更自然——它天生支持「一个计算任务被多次 join(),但只执行一次」,且能统一处理异常、超时、取消等边界。
立即学习“Java免费学习笔记(深入)”;
-
computeIfAbsent放入一个未完成的CompletableFuture<Optional<User>>,返回即为该 future - 所有线程都对该 future 调用
join()或get(),自动等待唯一一次 DB 查询完成 - 查询完成后,用
complete()或completeExceptionally()设置结果,后续调用立刻返回 - 注意:future 本身不能缓存失败(如 DB 异常),否则下次请求会直接拿到失败结果;应捕获异常后
complete(Optional.empty())或重抛并清空 future - 别忘了在 finally 清理 map 中已完成的 future,否则内存持续增长
ConcurrentHashMap 的 key 设计与缓存空值策略必须匹配
很多人只记得加锁,却忽略空值本身要不要缓存、缓存多久。如果每次 DB 查不到都返回 null 并不写入 map,那上面所有锁机制都白搭——下个请求还是从头来。
- 推荐写入
Optional.empty()或自定义空标记对象(如NullValueHolder.INSTANCE),而非裸null - 空值缓存时间建议明显短于正常值(如 2min vs 30min),避免长期屏蔽真实数据写入
- key 必须严格一致:注意字符串 trim、大小写、编码、JSON 序列化格式差异,否则相同业务含义的 key 被视为不同 key,锁和缓存都失效
- ConcurrentHashMap 不支持过期,如需 TTL,请换
Caffeine或Guava Cache,它们原生支持expireAfterWrite和refreshAfterWrite
空值缓存的粒度、锁的生命周期、future 的清理时机——这三个点任何一个没对齐,就会出现「以为防住了,其实漏了一半」的情况。



















