为什么这个并发缓存实现即使使用双重检查锁定,也有时会返回过时的或未完全初始化的值?

编程语言 2026-07-08

我正在开发一个高吞吐量的后端服务,我们在内存中维护一个计算得出的昂贵结果的缓存。该缓存必须延迟初始化,并且在高并发下要安全。

为了避免重复计算,我实现了经典的双重检查锁定模式:

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

问题

  1. 根据Java内存模型,这个实现真的线程安全吗,还是我误解了 synchronized 如何与 ConcurrentHashMap 的交互?
  2. 哪些微妙的并发问题可能解释:

  3. 在高负载下出现重复计算

  4. 即使使用了双重检查锁定,也偶尔出现部分可见的对象状态

  5. ConcurrentHashMap 与外部同步结合使用时,是否存在已知的陷阱?

  6. 对于这种高并发系统中的惰性初始化,什么才是正确的生产就绪模式

(这部分很怪)

当我把 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;
    }
}

解决方案

就发布值而言,你的代码在这方面是线程安全的。

我怀疑这并不是双重检查锁定的问题。

更可能的嫌疑是:

  1. computeExpensiveValue() 在对象构造完成之前就对外发布了这个值(即存在逃逸、后台线程、可变状态)。
  2. 可能存在多个ExpensiveCache实例。
  3. 其他代码路径也在修改/清理缓存。
  4. 你对键实现了equals() 和hashCode(),但实现方式并不合理。

此外,这会创建一个全局锁,导致延迟峰值。

synchronized(cache)

生产模式:

public Object get(String key) {
    return cache.computeIfAbsent(
        key,
        this::computeExpensiveValue
    );
}

ConcurrentHashMap.computeIfAbsent() 提供按键的原子性惰性初始化,并避免外部锁定。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章