☰
JCache与Caffeine、Guava Cache:标准与实现如何选型
2026/10/16 4:45:56 网站建设 项目流程

如果被问到“JCache(JSR-107)和 Caffeine、Guava Cache 应该怎么选”,很多写了几年 Java 的人都会卡一下:明明日常开发里 Caffeine 用得最多,JCache 只是在 Spring 配置里见过一两次,面试官却偏要放在一起比。这道题其实不难,难的是先想明白一件事——JCache 是一个“标准”,而 Guava Cache 和 Caffeine 是“实现”。标准讲的是“接口长什么样”,实现讲的是“内存里怎么折腾得快”。这一篇我就把二者彻底拆开讲清楚:从为什么会有这个规范,到三种编程模型各自的代码形态,再到特性侧重的差异、性能上的边界行为,最后给出一套可以直接照抄的选型判断逻辑和面试答题框架。无论你是准备面试,还是正在给新项目做技术选型,都可以把这篇文章当成一份缓存方案的对照笔记。

1. 为什么Java社区要搞一个JSR-107出来

1.1 JCache之前的Java缓存状态

在JSR-107正式落地之前,Java里的缓存使用可以说是“百家争鸣”。有的项目直接在业务代码里塞一个ConcurrentHashMap,再启动一个后台线程做定时清理;有的项目引入了早期版本的第三方缓存库,但每个库的 API 完全不同,get/put/remove的叫法差不多,到配置过期策略、监听器、加载器时就八仙过海了。更麻烦的是,很多框架内部自带缓存机制,一套接口一套风格,开发者在两个项目间切换时,光是记忆缓存 API 的成本就够喝一壶。

这种局面带来的直接问题就是“不可移植”。今天你在某个框架里写好的缓存代码,换个容器或者换个缓存库,基本上要重写。Java社区对这种状态忍了很久,最终在JCP提出了JSR-107,目标很明确:给Java缓存定义一个统一的标准API。这个规范的过程也比较漫长,从提出到2014年前后才发布1.0正式版,所以如果你在2025年再看它,会觉得这玩意儿有点“老派”,但它的设计思路至今仍然影响着后面的缓存框架。

1.2 标准到底规定了什么,又故意不规定什么

JCache规范最核心的贡献,是把缓存的生命周期和访问方式统一成了几个抽象:

  • CachingProvider:缓存的入口,负责创建和管理CacheManager;
  • CacheManager:管理一组Cache实例的生命周期,可以理解为缓存容器;
  • Cache<K, V>:真正干活的缓存对象,提供get/put/remove/getAll/putAll/invoke等方法;
  • Cache.Entry:缓存的键值条目,配合遍历和EntryProcessor使用;
  • CacheLoader和CacheWriter:分别处理缓存回源和写穿;
  • ExpiryPolicy:定义缓存条目的过期策略,比如创建后过期、访问后过期、修改后过期等;
  • CacheEntryListener:监听缓存条目被创建、更新、删除、过期的事件。

此外规范还定义了四个拦截器注解:@CacheDefaults、@CacheResult、@CachePut、@CacheRemove、@CacheRemoveAll,并且配套了@CacheKey这样的注解,方便基于方法的拦截式编程。看到这里你可能已经发现,这套东西跟 Spring 的 Cache 抽象在思路上很像——实际上 Spring 的@Cacheable就是借鉴了类似的设计,并兼容了 JCache 注解。

但规范真正聪明的地方是“故意不规定”的部分。JCache没有规定缓存容量怎么计算,没有规定淘汰算法必须是 LRU 还是 LFU,没有规定底层必须是 ConcurrentHashMap 还是别的结构,甚至没有规定缓存是否支持分布式。这些统统交给实现方去发挥。好处是厂商可以在标准接口下做各种优化,坏处也很明显:你写的是同一套 JCache 代码,换一个实现厂商,行为差异可能大得超出预期。这点我在后面“实际坑”里会专门展开。

2. JCache的编程模型:接口、注解和EntryProcessor

2.1 从CachingProvider开始的完整生命周期

