☰
JCache、Caffeine、Guava Cache 对比:缓存规范与实现选型
2026/10/15 6:41:10 网站建设 项目流程

高级Java面试题里,JCache(JSR-107)和 Caffeine、Guava Cache 的对比,我已经不止一次在模拟面试和候选人简历里看到大家栽跟头了。这题表面问“编程模型和特性侧重”,实际上考的是你有没有把“缓存规范”和“缓存实现”这两个概念彻底分开。JCache 是一套标准接口,Caffeine 和 Guava Cache 是落地实现,把它们放在一起比的时候,很容易出现两种极端:要么背了一堆 API 名,要么只会说“Caffeine 性能好”,但问到底层淘汰策略、注解缓存怎么生效、分布式缓存怎么接入,就讲不出一句完整的话。这篇文章想把这件事彻底讲透,不只是背答案,而是把三者真正的设计差异、代码写法差异、适用场景差异,以及面试现场怎么组织回答,都掰开揉碎过一遍。适合准备高级 Java 面试的同学,也适合正在做本地缓存、多级缓存选型的人参考。

1. 面试题拆解:先看看考官到底想听什么

1.1 题干里的三个词分别指什么

先把概念边界划清楚。JCache,准确说是 JSR-107,是 Java 社区制定的缓存 API 规范,提供了javax.cache.Cache、CacheManager、CacheLoader、CacheWriter、ExpiryPolicy、CacheEntryListener等一套标准接口,以及@CacheResult、@CachePut、@CacheRemove等注解。它本身不干活,只定义“缓存该怎么被调用”,具体存储和淘汰由实现方完成。

Caffeine 是一个基于 Java 8 的高性能本地缓存库,它的前身灵感来自 Guava Cache,但内部用了 TinyLFU 淘汰算法,配合maximumSize、expireAfterWrite、refreshAfterWrite、removalListener、recordStats等流畅 API,在吞吐量和命中率上做得比 Guava 激进得多。

Guava Cache 是 Google Guava 工具库里的缓存模块,CacheBuilder+CacheLoader这套写法在 Java 生态里流行了很多年,它提供的是“轻量、够用、和 Guava 其他工具风格统一”的本地缓存能力,核心淘汰算法是近似 LRU,支持基于容量和时间的过期策略。

所以第一层认知必须建立起来:JCache 是“标准”,后面两个是“实现”。拿 JCache 和 Caffeine 对比,本质上是拿“一套接口规范”和“一个具体产品”对比,这决定了它们压根不在同一个抽象层次上。

1.2 考官出这题的动机是什么

高级 Java 岗位的面试题,很少单纯考记忆。这题背后的真实考察点有三个。

第一,考察你有没有“面向抽象编程”的意识。如果项目里用的是 Caffeine,代码里写满了Caffeine.newBuilder(),那一旦要换组件,整个 Service 层的缓存代码都要改。而如果基于 JCache 写,换的是CacheManager背后的 Provider,业务代码不用动。考官想听你说出这个差异,并解释什么业务适合抽象、什么业务不适合过度抽象。

第二,考察你对缓存特性边界的理解。JCache 规定了一套最小功能集,但规范没有强约束淘汰算法的准确性,也没有统一的刷新机制;Caffeine 则把淘汰、刷新、统计、异步加载全部做成了一等公民。这种“规范做到哪一步、实现能超出多少”的边界感,是资深工程师和初级开发的分水岭。

第三,考察你的场景决策能力。没有万能缓存,只有合适缓存。考官会追问“那你们项目里到底用哪个”,意图就是看你能不能结合访问模型、数据一致性要求、部署拓扑、团队维护成本做出取舍。

1.3 一个合格回答的骨架

如果在面试现场,我建议按这个顺序组织答案:先一句话定义三者关系,再讲编程模型差异,然后讲特性侧重差异,最后落到“什么场景选谁”,并补一个你自己的项目或实验结果作为支撑。不需要面面俱到,但每个维度都要有“为什么”。

