臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

相关基础:HashMap、ConcurrentHashMap

一、💡 一句话理解

核心结论

Caffeine 是 Java 的第三方本地内存缓存库。它把经常使用的数据暂存在当前 Java 进程的内存中,让后续请求少查数据库或少调用远程接口。

Caffeine 不是 Java 基础语法、不是 JDK 自带功能,也不是完整框架。它属于 Java 生态中的缓存工具库。

Java 版本需要注意:

Java 版本Caffeine 版本
Java 8Caffeine 2.x,常用最终版本为 2.9.3
Java 11 及以上Caffeine 3.x

二、🧭 理论:它是什么

2.1 从一个用户查询场景开始

假设一个接口需要根据用户 ID 查询用户姓名:

GET /users/1

如果每次请求都查询数据库,相同数据会被反复读取:

请求 1 ──> 数据库
请求 2 ──> 数据库
请求 3 ──> 数据库

加入 Caffeine 后,第一次请求查询数据库并把结果放进缓存。后续请求可以直接从内存取值:

由于读取内存通常比访问数据库或远程服务快,所以接口响应会更快,下游系统的压力也会更小。

2.2 场景与真实概念的对应关系

场景角色真实技术对象
用户 ID缓存的 Key
用户姓名或用户对象缓存的 Value
内存中的临时记录Caffeine 缓存条目(Entry)
找到了记录缓存命中(Hit)
没找到记录缓存未命中(Miss)
再去查询数据库数据加载(Load)
太久不用或数量超限后清走过期或淘汰(Expiration / Eviction)

这个类比的边界是:Caffeine 不只是一个普通 Map。它还提供容量限制、过期、自动加载、异步处理、统计等缓存能力。

2.3 Caffeine 和 Java 基础的关系

Caffeine 的使用建立在 Java 基础之上:

  • 泛型:通过 Cache<K, V> 指定 Key 和 Value 的类型。
  • Lambda:缓存没有数据时,可以用 Lambda 加载数据。
  • 并发:缓存通常会被多个线程同时访问。
  • 集合:使用体验类似 Map,但语义和能力更适合缓存。

因此,学习顺序可以是:

HashMap → ConcurrentHashMap → 缓存基本概念 → Caffeine

三、⚙️ 理论:它是怎么工作的

3.1 先查缓存,未命中时再加载

Caffeine 最核心的流程是:

读取 Key
  ↓
缓存命中?── 是 ──> 返回缓存值
  │
  否
  ↓
执行加载逻辑,例如查询数据库
  ↓
把结果写入缓存
  ↓
返回结果

记忆口诀:先查缓存,没中再查库;查完放缓存,下次直接取。

3.2 三种常见缓存类型

类型特点适用场景
Cache<K, V>手动缓存,应用自己决定如何加载数据加载逻辑因业务而异,需要灵活控制
LoadingCache<K, V>创建缓存时统一指定加载函数每个 Key 的加载方式相同
AsyncCache<K, V> / AsyncLoadingCache<K, V>异步加载,返回 CompletableFuture加载过程耗时,且业务适合异步处理

初学时先掌握 Cache<K, V> 即可。

3.3 容量限制与淘汰

缓存不能无限占用内存。maximumSize(1000) 表示缓存最多保留大约 1000 条记录;达到容量上限后,Caffeine 会根据访问情况淘汰价值较低的条目。

Cache<String, String> cache = Caffeine.newBuilder()
        // maximumSize:限制缓存条目数量,防止缓存无限增长。
        .maximumSize(1_000)
        .build();

不要遗漏容量限制

本地缓存使用的是 JVM 内存。如果缓存无限增长,可能增加垃圾回收压力,严重时可能导致 OutOfMemoryError。

3.4 过期策略

常用时间策略有两种:

策略含义例子
expireAfterWrite写入或更新一段时间后过期商品信息写入 10 分钟后重新加载
expireAfterAccess最后一次访问一段时间后过期30 分钟没访问的会话数据被清理
Cache<String, String> cache = Caffeine.newBuilder()
        // 写入 10 分钟后,该条目会被视为过期。
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(1_000)
        .build();

过期时间不是“数据一定正确”的保证。它只决定缓存多久重新加载一次。数据能否接受短时间不一致,需要根据业务判断。

3.5 命中、未命中与统计

开启 recordStats() 后,可以查看缓存命中次数、未命中次数和命中率:

Cache<String, String> cache = Caffeine.newBuilder()
        // recordStats:开启缓存运行指标统计;不开启时不会记录这些指标。
        .recordStats()
        .maximumSize(1_000)
        .build();
 
