AsyncCache:JVM 级 SingleFlight 实现原理与最佳实践
本文基于Caffeine
AsyncCache+Java 8+,适用于需要高并发下防止缓存击穿、避免重复加载的场景。
一、核心目标:同一 Key 只加载一次
在高并发场景下,缓存失效瞬间可能出现缓存击穿(Cache Breakdown):
大量线程同时发现缓存缺失,同时去 DB / RPC 加载同一份数据。
AsyncCache的目标就是:无论多少线程并发访问,同一个 Key 只触发一次加载逻辑。
二、核心实现原理
1. 原子性保障:ConcurrentHashMap.computeIfAbsent
AsyncCache底层依赖ConcurrentHashMap,核心逻辑等价于:
CompletableFuture<V>future=map.computeIfAbsent(key,k->{// ✅ 同一时刻,只有一个线程能进入此处CompletableFuture<V>f=newCompletableFuture<>();executor.execute(()->{try{Vvalue=loadFromDb(k);f.complete(value);}catch(Throwablet){f.completeExceptionally(t);map.remove(k,f);// 加载失败,允许重试}});returnf;});关键保证
| 特性 | 说明 |
|---|---|
| 原子性 | computeIfAbsent对同一 key 所在桶加锁,保证创建 Future 是原子操作 |
| 可见性 | Node.value和next用volatile修饰,Happens-Before 规则保证对其他线程立即可见 |
| 唯一性 | 同一 key 永远只创建一个CompletableFuture |
| 等待机制 | 未抢到锁的线程直接拿到已有 Future,自然等待结果 |
✅这就是 JVM 级的 SingleFlight 实现
2. Java 8 的并发控制:CAS +synchronized桶级锁
⚠️重要更正:Java 8 中
ConcurrentHashMap已废弃 JDK 7 的 Segment 分段锁(Striped Locking),改用CAS 无锁 +synchronized桶级锁的混合策略。
JDK 7 vs JDK 8 对比
| 维度 | JDK 7(已淘汰) | JDK 8+(当前主流) |
|---|---|---|
| 数据结构 | Segment[]+HashEntry[]+ 链表 | Node[]+ 链表 / 红黑树 |
| 锁机制 | ReentrantLock分段锁(Striped Locking) | CAS +synchronized桶级锁 |
| 锁粒度 | Segment 级别(默认 16 段) | 单个桶的头节点(并发度 = 数组长度) |
| 读操作 | volatile保证可见性 | volatile保证可见性(完全无锁) |
| 写操作 | 先获取 Segment 锁 | 先 CAS 尝试,失败再synchronized锁桶 |
| 扩容 | 单个 Segment 独立扩容 | 多线程协同扩容(ForwardingNode标记) |
| 哈希冲突退化 | 纯链表 O(n) | 链表 ≥ 8 且容量 ≥ 64 时转红黑树 O(log n) |
computeIfAbsent的执行流程(Java 8)
线程 T1、T2、T3 同时调用 computeIfAbsent("sameKey", ...) │ ▼ ① 计算 hash,定位到桶下标 i │ ▼ ② 桶为空?── 是 ──→ CAS 直接插入(无锁快路径) │ 否 ▼ ③ synchronized 锁住桶的头节点 │ ▼ ④ 再次检查 key 是否仍不存在(double check) │ ▼ ⑤ 只有一个线程执行 mappingFunction │ ▼ ⑥ 释放锁,其他线程拿到同一个 Future核心要点:
- CAS 优先:无竞争时完全无锁,性能极高
synchronized锁桶头节点:只有发生哈希冲突时才加锁,且锁粒度极小- 锁升级机制:JVM 会自动将
synchronized从偏向锁 → 轻量级锁 → 重量级锁逐步升级,绝大多数场景停留在轻量级锁阶段 - 不同桶之间完全无竞争:并发度约等于桶数组长度(默认 16,可随扩容增长)
为什么 Java 8 选择synchronized而非ReentrantLock?
| 对比项 | ReentrantLock(JDK 7) | synchronized(JDK 8) |
|---|---|---|
| 锁粒度 | Segment 级(较粗) | 桶头节点级(更细) |
| JVM 优化 | 无特殊优化 | 偏向锁、轻量级锁、自旋、锁消除、锁粗化 |
| 内存开销 | 每个 Segment 一个锁对象 | 锁信息内嵌在对象头中,零额外对象 |
| 可中断 | 支持lockInterruptibly() | 不支持(但缓存场景不需要) |
| 公平性 | 可配置 | 不可配置(但 FIFO 等待已足够) |
| 实际性能 | 较好 | 更优(尤其高并发 + 短临界区) |
结论:在
ConcurrentHashMap这种锁持有时间极短、不需要条件变量和中断特性的场景下,synchronized经过 JVM 优化后性能全面优于ReentrantLock,且零内存开销。
3. 自动清理与内存安全
- Future 完成后,引用由 GC 自动回收
- 加载失败时主动
remove(key),避免:- 空值缓存
- 永久阻塞
- 内存泄漏
三、推荐写法
✅ 基础用法(最推荐)
publicStringgetData(Stringkey){returnasyncCache.get(key,(k,exec)->CompletableFuture.supplyAsync(()->loadDataFromDb(k),exec)).join();}exec:Caffeine 内置ForkJoinPool,生产环境建议自定义线程池join():阻塞等待结果,适合非响应式服务
✅ 带超时保护(防止线程堆积)
publicStringgetDataWithTimeout(Stringkey){returnasyncCache.get(key,(k,exec)->CompletableFuture.supplyAsync(()->loadDataFromDb(k),exec).completeOnTimeout("fallback",2,TimeUnit.SECONDS)).join();}✅ 防止:
- DB 慢查询
- RPC 无限阻塞
- 线程池被打爆
✅ 防止缓存污染(加载失败不缓存)
publicStringgetDataSafe(Stringkey){returnasyncCache.get(key,(k,exec)->CompletableFuture.supplyAsync(()->loadDataFromDb(k),exec).exceptionally(ex->{asyncCache.synchronous().invalidate(k);thrownewRuntimeException(ex);})).join();}📌非常重要:否则失败结果会被缓存,导致后续请求全部失败。
五、原子更新 Value
如果你是想做CAS 风格更新:
cache.asMap().compute(key,(k,oldFuture)->{if(oldFuture==null){returnCompletableFuture.completedFuture("init");}returnoldFuture.thenApply(v->v+"_updated");});⚠️ 注意:
AsyncCache中 Value 是CompletableFuture- 更新成本较高,通常不建议频繁使用
六、并发测试验证
publicstaticvoidmain(String[]args){AsyncCache<String,String>cache=Caffeine.newBuilder().buildAsync();AtomicIntegerloadCount=newAtomicInteger(0);List<CompletableFuture<String>>futures=IntStream.range(0,10).parallel().mapToObj(i->cache.get("sameKey",(key,exec)->{System.out.println(Thread.currentThread().getName()+" loading...");loadCount.incrementAndGet();returnCompletableFuture.supplyAsync(()->"Data-"+key);})).toList();futures.get(0).whenComplete((v,ex)->{System.out.println("Result: "+v);});System.out.println("Load count = "+loadCount.get());// ✅ 永远是 1}典型输出
ForkJoinPool.commonPool-worker-1 loading... Result: Data-sameKey Load count = 1✅完美证明:仅一个线程执行加载逻辑
七、与 Redis 分布式锁对比
| 维度 | AsyncCache(JVM 级) | Redis Lock(分布式) |
|---|---|---|
| 作用范围 | 单 JVM 内 | 跨 JVM / 跨机器 |
| 锁机制 | CAS +synchronized桶级锁 | SETNX / Redlock |
| 性能 | ⭐⭐⭐⭐⭐(纳秒~微秒级) | ⭐⭐(毫秒级 + 网络 IO) |
| 复杂度 | 低(开箱即用) | 高(需处理超时、死锁、脑裂) |
| 网络 IO | 无 | 有(每次加锁至少 1 次 RTT) |
| 适用场景 | 单机本地缓存防击穿 | 分布式协调、跨服务互斥 |
✅结论:能使用AsyncCache解决的场景,不要用 Redis 锁。二者不是替代关系,而是互补——分布式场景仍需 Redis,单机高并发场景AsyncCache是更优解。
八、避坑指南
❌ 坑 1:在computeIfAbsent的 Lambda 中再次操作同一个 Map
// 错误示范:可能导致死锁map.computeIfAbsent("key",k->{map.put("otherKey",someValue);// ⚠️ Lambda 内可能持有桶锁,再次操作可能死锁returncomputeValue();});✅正确做法:Lambda 内只做纯计算,不涉及任何 Map 写操作。
❌ 坑 2:mappingFunction返回null
computeIfAbsent的mappingFunction不允许返回 null,否则抛出NullPointerException。
// 错误cache.get(key,k->null);// NPE!// 正确:返回包装类型或 Optionalcache.get(key,k->CompletableFuture.completedFuture(null));// OK[citation:6]
❌ 坑 3:mappingFunction执行时间过长
computeIfAbsent在执行 Lambda 时持有桶锁(虽然时间极短),如果 Lambda 内做耗时操作,会阻塞同一桶的其他操作。
✅正确做法:Lambda 内只创建CompletableFuture,实际计算交给异步线程:
// ✅ 推荐:Lambda 立即返回 Future,计算异步执行cache.get(key,(k,exec)->{returnCompletableFuture.supplyAsync(()->loadDataFromDb(k),exec);});九、总结
Caffeine
AsyncCache通过ConcurrentHashMap.computeIfAbsent实现了 JVM 级的 SingleFlight。在 Java 8 中,底层依赖 CAS 无锁 +synchronized桶级锁的混合策略,以极低的锁开销保证同一 Key 只加载一次。
核心要点速查
| 要点 | 一句话说明 |
|---|---|
| 原子性来源 | ConcurrentHashMap.computeIfAbsent(Java 8:CAS +synchronized桶级锁) |
| 锁粒度 | 单个桶的头节点,不同桶之间零竞争 |
| 无锁路径 | 桶为空时 CAS 直接插入,完全无锁 |
| 等待机制 | 所有线程共享同一个CompletableFuture |
| 失败处理 | 主动remove,允许重试 |
| 超时保护 | completeOnTimeout防止永久阻塞 |
| 分布式场景 | 仍需 Redis / DB 层协调 |
| 最大陷阱 | Lambda 内不要操作同一个 Map,不要返回 null |
十、参考与延伸阅读
- JDK 源码:
ConcurrentHashMap.computeIfAbsent()(JDK 8u60+) - Caffeine 官方文档:https://github.com/ben-manes/caffeine
- Java 8
synchronized锁升级机制:偏向锁 → 轻量级锁 → 重量级锁 CompletableFuture超时 API:Java 9+completeOnTimeout/ Java 8 GuavaFutures.withTimeout