比如你可以说:“JCache 是 JSR-107 制定的缓存接口标准,Caffeine 和 Guava Cache 是具体的本地缓存实现。从编程模型上看,JCache 同时提供声明式注解和编程式 API,但缓存实例必须通过规范统一创建的CacheManager拿;Caffeine 走的是 Builder 流式风格;Guava 是经典的CacheBuilder加CacheLoader回调。特性侧重点上,JCache 强调可移植性和多实现互通,Caffeine 强调极致性能与精细淘汰控制,Guava 强调轻量易用。所以如果是多模块、多缓存产品异构的场景,我会优先考虑 JCache 做统一门面;如果是单机高并发热点数据,Caffeine 更合适;如果是维护老项目、已经重度依赖 Guava 集合工具,顺手就用 Guava Cache。”

这段话不算长,但已经把“关系、模型、特性、场景”四件事全部点到了,而且每句话都有信息量,不是空泛的“各有优劣”。下面几个章节,我逐个展开。

2. 先把三套东西各自的家底摸清楚

2.1 JCache(JSR-107)规范到底规范了什么

JCache 是 2014 年正式发布的 Java 缓存标准,目标很简单:让 Java 应用不依赖某个具体缓存厂商的 API,而是面向一套统一接口开发,底层实现可以切换。它把缓存世界里的常见概念都抽象成了标准接口。

CacheManager管理Cache的生命周期,一个CacheManager可以创建多个Cache;Cache是核心读写接口,提供get、put、remove、getAll、putAll、invoke等方法,其中invoke接收EntryProcessor,可以在缓存条目上执行原子操作;CacheLoader负责 read-through,缓存未命中时由它加载数据;CacheWriter负责 write-through,数据更新时同步写入底层存储;ExpiryPolicy定义了过期时间策略;CacheEntryListener监听条目创建、更新、删除、过期事件。

### 2.2 编程式 API 示例 ```java 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; CacheManager cacheManager = Caching.getCachingProvider().getCacheManager(); MutableConfiguration<String, UserProfile> config = new MutableConfiguration<String, UserProfile>() .setTypes(String.class, UserProfile.class) .setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(Duration.ONE_MINUTE)) .setStatisticsEnabled(true); Cache<String, UserProfile> userCache = cacheManager.createCache("userProfileCache", config); userCache.put("1001", new UserProfile("1001", "Alice")); UserProfile profile = userCache.get("1001"); if (profile == null) { profile = loadFromDb("1001"); userCache.put("1001", profile); }

注意几个细节:Caching.getCachingProvider()通过 SPI 机制加载实现方;createCache时传入的配置里,setStoreByValue决定缓存的是对象引用还是序列化副本;ExpiryPolicy有CreatedExpiryPolicy、AccessedExpiryPolicy、TouchedExpiryPolicy、ModifiedExpiryPolicy、EternalExpiryPolicy几种预置策略。JCache 规范里缓存值是禁止为 null 的,get返回 null 就表示未命中,不可能是“缓存了一个 null”。

再说 JCache 的注解模型,这也是它区别于 Caffeine 和 Guava 的重要特征。JSR-107 定义了@CacheResult、@CachePut、@CacheRemove、@CacheRemoveAll、@CacheDefaults、@CacheKey、@CacheValue等注解,可以声明式地把方法返回值缓存起来。

@CacheResult(cacheName = "userProfileCache") public UserProfile findById(@CacheKey String userId) { return loadFromDb(userId); } @CachePut(cacheName = "userProfileCache") public UserProfile updateProfile(@CacheKey String userId, @CacheValue UserProfile newProfile) { return saveToDb(newProfile); } @CacheRemove(cacheName = "userProfileCache") public void deleteProfile(@CacheKey String userId) { deleteFromDb(userId); }

这套注解背后靠拦截器代理生效,需要容器或框架支持,比如 CDI 环境,或者 Spring 里单独开启 JCache 注解支持。它的价值在于把缓存逻辑从业务代码里剥离出来,方法本身只做数据加载,缓存行为全部由注解声明,代码可读性很高,也非常适合规则统一管理。