System.out.println(cache.stats().hitCount());
System.out.println(cache.stats().missCount());
System.out.println(cache.stats().hitRate());

命中率高不一定代表设计合理。例如缓存了过期很久的数据,命中率可能很高,但业务结果是错误的。指标必须结合数据新鲜度、内存占用和数据库压力一起判断。

3.6 Caffeine、ConcurrentHashMap 和 Redis 的区别

对比项ConcurrentHashMapCaffeineRedis
定位Java 并发集合Java 本地缓存库独立的远程数据服务
数据位置当前 JVM 内存当前 JVM 内存Redis 服务端内存
多台应用共享不支持不支持支持
过期和淘汰需要自己实现内置丰富策略内置丰富策略
应用重启后保留不保留不保留取决于 Redis 持久化配置
网络开销无无有

使用边界

单体应用、单个实例或允许各实例分别缓存时,Caffeine 很合适。多台服务必须共享同一份缓存数据时,通常考虑 Redis;也可以使用 Caffeine + Redis 组成多级缓存,但一致性处理会更复杂。

3.7 多台服务器怎样让缓存保持一致

3.7.1 “不一致”到底是什么

假设系统部署了应用 A 和应用 B,每台应用都有自己的 Caffeine:

数据库:张三的手机号是 138...
应用 A 的 Caffeine:138...
应用 B 的 Caffeine:旧号码 139...

同一个用户请求应用 A 和应用 B,却得到不同结果,这就是缓存不一致。

它发生的原因很简单:两台应用各自拥有一块独立内存,修改其中一份,另一份不会自动变化。

3.7.2 只使用 Redis:所有实例读取同一份缓存

最容易理解的办法,是不在每台应用中保存业务缓存,而是让所有应用都访问同一个 Redis:

这样应用 A 修改或删除 Redis 中的 user:1 后,应用 B 下次读取的也是同一个 Redis Key,不会继续读取自己进程中的旧副本。

但是,**共享 Redis 只解决了“应用实例之间不共享内存”的问题,不会自动保证 Redis 与数据库一致。**数据库和 Redis 仍然是两个不同的存储位置。

常用的 Cache Aside(旁路缓存)流程是:

读取:先读 Redis
      ├─ 命中:直接返回
      └─ 未命中:查数据库 → 写入 Redis → 返回
 
修改:先更新数据库 → 再删除 Redis 中的旧缓存

为什么修改时通常选择“删除缓存”,而不是直接“更新缓存”?因为删除后,下一次读取会以数据库为准重新构建缓存,可以减少并发更新时旧值覆盖新值的风险。

Redis 也不等于绝对强一致

在“数据库更新成功、Redis 删除尚未完成”的短暂窗口内,其他请求仍可能读到旧缓存。如果删除失败,旧数据还可能保留更久。因此通常还要设置 TTL,并对删除失败进行重试或补偿。

3.7.3 Caffeine + Redis:为什么还需要失效通知

为了减少访问 Redis 的网络开销,可以把缓存分成两层:

层级技术数据位置速度
一级缓存(L1)Caffeine每台应用自己的 JVM最快,无网络请求
二级缓存(L2)Redis所有应用共享的 Redis较快,需要网络请求
最终数据源数据库数据库服务通常最慢

读取顺序是:

Caffeine → Redis → 数据库

问题在于:Redis 中的数据即使已经删除,应用 B 的 Caffeine 里仍可能保留旧值。此时需要“失效通知”:

这里的“通知”并不是把新数据复制给所有机器,而是告诉它们:

你本机保存的 user:1 可能已经过期,请删掉,下次重新查询。

3.7.4 为什么有通知还要设置 TTL

Redis Pub/Sub 可以广播失效消息,但它采用 at-most-once(最多一次) 投递:订阅者如果断线或正在重启,消息不会保存下来供它恢复后重新消费。

例如:

应用 B 重启中
    ↓
应用 A 发布 user:1 失效消息
    ↓
应用 B 没收到
    ↓
如果旧缓存没有 TTL,就可能一直保留

因此,多级缓存通常同时使用以下保护:

  1. 失效通知:正常情况下尽快删除各节点的本地缓存。
  2. 较短的本地 TTL:即使漏掉通知,旧值也会在限定时间后失效。
  3. 删除失败重试或补偿:避免数据库更新成功但 Redis 删除失败。
  4. 可持久化消息:不能接受消息丢失时,使用 Redis Streams、MQ 或数据库变更订阅,而不是只依赖 Pub/Sub。

