Java Caffeine 快速入门
一、前言在高并发读写的场景下基础的ConcurrentHashMap可以保证并发安全但是过期策略、淘汰策略都需要手动实现Guava Cache由此诞生但是身为“智能缓存”的Guava Cache本身不够“智能”。因为LRU缓存在少数场景下可能无法保证高缓存命中率因此高并发场景下其依旧存在瓶颈。这也是Caffeine产生的原因继承 Guava Cache 的优雅 API 同时在性能和命中率上实现质的飞跃。二、和Guava Cache的对比Caffeine和Guava Cache都是Java本地缓存的优秀实现但是 Caffeine 在算法、并发模型和性能上做了全面革新可以理解为 Guava Cache 的现代化升级版。功能Guava CacheCaffeine过期策略expireAfterWrite / expireAfterAccess同左 支持 per-entry 动态过期异步加载不支持✅ AsyncLoadingCache返回 CompletableFuture异步刷新不支持✅ refreshAfterWrite后台刷新访问返回旧值移除监听✅ RemovalListener✅ RemovalListener统计监控✅ recordStats✅ recordStats弱/软引用✅ weakKeys / softValues✅ weakKeys / softValues权重控制✅ maximumWeight✅ maximumWeight weigherSpring 集成需手动适配✅ 默认本地缓存实现Cacheable 原生支持Caffeine 继承了 Guava Cache 的优雅 API但在算法、并发、性能上做了全面革新是当前 Java 本地缓存的最优解。三、基础使用Caffeine的API设计非常简单结合Builder模式即可实现两种缓存的实现分别是手动加载以及自动加载模式1. 手动加载CacheString, User cache Caffeine.newBuilder() .maximumSize(10_000) // 最大条目数 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期 .build(); cache.put(user1, new User(1, 张三)); User user cache.getIfPresent(user1); // 不存在时通过函数加载 User user2 cache.get(user2, key - userDao.findById(key));2. 自动加载LoadingCacheString, User loadingCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterAccess(10, TimeUnit.MINUTES) .build(key - userDao.findById(key)); User user loadingCache.get(user1); // 自动触发加载3. Builder API介绍3.1 容量控制方法参数说明maximumSize(long)最大条目数缓存最多容纳的 key-value 对数量超出后按淘汰策略驱逐maximumWeight(long)最大总权重按权重限制缓存总容量需配合weigher使用weigher(WeigherK,V)权重计算函数自定义每个条目的权重如(k, v) - v.size()注意maximumSize与maximumWeight互斥不可同时使用。3.2 过期策略方法参数说明expireAfterWrite(long, TimeUnit)时长 时间单位条目写入后经过指定时间过期expireAfterAccess(long, TimeUnit)时长 时间单位条目最后一次读/写后经过指定时间过期expireAfter(ExpiryK,V)自定义过期策略可针对每个条目动态计算过期时间纳秒级精度expireAfterWrite和expireAfterAccess可组合使用取先到者。3.3 刷新策略方法参数说明refreshAfterWrite(long, TimeUnit)时长 时间单位条目写入后经过指定时间标记为需刷新下次访问时异步重新加载仅LoadingCache有效3.4 淘汰监听方法参数说明removalListener(RemovalListenerK,V)淘汰回调函数条目被驱逐/过期/手动移除时触发可获取 key、value 和淘汰原因evictionListener(RemovalListenerK,V)驱逐回调函数仅在因容量/权重限制被驱逐时触发手动移除和过期不触发3.5 统计与监控方法参数说明recordStats()无开启统计之后通过cache.stats()获取命中率、加载耗时、驱逐次数等3.6 弱引用 / 软引用方法参数说明weakKeys()无key 使用弱引用GC 可回收不再被外部引用的 keyweakValues()无value 使用弱引用GC 可回收不再被外部引用的 valuesoftValues()无value 使用软引用内存不足时 GC 可回收weakValues()与softValues()互斥。3.7 异步支持方法参数说明executor(Executor)线程池指定用于异步加载/刷新的线程池默认使用ForkJoinPool.commonPool()scheduler(Scheduler)调度器指定用于定时过期检查的调度器默认使用系统调度器3.8 构建方式方法参数说明build()无构建基本Cache需手动加载数据build(CacheLoaderK,V)加载函数构建LoadingCache缓存未命中时自动调用加载函数buildAsync()无构建异步AsyncCachevalue 为CompletableFuturebuildAsync(AsyncCacheLoaderK,V)异步加载函数构建异步AsyncLoadingCache典型组合示例CacheString, User cache Caffeine.newBuilder() .maximumSize(10_000) // 容量 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入过期 .refreshAfterWrite(1, TimeUnit.MINUTES) // 1分钟后异步刷新 .removalListener((key, value, cause) - // 淘汰监听 log.info(移除: {} 原因: {}, key, cause)) .recordStats() // 开启统计 .build(key - userDao.findById(key)); // 必须提供加载函数四、性能测量1. 无缓存淘汰场景这里我们通过JMH的BenchMark来针对读、写、读写混合场景对HashMap、ConcurrentHashMap、Guava、Caffeine分别进行性能基准测试1.1 单线程测试场景实现方式平均吞吐量 (ops/s)纯读 (pureRead)CONCURRENT_HASH_MAP992,320,631CAFFEINE153,815,895GUAVA16,921,348纯写 (pureWrite)CONCURRENT_HASH_MAP121,049,344CAFFEINE59,932,851GUAVA5,058,945读写混合 (readWriteMixed)CONCURRENT_HASH_MAP243,496,179CAFFEINE88,119,973GUAVA6,983,7111.2 多线程8线程测试场景实现方式平均吞吐量 (ops/s)纯读 (pureRead)HASH_MAP184,512,874CONCURRENT_HASH_MAP171,627,474CAFFEINE33,218,512GUAVA22,276,631纯写 (pureWrite)HASH_MAP119,833,818CONCURRENT_HASH_MAP55,684,573CAFFEINE21,163,099GUAVA16,011,427读写混合 (readWriteMixed)HASH_MAP100,403,857CONCURRENT_HASH_MAP84,025,325CAFFEINE23,610,020GUAVA14,474,005总结无并发场景 (单线程)HashMap性能最佳作为非线程安全的实现HashMap在所有单线程测试中均表现出最高的吞吐量是性能基准的上限。ConcurrentHashMap紧随其后性能非常接近HashMap在读写混合场景中达到了HashMap约 84% 的性能展现了极高的效率。Caffeine和Guava有额外开销两者作为功能更丰富的缓存库在单线程下的性能明显低于前两者。其中Caffeine的性能约为ConcurrentHashMap的 1/5 到 1/2而Guava则更慢一些。高并发场景 (8线程)ConcurrentHashMap吞吐量最高在所有并发测试中ConcurrentHashMap的绝对吞吐量均大幅领先尤其在纯读场景下性能是第二名Caffeine的6倍以上。Caffeine表现稳健在并发场景下Caffeine的性能显著优于Guava吞吐量大约是Guava的9到12倍是功能型缓存库中的更优选择。Guava性能相对落后在本次测试的所有并发场景中Guava的吞吐量均为最低。2. 缓存淘汰策略这个批次中我们通过1000、5000、10000容量测试并发场景下Caffeine、Guava和ConcurrentHashMap的读、写、混合吞吐量2.1 单线程读写混合容量策略CAFFEINEGUAVACONCURRENT_HASH_MAP1,000SIZE_ONLY2,186.2万2,271.8万4,082.8万WRITE_TTL1,805.5万1,461.2万2,636.4万ACCESS_TTL2,323.8万1,674.8万3,984.0万5,000SIZE_ONLY2,265.8万1,727.6万2,621.8万WRITE_TTL1,570.7万1,215.0万3,881.5万ACCESS_TTL1,641.6万1,313.5万2,813.6万10,000SIZE_ONLY1,462.8万1,301.3万2,606.4万WRITE_TTL1,027.5万917.5万3,979.9万ACCESS_TTL1,096.2万983.1万3,983.9万2.2 多线程8线程读写混合容量策略CAFFEINEGUAVACONCURRENT_HASH_MAP1,000SIZE_ONLY2,547.9万1,033.8万1.5亿WRITE_TTL1,804.2万792.7万8,499.7万ACCESS_TTL2,080.6万888.0万8,338.9万5,000SIZE_ONLY2,971.3万805.1万1.2亿WRITE_TTL2,175.3万656.5万1.0亿ACCESS_TTL3,225.2万722.6万7,543.1万10,000SIZE_ONLY3,422.0万658.5万8,170.6万WRITE_TTL2,628.2万590.2万9,315.9万ACCESS_TTL3,447.8万614.6万8,222.2万3. 性能对比ConcurrentHashMap是吞吐量之王但缺乏淘汰、过期等缓存管理能力数据会无限堆积导致 OOM。Caffeine是功能型缓存的最优解性能约为 Guava 的 3~10 倍且 W-TinyLFU 算法在命中率上显著优于 Guava 的 LRU。Guava Cache已全面落后Spring Boot 2.x 起已默认使用 Caffeine 替代新项目应直接选用 Caffeine。五、W-TinyLFU 算法原理1. 简介作为Caffeine的核心淘汰算法W-TinyLFU的策略非常切合真实的生产环境其核心分为三个区域Window 区、Probation 区、Protected 区。通过Count-Min Sketch频率素描数据统计策略实现冷热数据在三者之间流转避免了缓存污染、数据冷启动等问题大幅度提升了缓存命中率2. Window区窗口缓存区该区域约占总容量的1%专门接纳新写入或者新访问的数据其采用LRU策略管理缓存满额时将淘汰候选数据到主缓存区。这个设计能有效防止突发冷数据比如遍历数据污染主缓存同时维护刚刚上线的新热点数据3. Main Cache主缓存区1. Probation 区试探区接收从窗口区淘汰过来的候选数据属于待观察区域采用 LRU 管理。2.Protected 区保护区存储高频热点数据约占主缓存的 80%。只有Probation区中访问频率达标的数据才能晋升至此。4.Count-Min Sketch频率素描4.1 简单介绍这是针对key的访问频次统计算法传统的key访问频次统计是对所有key分别进行统计其可以确保100%精确但是其空间复杂度为O(n), 在百万级别key下其内存占用将会到达几十MB甚至上百MB该算法则通过二维数组多个独立的哈希函数其空间复杂度接近O(1)精确度与二维数组大小呈现正相关来确保精确度的前提下最大化节省空间占用。百万级别key内存占用情况大幅度降低。4.2 核心机制1.二维数组简单来说就是固定大小的矩阵每一行作为一个整体看待当访问到某个key的时候通过hash函数将某个key映射到对应下标中并1。2.哈希函数二维数组有n行对应哈希函数就有n个每个哈希函数互相独立为的就是让同一个key在每一行散落到不同的下标中3.取最小值计算获取某个key的访问频率时将会获取每一行的频次统计取最小值来作为统计结果。4.3 为什么这样设计因为哈希冲突所以某个下标可能同时统计多个key误差较大也就是说统计频次一定 实际频次而通过多行交叉验证取最小值就可以获取到最接近实际频次的数据。4.4 Caffeine 的工程优化除此之外Caffeine还针对Count-Min Sketch进行了大量的优化在性能、内存占用、缓存命中率上进一步拔高。4bit 计数器该算法统计频率至少占 4 字节intCaffeine每个单元格压缩到 4bit最大值仅有 15。配合频率衰减机制定期将所有计数器 ÷ 2避免数据超过最大值影响准确性百万级 key 下内存开销进一步降低。原先的“看频率”变为了“看趋势”频率衰减机制定期将所有计数器右移一位÷2使历史频率逐渐衰减一方面是避免老数据的累计频率统计过高长期霸占缓存另一方面是避免4 bit 计数器超过最大值。Doorkeeper 优化先通过布隆过滤器前置过滤首次访问的 key 因为没加入白名单中所以不参与计数只有二次访问才计入有效过滤大量一次性访问的冷数据比如爬虫恶意扫描数据偶然数据访问。自适应窗口大小Window 区与 Main 区的比例并非固定 1:99而是通过爬山算法hill climbing根据实际工作负载动态调整。偏向时间局部性时窗口更大偏向频率时窗口更小。快速处理模式当缓存大小未超过总容量的 50% 且驱逐策略未触发时Sketch 不会初始化以减小内存开销访问也不会被记录最大化访问性能。异步驱逐驱逐操作通过后台线程异步执行不影响主路径的读写性能保证 O(1) 时间复杂度的驱逐操作。5. 小节W-TinyLFU的设计本质是在缓存命中率、内存开销、并发性能三者之间取得最优平衡。它没有追求任何单一指标的极致而是通过分层架构将复杂问题拆解为多个子问题再用最合适的方案逐一解决。五、多数据源下的缓存一致性1. 前言在生产环境中我们经常会用到缓存但是当引入缓存后如何保证数据库与缓存之间的数据一致性就成为了一个难点。本章节我们将通过demo演示如何保障Caffeine与数据库之间的数据一致性。2. 背景多JVM实例每个实例内部维护一份Caffeine缓存需要保证数据库 —— Caffeine之间的数据一致性。我们追求数据的最终一致性且保障数据全链路的可用性。同时注意版本并发控制。3. 实现3.1 依赖引入dependencies !-- Web 场景提供内嵌 Tomcat、Spring MVC支撑 REST 接口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Boot 核心 starter提供自动配置与基础能力 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency !-- 测试场景JUnit 5 / Mockito / AssertJ 等测试框架 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency !-- Lombok编译期自动生成 getter/setter、构造器等样板代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency !-- MyBatis-PlusSpring Boot 3 适配版简化单表 CRUD免写 SQL -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.16/version /dependency !-- MySQL 8 驱动数据库连接 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency !-- Caffeine高性能本地缓存承载字典数据的一级缓存 -- dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency !-- AMQP / RabbitMQ字典变更时广播缓存刷新消息保证多节点一致性 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency !-- AOP支持自定义注解 RefreshDict 切面实现字典缓存刷新 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency /dependencies3.2 建表语句以字典表为例CREATE TABLE sys_dict ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 父级ID0表示顶级节点, type VARCHAR(50) NOT NULL COMMENT 字典类型如 user_status、order_source, level TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 层级深度根节点为1, name VARCHAR(100) NOT NULL COMMENT 显示名称, code VARCHAR(100) NOT NULL COMMENT 业务编码, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 乐观锁版本号, deleted BIGINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0正常删除时写入id值, PRIMARY KEY (id), UNIQUE KEY uk_type_code (type, code, deleted), KEY idx_parent (parent_id, deleted), KEY idx_type (type, deleted) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 字典表支持父子分类;3.3 核心Map/** * 单次引用替换发布完整快照volatile 保证新快照及其已构造数据对读线程可见。 */ private volatile DictCacheSnapshot snapshot; /** 一次发布的缓存版本与数据集合。Map 结构不可变但 {link DictDo} 不是深度不可变对象。 */ private record DictCacheSnapshot(long version, MapLong, DictDo data) { }3.4 缓存实现类构建/** * 基于版本快照的字典本地缓存。 * p新快照在内存中完整构造后才替换 {link #snapshot}因此数据库读取失败时旧快照仍可继续提供服务。/p * * author 王玉涛 * version 1.0 * since 2026/8/20 */ Slf4j Service RequiredArgsConstructor public class DictCacheServiceImpl implements DictCacheService { /** * 重建失败时额外重试一次。运行期最终失败会保留旧快照避免短暂数据库故障直接影响读服务。 */ private static final RetryTemplate CACHE_REBUILD_RETRY RetryTemplate.builder() .maxAttempts(2) .fixedBackoff(1) .retryOn(Exception.class) .build(); private final DictMapper dictMapper; private final DictCachePublisher dictCachePublisher; /** * 仅在当前 JVM 内串行化“查版本、构建、发布”流程避免并发通知触发重复全量加载。 * lt;pgt;读路径不获取该锁跨节点的一致性由广播消息和版本比较保证。lt;/pgt; */ private final Object rebuildLock new Object(); /** * 单次引用替换发布完整快照volatile 保证新快照及其已构造数据对读线程可见。 */ private volatile DictCacheSnapshot snapshot; /** * 在应用启动时构建首个快照。 * lt;pgt;首次构建没有可降级的旧数据失败时抛出异常以阻止应用以空缓存提供服务。lt;/pgt; */ Override PostConstruct public void initializeCache() { rebuildCache(true); log.info(字典缓存初始化完成, version{}, snapshot.version()); } /** * 返回一次 volatile 读取取得的一致性快照视图不会因并发重建而阻塞或中断遍历。 * lt;pgt;快照结构不可变但 {link DictDo} 本身仍可变调用方不得修改返回元素。lt;/pgt; */ Override public Collectionlt;DictDogt; getAll() { DictCacheSnapshot currentSnapshot snapshot; if (currentSnapshot null) { throw new IllegalStateException(字典缓存尚未完成初始化); } return currentSnapshot.data().values(); } /** * 根据数据库实际版本决定是否重建。通知中的版本只用于诊断不能作为缓存正确性的依据。 * lt;pgt;运行期重建失败时保留旧快照继续服务等待后续通知或下一次重建恢复。lt;/pgt; * * param notifiedVersion 广播消息携带的版本号仅用于日志追踪 */ Override public void refreshCache(long notifiedVersion) { log.debug(开始处理字典缓存刷新通知, notifiedVersion{}, notifiedVersion); rebuildCache(false); } /** * 查询提交后数据库的最新版本并广播刷新通知。 * lt;pgt;查询或发布失败会向调用方传播已提交的字典变更不会因此回滚。lt;/pgt; */ Override public void publishCacheRefresh() { dictCachePublisher.broadcastCacheRefresh(queryLatestVersion()); } /** * 以有限重试执行重建。启动阶段没有旧快照时采用 fail-fast运行阶段采用 fail-soft保留旧快照。 * * param failOnEmptySnapshot 是否在尚无快照时将最终失败向上抛出 */ private void rebuildCache(boolean failOnEmptySnapshot) { try { CACHE_REBUILD_RETRY.execute((RetryCallbacklt;Void, Exceptiongt;) context -gt; { rebuildIfVersionBehind(); return null; }); } catch (Exception e) { if (failOnEmptySnapshot amp;amp; snapshot null) { throw new IllegalStateException(首次构建字典缓存失败应用停止启动, e); } log.error(字典缓存重建失败保留当前快照, version{}, currentVersion(), e); } } /** * 在单 JVM 内按版本重建同版本跳过、数据库版本落后时拒绝回退、更高版本才发布新快照。 * lt;pgt;全量数据在锁内完成构建防止并发刷新重复加载发布仅是一次 volatile 引用替换。lt;/pgt; */ private void rebuildIfVersionBehind() { synchronized (rebuildLock) { long databaseVersion queryLatestVersion(); DictCacheSnapshot currentSnapshot snapshot; long localVersion currentSnapshot null ? -1L : currentSnapshot.version(); if (currentSnapshot ! null amp;amp; databaseVersion localVersion) { log.debug(字典缓存已是数据库最新版本, version{}, localVersion); return; } if (databaseVersion lt; localVersion) { throw new CacheRebuildException(数据库版本落后本地快照, databaseVersion%d, localVersion%d .formatted(databaseVersion, localVersion)); } DictCacheSnapshot newSnapshot buildSnapshot(databaseVersion); if (currentSnapshot null || newSnapshot.version() gt; currentSnapshot.version()) { snapshot newSnapshot; log.info(字典缓存快照已原子替换, version{}, size{}, newSnapshot.version(), newSnapshot.data().size()); } } } /** * 由本次数据库读取构造独立快照。重复主键仅保留首次记录避免异常数据破坏缓存结构。 * lt;pgt;版本查询与全量读取不是同一事务若其间发生写入后续刷新会依据新版本再次收敛。lt;/pgt; */ private DictCacheSnapshot buildSnapshot(long version) { Listlt;DictDogt; dictList dictMapper.selectList(new LambdaQueryWrapperlt;gt;()); Maplt;Long, DictDogt; data new LinkedHashMaplt;gt;(); for (DictDo dict : dictList) { data.putIfAbsent(dict.getId(), dict); } return new DictCacheSnapshot(version, Collections.unmodifiableMap(data)); } /** * 空表或空版本统一视为初始版本 {code 0}以便首次构建空快照。 */ private long queryLatestVersion() { DictDo latest dictMapper.selectLatestVersion(); return latest null || latest.getVersion() null ? 0L : latest.getVersion().longValue(); } private long currentVersion() { DictCacheSnapshot currentSnapshot snapshot; return currentSnapshot null ? -1L : currentSnapshot.version(); } /** * 一次发布的缓存版本与数据集合。Map 结构不可变但 {link DictDo} 不是深度不可变对象。 */ private record DictCacheSnapshot(long version, Maplt;Long, DictDogt; data) { } private static class CacheRebuildException extends RuntimeException { private CacheRebuildException(String message) { super(message); } } }4. 流程讲述1.缓存预热应用启动过程中会自动从数据库中拉取全量数据并构建对应缓存2.缓存刷新被RefreshDict标注的方法在事务结束后会直接发送对应的消息通知3.缓存重建消费者接受到消息后会对比版本号如果版本号落后会从数据库中拉取数据重建缓存快照然后原子性替换引用避免putAll过程中新旧缓存同时存在的问题4.容灾兜底缓存重建过程中会通过Retry机制快速重试避免网络抖动问题同时当数据库完全不可用时会跳过缓存重建暂时使用旧缓存确保服务基础可用5.消息可靠性针对消息队列的可靠性方案可以通过本地消息表 生产者确认 消费者重试机制 死信队列兜底 消息幂等性检查等方案来保障消息不丢失可追溯。六、总结本文从Caffeine 与 Guava Cache的对比出发梳理了 Caffeine 的基础使用方式、Builder API 配置能力并通过 JMH 基准测试验证了它在不同并发与淘汰场景下的表现最后深入拆解了 W-TinyLFU 算法的核心机制以及多数据源环境下基于版本快照的缓存一致性方案。可以看到ConcurrentHashMap 虽然吞吐量极高但它本质上只是一个并发容器缺少过期、淘汰、刷新等缓存治理能力Guava Cache 功能相对完善但 LRU 策略和并发模型已经难以满足高命中率、高吞吐量的要求。Caffeine 通过 W-TinyLFU 算法在命中率、内存开销和并发性能之间取得平衡同时又保留了接近 Guava 的简洁 API是当前 Java 技术栈中本地缓存的首选。在实际选型时可以遵循以下原则只做并发访问且不需要缓存治理时直接使用ConcurrentHashMap需要过期、淘汰、刷新、统计等能力时优先选用 Caffeine涉及多 JVM 实例与数据库缓存一致性时在 Caffeine 基础上叠加版本快照、不可变引用替换和消息广播机制追求最终一致性。最后需要强调的是缓存并不是银弹。引入缓存前应先明确热点数据特征、访问模式和可接受的一致性窗口生产环境中还要结合监控指标持续观察命中率、驱逐频率和内存占用才能使 Caffeine 真正发挥出它在算法和工程上的双重优势。