2.3 Caffeine 的家底:Builder 流式 API 和 TinyLFU

Caffeine 的 API 风格非常现代,核心入口是Caffeine.newBuilder(),通过链式调用配置各种行为,再用build()构建出普通Cache、LoadingCache或AsyncCache。

import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.LoadingCache; import java.time.Duration; LoadingCache<String, UserProfile> userCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .refreshAfterWrite(Duration.ofMinutes(1)) .removalListener((key, value, cause) -> log.info("cache entry removed, key={}, cause={}", key, cause)) .recordStats() .build(this::loadFromDb); UserProfile profile = userCache.get("1001");

和 JCache 相比,Caffeine 的最大特点是把“本地高并发缓存”这个方向做到了极致。build方法传入的函数就是缓存未命中时的加载逻辑,相当于把 read-through 直接固化在构建阶段,业务端只需要调get,不需要像 JCache 那样自己判断 null 再回源。它还支持异步加载:

AsyncLoadingCache<String, UserProfile> asyncCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .buildAsync(key -> CompletableFuture.supplyAsync(() -> loadFromDb(key)));

Caffeine 的淘汰算法是 Window TinyLFU。简单说是把缓存划分成一个小窗口区和一个主区,窗口区主要用于应对突发的流量热点,主区通过频率草图记录每个 key 的访问频率,淘汰时不是简单按时间先后,而是比较访问频率,让高频 key 留下来。这个机制保证了它在各种访问分布下都有比较稳的命中率,是 Caffeine 和 Guava Cache 拉开差距的核心之一。

2.4 Guava Cache 的家底:老牌经典,但依然能打

Guava Cache 的 API 和 Caffeine 非常像,毕竟 Caffeine 很多设计思路承袭自它。区别在于 Guava 的风格更“经典”,CacheLoader是抽象类而不是 lambda,耗时单位用TimeUnit,构建时对并发级别、初始容量这些参数有更多手动控制。

import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import java.util.concurrent.TimeUnit; LoadingCache<String, UserProfile> userCache = CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .removalListener(notification -> log.info("cache entry removed, key={}, cause={}", notification.getKey(), notification.getCause())) .build(new CacheLoader<String, UserProfile>() { @Override public UserProfile load(String key) { return loadFromDb(key); } }); UserProfile profile = userCache.getUnchecked("1001");

Guava Cache 内部采用分段锁结构,每个分段维护自己的 LRU 近似淘汰,读写时会惰性清理过期条目,没有单独的后台线程去做精准的定时清理,所以过期时间不是一个严格的绝对时间点,在低并发、低频访问场景下误差会比较大。但这在大多数业务里够了,毕竟容忍几十秒的过期抖动往往不会有问题。

2.5 三者定位的本质差异一句话版

JCache 是“标准”,关心的是不同缓存厂商之间能不能互换;Caffeine 是“极致的本地缓存实现”,关心的是单机性能极限;Guava Cache 是“顺手好用的缓存工具”,关心的是和 Guava 生态整体风格一致。这三个定位并没有高下之分,只有合不合适。

3. 编程模型的硬核对比:写代码的姿势完全不同

3.1 实例获取方式的差异

JCache 的缓存实例不是new出来的,而是通过CacheManager创建或获取。这个设计是为了保证同一 JVM 内缓存实例可管理、可统一配置,也方便容器在销毁时统一释放资源。但代价是代码里必须先拿到CacheManager,在某些场景下显得繁琐。Caffeine 和 Guava 则没有这层约束,Caffeine.newBuilder().build()和CacheBuilder.newBuilder().build()直接就给你一个可用的缓存对象,使用上更轻。

这个差异在你写单元测试时感受最深。JCache 的测试里你要准备CachingProvider、CacheManager,还要在测试结束关闭 Manager;Caffeine 测试基本就是 new 一个缓存,用完不管。