这是一种“通知负责快,TTL 负责兜底”的设计。

3.7.5 最终一致性和强一致性

一致性目标含义常见做法
最终一致性短时间内可能读到旧数据,但最终会恢复一致删除缓存、失效通知、TTL、重试补偿
强一致性更新成功后,任何读取都必须立即得到最新结果关键读取直接访问权威数据源,或使用事务、锁等更严格方案

大部分用户资料、商品介绍和字典数据可以接受几秒钟的最终一致性。余额、库存扣减、支付状态等关键判断则不能只相信缓存,数据库或其他权威数据源才是最终依据。

记忆句

Redis 解决“大家读同一份”,失效通知解决“本地副本赶紧删”,TTL 解决“通知漏了也能恢复”;它们通常实现的是最终一致,而不是绝对的强一致。

四、🚀 实践:从准备到验证

4.1 前置准备

本例使用:

  • JDK 8
  • Maven 3.6 或更高版本
  • Caffeine 2.9.3

项目结构:

caffeine-demo/
├── pom.xml
└── src/main/java/com/example/CaffeineDemo.java

4.1.1 Maven 依赖

文件位置:caffeine-demo/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <groupId>com.example</groupId>
    <artifactId>caffeine-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
 
    <properties>
        <!-- 本例使用 Java 8 编译。 -->
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
 
    <dependencies>
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <!-- Caffeine 2.x 支持 Java 8;3.x 要求 Java 11 及以上。 -->
            <version>2.9.3</version>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <mainClass>com.example.CaffeineDemo</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

4.2 可以拿来干什么

4.2.1 缓存数据库查询结果

假设 queryUserFromDatabase 是一个耗时的数据库查询。cache.get 会先查缓存:命中就直接返回;未命中才执行加载函数。

String user = cache.get("user:1", key -> queryUserFromDatabase(key));
  • 输入:缓存 Key user:1。
  • 过程:先查缓存,未命中才执行数据库查询。
  • 输出:用户数据,同时将查询结果写入缓存。
  • 适用场景:用户资料、字典数据、配置数据等读多写少的数据。

4.2.2 主动删除已经变化的数据

当业务已经修改数据库中的用户资料时,可以主动删除旧缓存:

// 数据库更新成功后,删除对应缓存。
cache.invalidate("user:1");

下一次读取时缓存未命中,程序会重新查询数据库,从而得到新数据。这种做法常被称为“更新数据库后删除缓存”。真实项目还要考虑并发写入、失败重试和多实例一致性。

4.2.3 缓存计算成本较高的结果

Caffeine 不只能缓存数据库数据。只要结果可以由 Key 唯一确定,也可以缓存复杂计算、文件解析或远程接口结果:

String report = cache.get("report:2026-10", key -> generateReport(key));

前提是该结果允许在一段时间内重复使用,并且要合理设置容量和过期时间。

4.2.4 多级缓存更新时怎样操作

假设 user:1 同时保存在数据库、Redis 和各应用的 Caffeine 中,一次常见的更新流程可以写成:

1. 更新数据库中的 user:1
2. 数据库提交成功后,删除 Redis 的 user:1
3. 发布 user:1 的本地缓存失效消息
4. 每台应用收到消息后执行 caffeine.invalidate("user:1")
5. 下一次读取依次查询 Caffeine、Redis、数据库,并重新填充缓存

还需要补上三个失败保护:

  • 第 2 步失败:记录任务并重试删除 Redis。
  • 第 3 步消息丢失:依靠 Caffeine 的较短 TTL 自动恢复。
  • 业务不能接受消息丢失:改用 Redis Streams、MQ 或数据库变更事件等可持久化方案。

这只是通用流程。真实项目要根据允许的旧数据时间、请求量和故障恢复要求选择方案,不能把缓存当成唯一正确的数据源。

4.3 完整实践:代码、启动和验证

文件位置:caffeine-demo/src/main/java/com/example/CaffeineDemo.java

package com.example;
 
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
 
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
 
public class CaffeineDemo {
 
    // 用计数器模拟并记录“数据库”实际被查询了多少次。
    private static final AtomicInteger DATABASE_QUERY_COUNT = new AtomicInteger();
 