JCache 的编程模型和“直接 new 一个 HashMap”完全不是一回事。你要拿到一个缓存,首先要拿到CachingProvider,然后通过它获取CacheManager,再用CacheManager创建具体的Cache。这背后隐含了一个重要概念:CacheManager是资源密集型对象,一般建议一个 JVM 只有一个实例,用完之后要close(),否则底层线程池、监听器等资源可能不会被释放。

import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.Duration; import javax.cache.expiry.TouchedExpiryPolicy; import java.util.concurrent.TimeUnit; CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); MutableConfiguration<String, Integer> config = new MutableConfiguration<>(); config.setTypes(String.class, Integer.class); config.setExpiryPolicyFactory( TouchedExpiryPolicy.factoryOf(new Duration(TimeUnit.SECONDS, 30)) ); Cache<String, Integer> cache = cacheManager.createCache("demoCache", config); cache.put("key", 1); Integer value = cache.get("key"); cache.remove("key"); cacheManager.close();

注意几个细节。第一,MutableConfiguration里配置的泛型类型是必须的,JCache 规范不允许缓存值为 null,如果你在get之后把值直接赋给基本类型变量,很容易踩 NPE。第二,过期策略是通过ExpiryPolicyFactory注入的,TouchedExpiryPolicy表示“访问后过期”,还有CreatedExpiryPolicy(创建后过期)、ModifiedExpiryPolicy(修改后过期)、EternalExpiryPolicy(永不过期)。这点跟 Caffeine 的expireAfterAccess、expireAfterWrite是对应起来的,只是 JCache 的命名更偏向“策略模式”。

从使用者的视角看,JCache 的Cache并不只是Map的平替。普通Map的所有操作都是立即内存读写,而 JCache 的get/put背后可能要触发过期计算、事件通知、加载器回源、写穿等一连串动作。所以如果你需要的只是一个简单的 HashMap,完全没必要引入 JCache。

2.2 注解拦截和EntryProcessor

JCache 的注解是它区别于 Caffeine、Guava Cache 的最大亮点。你可以直接在业务方法上标注注解,由拦截器完成缓存的读写,而不需要在每个方法里手写cache.get/cache.put。

@CacheDefaults(cacheName = "userCache") public class UserService { @CacheResult public User findById(Long id) { return loadUserFromDb(id); } @CachePut public void update(@CacheKey Long id, User user) { saveUserToDb(id, user); } @CacheRemove public void delete(@CacheKey Long id) { deleteUserFromDb(id); } }

这里有个容易误解的地方:JCache 注解并不等于 Spring 的@Cacheable。Spring 的缓存抽象有自己的Cache和CacheManager接口,而 JCache 注解是规范自己定义的一套拦截器。Spring 通过适配层可以支持 JCache 注解,但你用的时候要搞清楚当前项目中缓存拦截器到底是哪一套。实际开发里,我见过不少同事在 Spring Boot 项目里引入 JCache 注解后,发现缓存一直没生效,最后排查发现项目的缓存管理器根本不是 JCache 那一条链路。

除了注解,JCache 还提供了一个非常实用的原子操作入口:EntryProcessor。它对应Cache.invoke方法,可以在缓存条目上执行一段逻辑,并且保证线程安全。这有点像ConcurrentHashMap的compute,但又比compute更贴近缓存的语义。

Integer newValue = cache.invoke("counter", (entry, arguments) -> { Integer old = entry.getValue(); entry.setValue(old == null ? 1 : old + 1); return entry.getValue(); });

在需要实现“缓存更新计数”这类场景时,EntryProcessor可以避免“先 get 再 put”的竞态窗口,是 JCache 编程模型里很有价值的一个能力。Caffeine 虽然也提供了asMap().compute,但它更偏向 Map 语义,没有 JCache 那种显式的处理器抽象。

2.3 能力边界:规范没提的,就是你需要自己补的

JCache 编程模型最让人不习惯的地方,是它缺少一些现代缓存库的“默认配置”。比如规范里没有“最大容量”的概念,你无法通过标准 API 告诉缓存“最多放一万条”。虽然可以通过MutableConfiguration传入一些依赖实现的自定义属性,但这些属性换个厂商就不一定被识别了。再看异步加载和后台刷新,JCache 规范同样没有内建支持,你得自己用ExecutorService包一层。