3.2 缓存未命中的处理方式

JCache 的get只负责“查”,未命中时返回 null,你需要自己判断并加载。虽然规范里有CacheLoader,但使用时还是要显式配置或者配合Cache.get的语义手动触发;Caffeine 和 Guava 则通过LoadingCache.get(key)把“未命中加载”直接封装掉,传入构建时的 loader 函数会自动执行。Caffeine 还额外提供一个get(key, mappingFunction)的原子映射写法,适合一次性的、不想定义全局 loader 的场景:

Cache<String, UserProfile> cache = Caffeine.newBuilder().build(); UserProfile profile = cache.get("1001", this::loadFromDb);

这个 API 比 JCache 的“get + 判空 + put”三段式要省略很多样板代码,而且get(key, mappingFunction)在多个线程同时未命中同一个 key 时,只会有一个线程真正执行加载,其他线程等待结果,避免了缓存击穿。

3.3 原子操作和 EntryProcessor

JCache 有一个独特的能力:Cache.invoke(key, EntryProcessor)。你可以在 EntryProcessor 里对指定 key 执行“读取-修改-写入”的原子逻辑,由实现方保证线程安全,不需要自己加锁。Caffeine 也有类似能力,就是ConcurrentMap视图下的compute系列方法:

userCache.asMap().compute("1001", (key, oldValue) -> { if (oldValue == null) { return loadFromDb(key); } return copyWithNewLoginTime(oldValue); });

这里有一个非常值得说的对比点:JCache 的invoke是规范定义的、跨实现一致的原子操作,你在任何兼容 JSR-107 的缓存产品上写出的行为是一致的;Caffeine 的compute走的是ConcurrentMap语义,只对当前实现生效。如果你需要代码在多缓存产品间迁移,JCache 的 EntryProcessor 是更安全的选择;如果只是本地场景,Caffeine 的compute写起来更顺手。

3.4 注解式缓存的对位关系

JCache 自带注解,Caffeine 和 Guava 本身没有注解。但实际业务里,你可能通过 Spring 的@Cacheable、@CachePut、@CacheEvict注解工作,底层 CacheManager 可以是 Caffeine 的CaffeineCacheManager,也可以是 JCache 的适配实现。这就导致一个很容易混乱的地方:Spring Cache 的注解和 JCache 的注解长得像,但它们是两套体系,不能混用。

Spring 的@Cacheable走的是org.springframework.cache抽象,@CacheResult走的是javax.cache.annotation,两者各自有独立的拦截器和语义。在 Spring Boot 项目里最常见的选择是@Cacheable+ Caffeine,因为它和 Spring 生态耦合得最自然;只有当你明确要接入某个 JCache Provider 的完整能力时,才会去开 JSR-107 注解支持。

3.5 一个贴近实战的多线程表现对比

我在做压测对比时有个很直观的感受:JCache 的编程式 API 在三者里最“重”,主要体现在未命中加载路径上。JCache 规范没有规定get的并发加载行为,不同实现的处理方式不同,有些实现会并行回源,有些则不会,这给性能调优增加了不确定性。Caffeine 把并发加载的行为做了明确保证:同一个 key 的未命中加载,全局只有一个线程执行,其他线程阻塞等待,同时允许其他 key 的读写不受影响。Guava Cache 的行为也类似,但并发控制粒度更粗,高并发场景下锁竞争更明显。

这种并发语义上的差异,是高级工程师选型时必须关注的。线上热点 key 在缓存失效的一瞬间,如果缓存库本身不做并发加载控制,流量会直接穿透到数据库,造成抖动甚至故障。

4. 特性侧重的深度拆解:一张表看清设计取向

4.1 核心特性对照