    public static void main(String[] args) throws InterruptedException {
        Cache<String, String> userCache = Caffeine.newBuilder()
                // maximumSize:缓存最多保留 100 条数据,避免无限占用 JVM 内存。
                .maximumSize(100)
                // expireAfterWrite:写入 2 秒后过期,便于在演示中观察重新加载。
                .expireAfterWrite(2, TimeUnit.SECONDS)
                // recordStats:记录命中、未命中等统计信息。
                .recordStats()
                // build:创建一个需要我们手动提供加载逻辑的 Cache。
                .build();
 
        System.out.println("第一次读取:缓存中没有,会查询数据库");
        String first = userCache.get("user:1", CaffeineDemo::queryUserFromDatabase);
        System.out.println("结果:" + first);
 
        System.out.println("\n第二次读取:缓存命中,不会重复查询数据库");
        String second = userCache.get("user:1", CaffeineDemo::queryUserFromDatabase);
        System.out.println("结果:" + second);
        System.out.println("数据库查询次数:" + DATABASE_QUERY_COUNT.get());
 
        System.out.println("\n等待缓存过期……");
        Thread.sleep(2_500L);
 
        System.out.println("第三次读取:原数据已过期,会重新查询数据库");
        String third = userCache.get("user:1", CaffeineDemo::queryUserFromDatabase);
        System.out.println("结果:" + third);
        System.out.println("数据库查询次数:" + DATABASE_QUERY_COUNT.get());
 
        // invalidate:主动删除指定 Key 的缓存。
        userCache.invalidate("user:1");
        System.out.println("\n主动删除后读取:" + userCache.getIfPresent("user:1"));
 
        System.out.println("缓存命中次数:" + userCache.stats().hitCount());
        System.out.println("缓存未命中次数:" + userCache.stats().missCount());
        System.out.println("缓存命中率:" + userCache.stats().hitRate());
    }
 
    /**
     * 模拟根据 Key 查询数据库。
     * 真实项目中,这里通常会调用 Repository、Mapper 或远程接口。
     */
    private static String queryUserFromDatabase(String key) {
        int queryNumber = DATABASE_QUERY_COUNT.incrementAndGet();
        System.out.println("  -> 第 " + queryNumber + " 次查询数据库,Key=" + key);
        return "张三";
    }
}

进入 caffeine-demo 目录后运行:

mvn clean compile exec:java

关键预期结果:

第一次读取:查询数据库
第二次读取:直接命中缓存
数据库查询次数:1
 
等待 2.5 秒后第三次读取:再次查询数据库
数据库查询次数:2
 
主动删除后读取:null

这个结果说明:同一个 Key 在缓存有效期内只加载一次;过期后再次访问会重新加载;调用 invalidate 可以主动删除缓存。

4.4 常见问题排查

4.4.1 Java 8 使用 Caffeine 3.x 编译失败

原因:Caffeine 3.x 要求 Java 11 或更高版本。

处理方式:

<version>2.9.3</version>

或者把项目 JDK 升级到 Java 11 及以上,再使用 Caffeine 3.x。

4.4.2 为什么 getIfPresent 返回 null

getIfPresent(key) 只读取已经存在且未过期的数据,不会自动调用数据库查询。需要“没有就加载”时,可以使用:

cache.get(key, this::loadData);

4.4.3 为什么应用重启后缓存没了

Caffeine 的数据保存在当前 JVM 内存中。应用停止或重启后,进程内存被释放,缓存自然消失。这是本地缓存的正常行为,不是数据持久化方案。

4.4.4 为什么两台服务器读到的缓存不一样

每个应用实例都有自己的 JVM,也就有自己独立的 Caffeine 缓存。Caffeine 不会自动在多台服务器之间同步。需要共享缓存时,应考虑 Redis 等分布式方案,或者专门设计多级缓存与失效通知机制。

五、📌 总结

  • Caffeine 是第三方 Java 本地缓存库,不是 Java 基础,也不是 JDK 内置类。
  • Java 8 使用 Caffeine 2.x;Caffeine 3.x 要求 Java 11 及以上。
  • 核心流程是“先查缓存,未命中再加载,加载后写回缓存”。
  • maximumSize 控制容量,过期策略控制数据保留时间,recordStats 用于观察缓存效果。
  • Caffeine 数据只存在于当前 JVM,应用重启会丢失,多实例之间不会自动共享。
  • Caffeine 适合低延迟的本地缓存;跨实例共享数据通常使用 Redis 等分布式缓存。
  • 多级缓存通过“删除 Redis + 通知各节点删除 Caffeine + TTL 兜底”实现最终一致性。
  • 余额、库存和支付状态等关键判断不能只依赖缓存,应以权威数据源为准。

最后记一句

Caffeine 就是一个专门做缓存的高性能线程安全 Map:它比普通 Map 多了容量控制、过期淘汰、自动加载和统计等能力,但数据只属于当前 Java 进程。

六、🔗 官方资料