这些都是 JCache 与 Caffeine、Guava Cache 在编程模型上的根本差异。JCache 更像一张“标准接口合同”,强调统一和可移植;而 Caffeine 和 Guava Cache 是“开箱即用的具体工具”,强调把常用功能都给你备好。理解这一点,很多选择题的答案就开始清晰了。

3. Caffeine和Guava Cache的编程模型与特性侧重

3.1 三种创建的代码对比

先看 Caffeine 的经典写法。Caffeine.newBuilder()可以链式配置容量、过期、刷新、统计、监听器,最后通过build()得到一个普通缓存,或者通过build(key -> loader(key))得到一个带自动回源能力的LoadingCache:

LoadingCache<String, Order> orderCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .refreshAfterWrite(1, TimeUnit.MINUTES) .recordStats() .build(key -> loadOrderFromDb(key)); Order order = orderCache.get("order-123");

Guava Cache 的编程模型几乎是 Caffeine 的前身——实际上 Caffeine 的很多 API 设计思路就是从 Guava Cache 借鉴过来的:

LoadingCache<String, Order> orderCache = CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(new CacheLoader<String, Order>() { @Override public Order load(String key) { return loadOrderFromDb(key); } });

可以看到,Caffeine 和 Guava Cache 的编程模型非常像,核心都是“一个 Builder + 一个 CacheLoader”。它们最大的共同点是你不用通过CacheManager去管理缓存实例,每次build()出来的缓存对象直接可用,用完也不像 JCache 那样需要显式close()。这在单元测试和工具类里非常轻量。

3.2 特性侧重逐项对比

我把三者的关键差异整理成一个表格,这样面试和选型时一眼就能定位:

对比维度JCache(JSR-107)Guava CacheCaffeine
定位标准规范具体实现库具体实现库
创建入口CachingProvider -> CacheManagerCacheBuilderCaffeine.newBuilder
自动回源CacheLoader(read-through)LoadingCache + CacheLoaderLoadingCache + CacheLoader
异步加载无线内建AsyncLoadingCacheAsyncCache / AsyncLoadingCache
后台刷新无线内建refreshAfterWriterefreshAfterWrite
淘汰算法交给实现方决定近似的LRUW-TinyLFU + 窗口LRU
容量上限规范无此概念maximumSize / maximumWeightmaximumSize / maximumWeight
统计能力规范定义了但实现差异大recordStats()recordStats() 更完善
监听事件CacheEntryListener,规范统一removalListenerremovalListener
原子操作EntryProcessor(Cache.invoke)无独立抽象,靠Map.computeasMap().compute
注解支持规范内置注解无无
过期策略通过ExpiryPolicy配置expireAfterAccess / expireAfterWriteexpireAfterAccess / expireAfterWrite / expireAfter

表格能看出来的趋势:JCache 的特点在“规范统一”,Guava Cache 的特点是“老牌好用”,Caffeine 的优势则是“既好用又性能强”。需要多说一句的是淘汰策略:Guava Cache 内部实现更接近一个近似的 LRU,Caffeine 采用了一种叫 W-TinyLFU 的算法,结合了 LRU 和 LFU 的优点。简单理解就是:既记录“最近访问过”,也记录“这个 Key 是不是经常访问”。高频业务热点 Key 不容易被一次性流量挤出去,而那些“一秒钟爆量、接下来不再访问”的突发 Key 也不会长期占着内存。这一点在高并发读多写少的场景里非常关键。

3.3 异步和刷新能力的真实差异

Caffeine 在异步这条路上走得比 Guava 更远。Caffeine 提供AsyncCache和AsyncLoadingCache,调用get时返回CompletableFuture,适合那些加载成本很高、不想让请求线程阻塞的场景:

AsyncLoadingCache<String, Order> cache = Caffeine.newBuilder() .maximumSize(10_000) .buildAsync(key -> loadOrderFromDb(key)); CompletableFuture<Order> future = cache.get("order-123"); future.thenAccept(order -> System.out.println(order.getTotalPrice()));