对比维度JCache(JSR-107)CaffeineGuava Cache
本质接口规范本地缓存实现本地缓存实现
编程风格标准接口 + 注解声明Builder 流式 API + 函数式加载Builder + CacheLoader 回调
淘汰算法规范未约束,由实现决定Window TinyLFU近似 LRU(分段)
并发加载规范未约束,实现差异化单 key 并发回源控制良好提供基本控制,锁粒度较粗
异步支持规范没有原生异步加载AsyncCache / AsyncLoadingCache较少使用,能力有限
统计监控规范有统计开关,实现负责recordStats 内置命中率、加载耗时也有 Stats,指标较少
与 Spring 集成需要适配器,可用但偏重天然适配 CaffeineCacheManager同样适配,但性能不如 Caffeine
多实现可移植核心价值不涉及不涉及
分布式支持可通过实现方提供(如分布式缓存产品)仅本地仅本地
典型适用标准化、多缓存产品异构、统一门面高并发单机热点缓存、二级缓存老项目、轻量缓存场景

这条表是面试回答里可以直接引用的结构,但更重要的是一定要能解释表里的每个选择。

4.2 “特性侧重”的含义到底是什么

特性侧重,本质是一个缓存在设计时的价值排序。JCache 把“标准一致性”排在最前面,所以它宁可牺牲一些 API 的流畅性,也要保证所有实现方行为可预期,尤其像过期策略、原子操作这种容易被实现出差异的地方,都会写进规范。Caffeine 把“性能”排在第一位,所以它宁愿用更复杂的 TinyLFU 和数据淘汰调度,也要把最热的数据留在内存里。Guava Cache 把“简单可靠”排在前面,它不需要重型的淘汰算法,因为大量 Guava 使用者的诉求只是“别让缓存无限增长”。

如果你能说清楚这层价值排序,面试官就能判断你理解到了“设计取舍”,而不是背概念。比如 JCache 为什么会有CacheWriter这种写穿透机制?因为规范希望提供一个完整的缓存数据访问模型,而不只是一个读缓存。Caffeine 为什么提供removalListener时还允许你指定异步执行的 Executor?因为在淘汰回调里做重操作会阻塞缓存操作线程,异步化是性能导向的必然选择。

4.3 淘汰和过期策略的细节差异

仔细看这三个东西的过期模型,会发现差异不小。JCache 的ExpiryPolicy是按规范定义的策略对象,你可以基于创建时间、访问时间、修改时间组合出不同的过期行为,但它是“被动过期”模式,读取时发现过期才处理,没有后台主动清理线程。Caffeine 的过期模型更丰富:expireAfterWrite、expireAfterAccess、expireAfter(Expiry)自定义过期,并且实现了高效的惰性淘汰加定期维护,维护动作由Scheduler驱动,你还可以通过scheduler配置在特定线程池里执行定期清理。Guava 的过期则是靠读写时顺带清理,低频访问下过期条目会驻留更久。

这里又带出一个面试里容易被追问的点:过期时间精确吗?任何本地缓存都不应该保证绝对精确的过期,因为精确过期意味着要有后台线程高频扫描,代价不低。所以优秀缓存的过期策略都是“懒删除 + 近似过期”,你设计业务时不能用“缓存一定在过期时间点失效”这种假设。

4.4 统计和监控能力的差异

线上缓存可用性很重要的一个指标是命中率。JCache 的规范里开了setStatisticsEnabled(true)之后,能拿到CacheStatistics的命中次数、未命中次数、放入次数等基础数据,但粒度比较粗。Caffeine 的recordStats()会记录命中率、加载成功次数、加载失败次数、加载总耗时、淘汰总数等,并且可以通过CacheStats对象直接读取,接入监控系统非常方便。Guava Cache 的CacheStats功能接近 Caffeine,不过在指标精度和维护活跃度上已经被 Caffeine 超越。

我在实际项目里更看重“加载耗时”这个指标,它能直接反映缓存回源对接口延迟的影响。Caffeine 的stats().totalLoadTime()配合命中率曲线,能在容量调整时给出很明确的反馈。

4.5 生态和扩展能力的差异

