Java基于ConcurrentHashMap实现缓存时如何处理锁应对未命中并发场景
基于ConcurrentHashMap的缓存防击穿实现方案
核心问题本质
你遇到的是典型的缓存击穿场景:基于ConcurrentHashMap的缓存虽然保证了读写操作的线程安全,但当同一个key同时出现并发未命中时,所有未命中的线程都会穿透到数据库,完全没有起到缓存的削峰作用。
适配现有结构的实现思路
不要直接给缓存加全局锁,那样会把所有key的并发操作全部串行化,完全丧失ConcurrentHashMap的并发性能优势。正确的做法是为每个待加载的key分配独立的、可等待的资源,保证同key的并发请求只触发一次数据库查询。
方案1:基于专属对象锁的轻量实现
这个方案和你现有get/put的方法结构兼容度最高,改造量最小:
- 除了存实际业务KV的主缓存(你现有的ConcurrentHashMap实例),额外维护一个仅用于锁分配的ConcurrentHashMap:
lockMap,存储结构为<缓存key, 互斥锁对象> - 改造
get方法的执行流程:- 快路径:直接查询主缓存,如果命中直接返回值,无任何额外锁开销
- 慢路径(缓存未命中):
- 调用
lockMap.computeIfAbsent(key, k -> new Object())获取当前key对应的专属锁对象。这个操作是线程安全的,同一个key在并发场景下只会生成一次锁实例,不会重复创建 - 对拿到的锁对象加synchronized进入临界区
- 必须执行双重检查:进入临界区后立刻再查一次主缓存。如果此时缓存已经被之前拿到锁的线程写入,直接返回缓存值,完全不需要查询数据库
- 如果双重检查后仍未命中:当前线程是这个key的唯一数据库查询责任线程,正常执行数据库查询,拿到结果后调用你现有的
put方法写入主缓存,写完后将当前key对应的锁从lockMap中移除,避免lockMap内存堆积 - 临界区执行完成后自动释放锁,后续在等锁的线程会依次进入临界区,执行上述双重检查逻辑,直接读取已经写好的缓存值返回
- 调用
- 关键踩坑提醒:
- 绝对不要把数据库查询、缓存写入的重逻辑放在
lockMap.computeIfAbsent的lambda表达式里:ConcurrentHashMap的computeIfAbsent执行映射函数时会锁住对应数据段的桶,慢操作会直接阻塞其他无关联key的并发操作,造成性能雪崩 - 做好异常兜底:如果负责查数据库的线程抛出异常,一定要在finally块中把对应key的锁从lockMap中清除、释放锁,不然所有等这个key的线程会永久阻塞
- 锁的清理时机不要太早:要等缓存写完、双重检查逻辑全部完成后再清理锁,不然会出现锁被删了之后,后续进来的线程又创建新锁、重复查数据库的问题
- 绝对不要把数据库查询、缓存写入的重逻辑放在
方案2:基于CompletableFuture的无锁化优化方案
如果对性能要求更高,可以把专属锁替换成CompletableFuture,减少线程等锁的上下文切换开销:
- 同样维护两个ConcurrentHashMap:主缓存、
futureMap(存储<缓存key, CompletableFuture<缓存值>>) - 改造
get方法的流程:- 快路径:主缓存命中直接返回
- 慢路径(缓存未命中):
- 调用
futureMap.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> { // 仅执行一次的数据库查询、写主缓存逻辑 查数据库 -> 调用现有put方法写主缓存 -> 返回查询到的值 })) - 所有并发请求同一个key的线程,拿到的都是同一个CompletableFuture实例,直接调用
future.join()等待结果返回即可 - 等CompletableFuture执行完成(不管成功还是失败),在回调里把对应key从
futureMap中移除,避免内存泄漏
- 调用
原有put方法的适配
不需要修改你现有的put方法的核心逻辑,只需要保证在上述缓存加载流程中,写入缓存时直接复用现有put的逻辑,和其他外部触发的缓存写入逻辑保持线程安全即可。
内容的提问来源于stack exchange,提问作者user15439867
相关产品推荐
相关产品推荐