Guava 的AsyncLoadingCache也是类似思路,但返回的是ListenableFuture,你在后续组合时需要的Futures.transform系列方法,和 Java 8 的CompletableFuture用起来体验不太一样。这里没有绝对的孰优孰劣,但从“和现代 Java 代码融合”的角度,Caffeine 的CompletableFuture风格明显更顺滑。

再说后台刷新。refreshAfterWrite和expireAfterWrite是两个完全不同语义的东西,很多人容易搞混。expireAfterWrite是“过期后移除,下次 get 触发重新加载”;refreshAfterWrite是“超过时间后,在访问时异步刷新,老值在刷新完成前仍然可用”。如果你对数据一致性容忍度较高、又不想让请求因为缓存过期而全部打到数据库上,refreshAfterWrite是非常好用的。Caffeine 和 Guava 都支持,而 JCache 标准里没有这个能力,只能靠你自己起线程做定时刷新。

4. 实测对比:不仅看吞吐,还要看边界行为

4.1 吞吐与延迟的大致区间

无论面试还是项目选型,最终还是得回到“谁更快”这个问题上。我在一个新项目里做过一次简单的缓存压力测试:同一批数据预热后,分别用 Guava Cache 和 Caffeine 构建LoadingCache,设置相同的最大容量、相同的过期时间,然后用多线程随机 Key 读取。

结论大致是:读密集场景下,Caffeine 比 Guava Cache 的吞吐高出 20% 到 50% 左右,写密集场景差距会更大一些;Guava Cache 在触发淘汰时偶尔会出现延迟毛刺,Caffeine 的表现则平稳不少。不同机器、不同数据规模、不同并发度下差距会有浮动,但趋势基本一致——这也和 Caffeine 官方公开的 benchmark 结论吻合。

Caffeine 为什么快?主要原因是它并不只是简单地在ConcurrentHashMap上做二次封装,而是设计了一套更精细的数据结构:写入数据后,访问记录放进暂存缓冲区,后续由维护线程批量处理;淘汰算法采用频率记录加窗口 LRU 的组合;读路径尽可能避免加锁。更底层的细节可以去看它的设计文档,但面试时你能说出来“Caffeine 用了 W-TinyLFU 和异步化维护”,已经比大多数候选人强了。

4.2 比性能更容易踩雷的边界行为

性能不是一切,实际开发里真正让人头疼的是各种边界行为。我在某次做“统一缓存中间层”时踩过一个坑:因为是多环境部署,要求缓存访问全部走 JCache 接口。当时选了一个实现厂商,结果发现它内部的getAll是简单循环单个get,在批量查询场景下性能惨不忍睹,而另一个厂商的getAll却做了批量优化。接口完全一样,换了厂商,行为天差地别——这就是“标准统一了接口,但统一不了实现”的典型教训。

再讲CacheLoader。Caffeine 和 Guava 的LoadingCache对同一个 Key 的并发访问有保护:只有一个线程真正执行 loader,其他线程等待结果。这是应对“缓存穿透”的关键机制。但 JCache 规范并没有强制约定这一点,也就是说某些 JCache 实现可能会在多个线程同时获取同一个 Key 时,重复调用CacheLoader。如果你的回源逻辑很重,这就会变成事故。所以在选 JCache 实现时,一定要看清楚它对并发加载的处理逻辑。

还有一个几乎人人都忽略的坑:过期条目的清理时机。很多缓存框架是“懒清理”,也就是条目过期后不会立即被移出内存,要等到下次访问时才发现。Caffeine 在后台维护线程里会做一定程度的主动清理,但 JCache 的实现差异更大。如果你用 JCache 写了一个“过期后必须立即失效”的业务逻辑,只靠标准 API 很可能会收到惊喜。遇到这种需求,最可靠的做法是在业务层做二次校验,而不是完全信任缓存框架的过期语义。

4.3 关于Null值、统计和监听器

JCache 规范明确禁止缓存 Key 和 Value 为 null。Caffeine 也禁止 null 键值。但这里有个容易踩的坑:get返回 null 到底代表“缓存中没有”还是“value 本身就是 null”?其实两者在语义上是同一个结果,但如果你需要区分“没缓存过”和“缓存了但值为空”,可以配合containsKey,或者对空值做一个专用包装对象。