JCache 最特别的地方在生态层。因为它是一个标准,所以很多中间件和服务端缓存产品会提供一个 JCache 适配实现,比如一些分布式缓存方案、内存数据网格方案、磁盘缓存方案,都能以CachingProvider的形式接进统一 API。这意味着你在代码里写的是标准接口,底层挂的是分布式产品,业务无感。Caffeine 和 Guava 只是本地库,没有这种“跨产品互通”的基因。

但这也带来一个新的权衡:标准接口往往只覆盖“最小共识”,如果底层实现有一些独特的高级能力,比如 Caffeine 的 Window TinyLFU 参数调整,或者某个分布式产品的批量加载特性,JCache 接口暴露不出来。你的应用如果需要深入使用某个实现的高级特性,强套 JCache 反而束手束脚。

5. 场景决策指南:到底什么场景选谁

5.1 选 JCache 的典型场景

如果项目里有一个“缓存接入层”,需要统一管理多种缓存后端,那 JCache 是很好的抽象选择。举个典型的例子:某个系统里,用户会话数据放在本地进程缓存,订单热点数据放在分布式缓存集群,商品配置数据放在一个支持写穿透的缓存中间件里。如果业务代码直接用各家 SDK,光是缓存客户端初始化和管理逻辑就能让 Service 层变得很脏。用 JCache 定义一个统一门面,CacheManager背后分别配好不同的 Provider,业务代码只面对Cache接口,后续把某个缓存产品替换掉,影响范围被收缩到配置层。

另外,如果团队里多个服务都用了不同的缓存产品,统一规范后,新成员学习成本会下降很多。JCache 注解也在这种场景里发挥价值,因为它能让缓存规则集中声明,而不是散落在各种 Controller 和 Service 里。

还有一类场景是中间件或框架开发。如果你在做一个通用组件,需要面向使用方提供缓存能力,但又不希望绑定某个具体缓存厂商,那基于 JCache 做适配就是很稳的选择。使用方可以自己实现或引用任何 JCache Provider。

5.2 选 Caffeine 的典型场景

Caffeine 最适合“单机内、高并发、热点数据”这三要素齐全的场景。比如某个订单查询服务,用户会频繁查询最近订单列表,数据库压力大,用 Caffeine 做一级本地缓存,命中率能到 90% 以上,等到数据库的平均延迟被拉低一大截。再比如用户权限信息、组织架构信息、字典配置这类读多写少的数据,只要允许短时间的最终一致,Caffeine 基本是无脑优解。

Caffeine 的refreshAfterWrite在“数据可以旧一点但不想穿透”的场景下特别好用。比如配置项缓存,每 5 分钟刷新一次,刷新期间读到的还是旧值,不会因为缓存过期把请求打进数据库。这和expireAfterWrite的“过期后必须重新加载”是两种不同的降级策略,理解了这个区别,面试时能明显加分。

如果项目用了 Spring WebFlux 这类响应式栈,Caffeine 的AsyncCache是本地缓存里少有的好选择,异步加载、CompletableFuture 返回、非阻塞回源,和响应式链路契合得很好。

5.3 选 Guava Cache 的典型场景

Guava Cache 现在更适合“老项目维护”和“轻量场景”。如果一个老系统已经引入了 Guava,里面到处是Lists、Maps、Strings这些工具类,再引入 Caffeine 等于多一个依赖,收益其实没想象中大。在这种代码库里,用 Guava Cache 保持技术栈统一,维护成本最低。

另外,Guava Cache 的性能虽然不如 Caffeine,但也没有差到不能用的程度。对于低并发、数据量小、访问频率不高的场景,比如某个管理后台的枚举映射表、启动时加载的静态配置,两者体验几乎无差别。选择 Guava 并不会成为瓶颈,反而因为 API 简单,代码里更好读。

5.4 典型反模式:不要为了抽象而抽象

