为何带双重检查锁的ConcurrentHashMap缓存仍返回过期/未初始化值?
背景
我正在开发一个高吞吐量后端服务,需要维护存储昂贵计算结果的内存缓存,要求缓存支持延迟初始化且在高并发场景下安全。为避免重复计算,我实现了经典的双重检查锁模式,代码如下:
import java.util.concurrent.ConcurrentHashMap; public class ExpensiveCache { private final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>(); private Object computeExpensiveValue(String key) { // 模拟昂贵计算 return new Object(); } public Object get(String key) { Object value = cache.get(key); if (value == null) { synchronized (cache) { value = cache.get(key); if (value == null) { value = computeExpensiveValue(key); cache.put(key, value); } } } return value; } }
观测到的行为
在多线程负载测试中,偶尔会出现以下问题:
- 多个线程针对同一个key触发
computeExpensiveValue执行 - 少数情况下线程获取到未初始化/不完整状态的对象(比如默认值或未完成构造的对象)
- 加锁后仍存在明显的延迟峰值
- 缓存命中率未达到预期
computeExpensiveValue的实际实现带有外部副作用:
private ExpensiveResult computeExpensiveValue(String key) { ExpensiveResult result = new ExpensiveResult("value-" + key); metrics.incrementComputed(); // 外部指标统计 return result; }
指标显示,即便预期同步块能防止重复初始化,负载下同一key对应的computeExpensiveValue仍会被多次执行。
预期行为
由于我对cache加锁并在同步块内做了二次null检查,预期:
- 每个key仅执行一次昂贵计算
- 所有线程都能看到完全构造完成的对象
- 不存在重复初始化的情况
已排查内容
- 缓存对象构造完成后是不可变的
computeExpensiveValue中没有显式的写重排序逻辑- 没有手动实现的缓存驱逐逻辑
- 使用的JVM版本为17
核心问题
- 该实现在Java内存模型(JMM)下是否真的线程安全?我是不是误解了
synchronized和ConcurrentHashMap的交互方式? - 哪些微妙的并发问题会导致:
- 负载下的重复计算
- 即便用了双重检查锁,仍偶尔出现对象状态部分可见的情况?
- 将
ConcurrentHashMap与外部synchronized同步结合使用,有没有已知的陷阱? - 高并发系统中,实现此类延迟初始化的正确生产级模式是什么?
异常现象
当我把ConcurrentHashMap替换成普通HashMap但保留相同的同步块时,问题出现的频率反而降低了,这和直觉不符,说明我还没找到根本原因。
完整问题代码
import java.util.concurrent.ConcurrentHashMap; public class ExpensiveCache { private final ConcurrentHashMap<String, ExpensiveResult> cache = new ConcurrentHashMap<>(); static class ExpensiveResult { final String value; final long createdAt; ExpensiveResult(String value, long createdAt) { this.value = value; this.createdAt = createdAt; } } private ExpensiveResult computeExpensiveValue(String key) { // 模拟昂贵计算 return new ExpensiveResult("value-" + key, System.nanoTime()); } public ExpensiveResult get(String key) { ExpensiveResult value = cache.get(key); if (value == null) { synchronized (cache) { value = cache.get(key); if (value == null) { value = computeExpensiveValue(key); cache.put(key, value); } } } return value; } }
1. 实现是否线程安全?对synchronized与ConcurrentHashMap的误解
这个实现不是线程安全的,核心问题在于混淆了ConcurrentHashMap的内置同步和外部synchronized块的语义:
ConcurrentHashMap的get方法是无锁的(基于CAS和volatile读),它的可见性保证仅局限于自身操作,不会和外部synchronized块形成绑定的happens-before关系。- 用
cache作为锁对象时,ConcurrentHashMap内部的分段锁/Node级锁与外部锁完全独立,外部同步块无法阻止ConcurrentHashMap自身的并发操作,导致可见性和同步逻辑失效。
2. 导致问题的并发根源
重复计算的原因
- 第一个无锁的
cache.get(key)在高并发下会让多个线程同时读到null,进而排队等待进入synchronized块。而ConcurrentHashMap的put操作的可见性无法通过外部synchronized块强制传递,部分线程进入同步块后再次get时仍可能读到null,触发重复计算。 ConcurrentHashMap的get可能读到半初始化的Node节点(put操作是分步链入哈希表的),导致线程误判缓存未命中。
对象状态部分可见的原因
- 虽然
ExpensiveResult的字段都是final(JMM对final字段有可见性保证),但该保证仅在对象正确发布时生效。这里ConcurrentHashMap的put操作与外部synchronized的锁释放没有强制的happens-before关系,JVM可能重排序操作顺序,导致线程读到的ExpensiveResult对象中final字段尚未完成初始化。
3. ConcurrentHashMap与外部同步的陷阱
- 锁语义冲突:
ConcurrentHashMap自身已实现细粒度并发安全,外部套全局synchronized会废掉它的分段锁优化,同时两种同步机制的可见性语义无法协同,导致状态不一致。 - 可见性割裂:外部
synchronized的happens-before关系仅覆盖进入/退出同步块的线程,与ConcurrentHashMap内置的volatile读写、CAS操作的可见性保证相互独立,无法统一缓存状态的视图。 - 锁对象冗余:用
ConcurrentHashMap作为锁对象毫无必要,它的内部锁逻辑与外部锁完全无关,只会增加同步复杂度。
4. 高并发下延迟初始化的正确生产级模式
模式一:使用ConcurrentHashMap的computeIfAbsent方法
这是最简洁可靠的方案,computeIfAbsent本身就是为高并发下的延迟初始化设计的,保证同一个key的计算只会执行一次:
public ExpensiveResult get(String key) { return cache.computeIfAbsent(key, this::computeExpensiveValue); }
- 原理:内部使用Node级别的锁,同一key仅允许一个线程执行计算,其他线程等待结果生成后直接获取,兼顾并发性能与线程安全。
模式二:基于FutureTask的双重检查锁(复杂场景适配)
如果需要在计算前后添加额外逻辑,可以用FutureTask实现安全的双重检查锁:
private final ConcurrentHashMap<String, Future<ExpensiveResult>> cache = new ConcurrentHashMap<>(); public ExpensiveResult get(String key) throws ExecutionException, InterruptedException { Future<ExpensiveResult> future = cache.get(key); if (future == null) { FutureTask<ExpensiveResult> task = new FutureTask<>(() -> computeExpensiveValue(key)); future = cache.putIfAbsent(key, task); if (future == null) { task.run(); future = task; } } return future.get(); }
- 原理:
putIfAbsent保证同一key仅存入一个FutureTask,后续线程复用该任务等待计算完成,避免重复执行。需注意处理计算异常的情况,必要时清理缓存中的失败任务。
模式三:使用Guava的LoadingCache(成熟缓存方案)
若项目依赖Guava,LoadingCache提供了开箱即用的线程安全缓存实现,支持自动加载、过期、刷新等多种策略:
private final LoadingCache<String, ExpensiveResult> cache = CacheBuilder.newBuilder() .maximumSize(1000) // 根据业务需求设置缓存容量 .build(new CacheLoader<String, ExpensiveResult>() { @Override public ExpensiveResult load(String key) { return computeExpensiveValue(key); } }); public ExpensiveResult get(String key) throws ExecutionException { return cache.get(key); }
关于替换普通HashMap后问题减少的原因
换成普通HashMap后,外部synchronized块相当于给整个哈希表加了全局锁,所有get和put操作都被串行化,缓存状态的可见性由synchronized的happens-before关系保证,因此问题减少。但这种方式性能极差,全局锁会完全阻塞高并发场景下的缓存操作,不可作为生产级方案。
内容的提问来源于stack exchange,提问作者Overclocked