监听器方面,JCache 的CacheEntryListener覆盖了创建、更新、删除、过期事件,并且支持同步和异步两种派发方式;Caffeine 的removalListener只关注“条目被移除”事件,在接口上更简单。统计入口方面,Caffeine 开启recordStats()后可以通过cache.stats()拿到命中等信息,而 JCache 虽然规范里有对应的 MXBean 定义,但实际实现往往不够完善。这也是为什么很多人用 Caffeine 接监控系统会舒服很多。

5. 选型逻辑:什么场景选JCache,什么场景选Caffeine

5.1 先想清楚这四件事再下手

选型不是背一个“JCache 是标准、Caffeine 是性能王”的结论就完了,真正要落到自己的项目里,需要回答几个问题:

第一,你的代码需要在多个缓存实现之间切换吗?如果答案是“会”,比如你开发的是一个通用缓存组件,将来可能对接不同厂商实现的 JCache,或者在容器环境里需要遵循 JSR-107 标准来暴露缓存,那选 JCache 接口是明智的。这就像写 SQL 时选 JDBC 而不是某个数据库的私有 API。

第二,你是否已经处于 Spring 生态?Spring Boot 项目通常用spring-boot-starter-cache做缓存抽象,底层默认可能是ConcurrentMapCacheManager,但如果你在 classpath 里引入了 Caffeine,Spring Boot 会自动切到CaffeineCacheManager。在这种架构下,你的业务代码都在写@Cacheable,那底层用 Caffeine 是最顺手的方式,获得的性能和特性也都最好。JCache 也能接入 Spring Cache 抽象,但需要在配置上多做一层适配,收益并不明显。

第三,你是不是已经跑着一个老项目,里面使用了 Guava Cache?如果代码跑得稳、性能也满足,没必要为了“新技术”重构缓存层。我在实际项目里见过太多因为赶时髦换缓存库,结果监听器、统计、过期策略细节没对齐,上线后出现一堆诡异 bug 的案例。老项目能不碰就不碰,除非有明确的性能瓶颈或功能缺失。

第四,你最需要的是本地缓存还是分布式缓存?本地缓存意味着进程内各有一份,JCache、Caffeine、Guava 都属于这一类。如果你的系统是多节点部署,并且对缓存一致性要求高,那纯本地缓存本身就满足不了,需要在上面加一层分布式缓存。常见的做法是 Spring Cache 抽象 + Caffeine 做本地一级缓存 + Redis 做远端二级缓存。本地这一层用 Caffeine 几乎是没有悬念的。

5.2 三种典型的组合方案

组合一:新起的 Spring Boot 项目,缓存数据量不大,但希望开发简单、性能尚可。推荐:Spring Cache 抽象 + Caffeine。业务层写@Cacheable,配置里指定spring.cache.type=caffeine和spring.cache.caffeine.spec,不用写任何 CacheManager 的 Java 代码。

组合二:公司内部有多个系统都要接入缓存,希望统一缓存访问方式,将来可以按需替换不同厂商的缓存实现。这种情况推荐把 JCache 作为 API 层,选一个成熟的 JCache 实现作为底层,业务代码只依赖javax.cache包,不依赖任何具体实现类。这相当于用接口契约锁住变化,代价是要忍受不同实现之间的行为差异,需要自己补充一些约定。

组合三:老系统里已经大量使用 Guava Cache,且没有强需求。推荐保持 Guava 不动。此时的“选型”其实不是选型,而是“别动”。如果非要做增强,可以逐步在新增模块里试水 Caffeine,但不要大面积替换。

5.3 一个“千万不要”的忠告

千万不要因为面试题考到了 JCache,就以为生产环境“标准大于天”。标准和性能之间,没有绝对的好坏,只有场景匹配度。JCache 的诞生是为了解决“API 不统一”的问题,而不是为了解决“缓存不够快”的问题。如果你的场景是极端的高并发、超大数据量、毫秒级延迟,JCache 的某些实现根本没有和 Caffeine 掰手腕的资本。反过来,如果你的系统是一个需要长期维护的框架级组件,直接硬编码 Caffeine 的Caffeine.newBuilder()也会让以后的可扩展性变差。成年人的选型,不是二选一,而是一层一层地问:我需要可移植性吗?需要性能极致吗?需要生态集成吗?把这些答案拼起来,选择就自然浮现了。