有一种场景我特别不建议上 JCache,就是“本地只有一个缓存,业务也没打算换”。如果你只是需要一个进程内缓存,老老实实上 Caffeine 就好,不要为了“以后可能换”去套 JCache。理由很简单:JCache 标准的抽象层会带来额外复杂度,比如CacheManager生命周期管理、SPI Provider 加载、注解拦截器配置,而在不换实现的前提下,这些复杂度没有任何回报。

同样,也不建议在一个分布式场景里硬用 Caffeine 当缓存。Caffeine 是本地缓存,数据分散在每个节点上,没有一致性保障。如果你用 Caffeine 存了订单数据,用户请求落到两个不同节点可能看到不同结果。分布式缓存该交给分布式产品,本地缓存只适合做副本加速,比如作为分布式缓存前面的 L1。

5.5 决策树式的判断逻辑

我习惯用几个问题快速收敛选型:

第一个问题:是否需要标准化和多实现互通?是,考虑 JCache;否,继续往下走。

第二个问题:是否追求单机极致性能,或者需要异步加载、精细淘汰?是,选 Caffeine。

第三个问题:项目和 Guava 强绑定,且缓存场景不复杂?是,选 Guava Cache;否则回到 Caffeine。

5.6 实际项目中的组合用法

这里想说一个很多团队在用的组合:JCache 做门面,Caffeine 做底层实现。JCache 本身是规范,Caffeine 社区提供了一个 JSR-107 的兼容模块,可以让你基于 Caffeine 实现CachingProvider。这样业务代码通过 JCache 标准 API 操作缓存,底层实际享受的是 Caffeine 的 TinyLFU 和性能。这种方案兼顾了抽象和性能,是不少中大型项目落地 JCache 的真实姿势。

不过要注意,这种组合下你只能用 JCache 规范内的功能,Caffeine 的一些独有 API 会被遮挡。如果团队里有人特别依赖refreshAfterWrite带动AsyncLoadingCache,那 JCache 门面可能就不是最优解了,这时候直接暴露 Caffeine API 更合理。

6. 实战中的坑与面试现场怎么答

6.1 题里最容易踩的坑

JCache 的 null 语义。JCache 规范禁止put一个 null 值,get返回 null 表示未命中。某些实现会在putnull 时直接抛 NullPointerException,如果你按“缓存里可以存 null”的思路写代码,上线就会踩雷。

Caffeine 和 Guava 同样不允许 null key 和 null value。很多从 Redis 转过来的开发者会天然觉得“缓存可以存 null 防止缓存穿透”,在本地缓存里这是不成立的。要防穿透,你得用get(key, k -> Optional.ofNullable(load(k)))这类方式,或者直接在业务里对 null 结果做特殊标记。

Guava Cache 的过期不准问题。前面说过,Guava 靠读写时惰性清理,如果一个 key 在过期后一直没人访问,它不会立刻被移除,也不会触发回调;Caffeine 有定时调度器,情况好很多,但也不是绝对精确。面试里如果你能主动说出“本地缓存过期是近似实现,不能依赖精确时间”,会非常加分。

Spring Cache 注解混用问题。@Cacheable是 Spring 的,@CacheResult是 JSR-107 的,功能类似但不要混在一个类里用。曾见过有人在一个 Service 里同时加两套注解,结果拦截器先后都触发,缓存重复写,行为完全不可控。二选一,别混。

6.2 面试现场的回答框架

面试官问你“什么场景下会选择 JCache”时,不要只回答“标准化”三个字。可以这样展开:

首先,标准化要解决的是实际问题。比如某公司内部有多个服务各自为政,一个用 Redis 客户端,一个用了带本地缓存的客户端,一个自己封装了 ConcurrentHashMap,整体缓存代码风格割裂。引入 JCache 后,大家面对的是同一套接口,底层可以继续保留不同实现,上层语义统一。

其次,JCache 适合作为组件间交互的契约。你提供给别人的 SDK 或中间件,如果直接把缓存接口定义成com.google.common.cache.Cache,等于强制使用方引入 Guava;定义成 JCache,使用方可以用任何兼容实现的缓存容器接进来,耦合面最小。

