为什么这个并发缓存实现即使使用双重检查锁定,也有时会返回过时的或未完全初始化的值?
我正在开发一个高吞吐量的后端服务,我们在内存中维护一个计算得出的昂贵结果的缓存。该缓存必须延迟初始化,并且在高并发下要安全。
为了避免重复计算,我实现了经典的双重检查锁定模式:
import java.util.concurrent.ConcurrentHashMap;
public class ExpensiveCache {
private final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
private Object computeExpensiveValue(String key) {
// simulate expensive computation
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;
}
}
观察到的行为
在对大量线程进行压力测试时,我偶尔会观察到:
- 多个线程在同一个键上触发
computeExpensiveValue - 极少数情况下,某个线程检索到看起来尚未初始化的值(在实际实现中表现为默认状态或不完整的对象状态)
- 尽管加锁,延迟仍然会出现明显的峰值
- 缓存命中率与预期不符
private ExpensiveResult computeExpensiveValue(String key) { ExpensiveResult result = new ExpensiveResult("value-" + key);
metrics.incrementComputed(); // 外部副作用
return result; }
度量显示,在负载下computeExpensiveValue() 有时会对同一个键执行多次,即使我期望同步块能够阻止重复初始化。
我期望的情况
因为我在缓存上进行同步,并在同步块内部做第二次空值检查,所以我期望:
- 每个键只有一次计算
- 完全构造好的对象始终对其他线程可见
- 不会有重复初始化
我已经检查过的内容
- 缓存对象在构造后本身就是不可变的
- 就我所知,compute函数中没有显式的写操作重排序
- 其他地方没有手动的缓存驱逐逻辑
- JVM版本是17
问题
- 根据Java内存模型,这个实现真的线程安全吗,还是我误解了
synchronized如何与ConcurrentHashMap的交互? -
哪些微妙的并发问题可能解释:
-
在高负载下出现重复计算
-
即使使用了双重检查锁定,也偶尔出现部分可见的对象状态
-
将
ConcurrentHashMap与外部同步结合使用时,是否存在已知的陷阱? - 对于这种高并发系统中的惰性初始化,什么才是正确的生产就绪模式?
(这部分很怪)
当我把 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) {
// simulate expensive computation
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;
}
}
解决方案
就发布值而言,你的代码在这方面是线程安全的。
我怀疑这并不是双重检查锁定的问题。
更可能的嫌疑是:
- computeExpensiveValue() 在对象构造完成之前就对外发布了这个值(即存在逃逸、后台线程、可变状态)。
- 可能存在多个ExpensiveCache实例。
- 其他代码路径也在修改/清理缓存。
- 你对键实现了equals() 和hashCode(),但实现方式并不合理。
此外,这会创建一个全局锁,导致延迟峰值。
synchronized(cache)
生产模式:
public Object get(String key) {
return cache.computeIfAbsent(
key,
this::computeExpensiveValue
);
}
ConcurrentHashMap.computeIfAbsent() 提供按键的原子性惰性初始化,并避免外部锁定。
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。