【缓存】本地缓存Caffeine AsyncCache实现原理与最佳实践
2026/8/4 19:08:12 网站建设 项目流程

AsyncCache:JVM 级 SingleFlight 实现原理与最佳实践

本文基于CaffeineAsyncCache+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.valuenextvolatile修饰,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

核心要点

  1. CAS 优先:无竞争时完全无锁,性能极高
  2. synchronized锁桶头节点:只有发生哈希冲突时才加锁,且锁粒度极小
  3. 锁升级机制:JVM 会自动将synchronized从偏向锁 → 轻量级锁 → 重量级锁逐步升级,绝大多数场景停留在轻量级锁阶段
  4. 不同桶之间完全无竞争:并发度约等于桶数组长度(默认 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

computeIfAbsentmappingFunction不允许返回 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);});

九、总结

CaffeineAsyncCache通过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 8synchronized锁升级机制:偏向锁 → 轻量级锁 → 重量级锁
  • CompletableFuture超时 API:Java 9+completeOnTimeout/ Java 8 GuavaFutures.withTimeout

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询