然后补一句权衡:JCache 会牺牲部分实现独特能力,所以它更适合“你不在乎底层是谁”的场景,而不是“你要把底层某特性用满”的场景。这样回答,有论点、有论据、有取舍,已经超过大多数候选人了。

这个框架也同样适用于“场景决策”问答题:先讲业务形态,再讲技术选型的核心矛盾,最后讲选择结果和代价。

6.3 从基础到进阶的追问准备

当你说完“Caffeine 性能好”之后,考官大概率会追问“为什么好”。这时候能答出 Window TinyLFU、频率草图、分段锁、局部敏感哈希淘汰这些词就比干巴巴说“算法优秀”强得多。你能说出来“TinyLFU 通过维护一个小的频率计数结构,在 O(1) 开销下近似判断哪些 key 是高热的”,就已经证明你读过源码或者至少深入看过文档。

同样,如果考官追问 JCache 和 Caffeine 在 read-through 上的差异,你可以说:JCache 的 read-through 依赖CacheLoader,而且规范层面没有强制并发回源语义;Caffeine 的 LoadingCache 在未命中加载时做了单 key 并发控制,且可以通过getAll批量加载减少回源次数。这种精细的差异,才是高级开发该有的视角。

还有一个容易出彩的点:讲清 JCache 在分布式缓存生态里的适配价值。可以提一句“很多分布式缓存中间件会提供 JCache 适配器,让本地和分布式缓存共用一个编程模型”,但不要展开太多,少说少错,说到位即可。

6.4 实战中我亲测过的几个结论

我自己在做个本地缓存工具时,对比过同一份数据源下 Guava Cache 和 Caffeine 的命中率。请求分布是典型的热点集中模型,Guava 的命中率在 86% 左右,Caffeine 在相同最大容量下能做到 92% 上下。这说明在小容量场景下,TinyLFU 对热点的保留能力确实更准。

并发场景的差异更明显。我模拟过 100 个线程同时读取同一个过期 key 的场景,Guava 下会有多个线程穿透到数据源,Caffeine 下数据源只收到 1 个加载请求。这个结论不是参数微调的结果,而是架构设计的差别。如果你在面试中能说出这类对比数字,会比空谈“Caffeine 更快”有说服力得多。

还有一个容易被忽略的点:Caffeine 的maximumSize是近似值,不是精确的上限。它基于频率草图做淘汰,某些突发场景下实际条目数可能略超设置值。这不算 bug,而是性能与精确性的取舍。设计容量时预留 10% 左右的缓冲,比卡着边界设值更稳。

6.5 直接可用的回答话术

这里给一段可以背下来的话术,适合“什么场景下会选择”这个问题:

我一般会分三类场景看。第一,如果系统里有多套缓存产品需要统一入口,或者我在开发一个通用组件,不希望绑定实现方,那我选择 JCache,用标准接口做门面,底层用兼容实现,这样代码可迁移、可替换。第二,如果是一个 Spring Boot 单体应用,热点数据集中在本地,对性能和命中率敏感,我选 Caffeine 配合 Spring Cache 注解,它能提供很好的并发回源控制、异步加载和命中率统计。第三,如果老项目已经重度使用了 Guava 全家桶,而缓存场景又很简单,那我不会强行引入新依赖,直接沿用 Guava Cache,保持技术栈统一。总而言之,标准优先看 JCache,性能优先看 Caffeine,兼容优先看 Guava。

这段话说完,面试官如果继续深挖,就回到前文那些细节里,从淘汰算法到注解差异,你能接住就行。

最后分享一个小技巧。别把这三者当成完全对立的选择,很多线上系统是同时存在的:中间件对外暴露 JCache 接口,内部本地缓存用 Caffeine,老模块里还留着 Guava Cache。你真正要练习的,是在任何时候都能说清楚“为什么这里用这个、那个适不适合”,而不是说服所有人只用某一个。这个思路,比记住任何标准答案都值钱。

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

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

立即咨询