You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何带双重检查锁的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

核心问题

  1. 该实现在Java内存模型(JMM)下是否真的线程安全?我是不是误解了synchronized和ConcurrentHashMap的交互方式?
  2. 哪些微妙的并发问题会导致:
    • 负载下的重复计算
    • 即便用了双重检查锁,仍偶尔出现对象状态部分可见的情况?
  3. 将ConcurrentHashMap与外部synchronized同步结合使用,有没有已知的陷阱?
  4. 高并发系统中,实现此类延迟初始化的正确生产级模式是什么?

异常现象

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 04:59:56