6. 面试回答这道题的框架与常见追问

6.1 60秒回答模板

面试遇到这道题,不要一上来就报菜名。我建议按四步组织答案:

第一步,一句话定位。JCache 是 Java 缓存的标准 API(JSR-107),核心价值是接口统一;Guava Cache 和 Caffeine 是具体实现,解决的是“缓存怎么存得高效”的问题。可以打个比方:网络领域的 TCP/IP 是协议,不同操作系统内核是实现。

第二步,说编程模型差异。JCache 的入口是CachingProvider -> CacheManager -> Cache,缓存需要显式创建和关闭,支持注解和EntryProcessor;Caffeine 和 Guava 用CacheBuilder或Caffeine.newBuilder(),每次 build 一个独立的缓存对象,使用更直接,并通过LoadingCache和Async Cache提供加载能力。

第三步,说特性侧重。JCache 侧重规范、注解、事件、可移植性;Guava Cache 侧重轻量老牌,功能够用;Caffeine 在继承 Guava 优点的基础上,用 W-TinyLFU 替换了近似的 LRU,加入了异步维护、读写缓冲和更细粒度的统计,性能更强。

第四步,落到选型。有强制标准要求或需要跨实现迁移时选 JCache;Spring Boot 新项目追求性能和集成便利时选 Caffeine;老项目稳定运行且改动成本高时继续用 Guava。最后加一句:实际项目中常见的是 Spring Cache 做统一入口,Caffeine 做本地缓存,Redis 做分布式缓存。这样回答既完整又有层次,时长大概一分钟。

6.2 追问怎么接

面试官听完你的回答,大概率会追问几个细节。最常见的追问是“Caffeine 为什么比 Guava 快”。你可以从三点切入:淘汰算法用了 W-TinyLFU,能更好地区分热点数据;访问记录的维护做了异步化,降低了读线程的包袱;底层基于ConcurrentHashMap做并发控制,读路径几乎无锁。

另一个高频追问是“Spring 的 @Cacheable 和 JCache 的 @CacheResult 有什么区别”。这是很多人答不好的地方。核心是:它们都是方法级拦截的缓存注解,但属于两套体系。@Cacheable是 Spring Cache 抽象的一部分,底层 CacheManager 可以换成 ConcurrentHashMap、Caffeine、Redis 等;@CacheResult是 JSR-107 规范的一部分,授权实现方按 JCache 接口工作。Spring 也提供了对 JCache 注解的支持,但在集成时你要清楚当前项目走的是哪套拦截器链。

还有一类追问偏实践——“本地缓存都有哪些坑”。你可以快速列出来:缓存穿透(想办法用 null 对象或者布隆过滤器挡)、缓存击穿(依赖 LoadingCache 的并发加载保护)、缓存雪崩(大量缓存同一时间失效,把过期时间加随机扰动)、内存里存放了可修改对象的引用导致数据被污染。这些问题和 JCache、Caffeine 都有关联,能答出来就已经把基础篇吃透了。

6.3 这道题背后的“隐藏考点”

说实话,面试官问这道题,不完全是在考你知识量。他真正想摸清的,是你有没有“技术决策意识”。标准 API 和具体实现是两个维度的东西,能看到这一点,说明你不是照着文档写代码,而是在思考为什么会有这么多缓存的库、标准在这个过程中解决什么问题。这比单纯背诵几个方法名重要得多。所以回答完选型逻辑之后,如果你还能补一句“我理解的缓存选型本质是权衡可移植性、性能和维护成本”,这场面试的加分效果往往比多说十个 API 都强。

我在实际项目中反复体会到一个道理:缓存不是越强越好,而是越合适越好。很多系统用最简单的ConcurrentHashMap就能跑得很好,根本没有必要引入任何缓存框架。一旦真的需要缓存框架,再按标准、性能、生态三个维度去衡量,这时候你会发现 JCache、Caffeine、Guava Cache 各自的定位非常清晰。下次再有人拿它们做对比,你可以很从容地告诉他:先分清标准和实现,再谈编程模型,最后落到场景,答案自己就出来了。

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

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

立即咨询