前几天有个读者私信我,说他面试某厂 Java 高级岗时被问了一道“基础篇”的题:JCache(JSR-107)在 Java EE 或 Spring Boot 环境中如何集成和启用。他当场愣了一下——平时用的都是 Redis、Caffeine,没正经研究过 javax.cache 这套标准 API。其实这道题问得很有水平,表面考的是“会不会配依赖”,内里考的是你有没有规范意识、能不能在 Spring Cache 抽象之外说清 JCache 的本质。这篇文章我就从面试题角度入手,把 JCache、JSR-107 的底层逻辑、Java EE 和 Spring Boot 两种集成路径、以及我实际跑项目时踩过的坑完整捋一遍。
1. JCache(JSR-107)背后的规范逻辑:面试官到底在考什么
1.1 先分清规范、API、实现三个层次
很多人一听到 JCache 就往“缓存中间件”上想,这是第一个误区。JCache 不是一个产品,它是一个规范,编号 JSR-107,全称叫 “Java Temporary Caching API”。它定义了一套标准接口,放在javax.cache包下,规定了缓存管理器、缓存、过期策略、事件监听、注解这些基础概念长什么样。
规范之下还有 API 和实现之分。API 就是那套javax.cache.CacheManager、javax.cache.Cache等接口;实现则是 Ehcache、Hazelcast、Caffeine 这些第三方库对接口的具体落地。接口是公有的,实现是私有的,你可以把 JCache 理解成“接口标准”,把 Ehcache 理解成“按这个标准盖出来的房子”。
这套思路特别重要,因为面试官问到“如何集成和启用”时,如果只背配置不聊原理,很容易被追问卡住。你先说出“规范—API—实现”三者的关系,再往下讲集成步骤,层次感立刻就不一样了。
1.2 核心接口拆解:CacheManager、Cache、Entry、ExpiryPolicy
JCache 的核心接口并不多,但每一个都有明确职责,面试时可以按这个顺序讲:
| 接口 | 职责 | 生活化类比 |
|---|---|---|
CachingProvider | 负责创建和管理CacheManager,是桥接 SPI 的入口 | 缓存行业的施工队 |
CacheManager | 管理一组Cache,可以创建、销毁、获取缓存实例 | 一个物业公司,管理多栋楼 |
Cache<K,V> | 真正存取数据的缓存对象,类似Map但带生命周期和事务语义 | 楼里的房间 |
Cache.Entry<K,V> | 缓存中的键值对条目,支持获取 key、value 和访问相关信息 | 房间里的人和物品 |
ExpiryPolicy | 控制缓存的创建、访问、更新后的过期行为 | 保洁阿姨何时来清理 |
CacheLoader/CacheWriter | 读不到数据时从外部加载,写入时同步到外部存储 | 没库存时去仓库调货 |
这里我想强调一点:Cache不等于Map。Map是纯粹的内存数据结构,而Cache有失效策略、缓存事件、读写穿透、统计信息等能力。你在面试里能说出“JCache 把缓存从裸数据结构提升到了企业级组件”这句话,会比单纯背接口名有用得多。
1.3 为什么这个问题会出现在“基础篇”
虽然标题叫“高级 Java 每日一道面试题”,但 JCache 的基础属性非常强。它回答的是“标准缓存 API 长什么样”这个基础问题,而不是某个中间件的进阶用法。面试官把它放在基础篇,很可能是在考察:
- 你是否有读规范、看官方 JSR 的习惯;
- 你是否清楚 Spring Boot 的 Cache 抽象底层是可以对接不同标准实现的;
- 你遇到多套缓存组件混用时,能不能用统一 API 收敛复杂度。
这三点比“会不会写@Cacheable注解”重要得多。毕竟@Cacheable本身就是 Spring 的封装,真正底层是CacheManager,而CacheManager背后完全可以接 JCache。
2. 集成前先选对实现:Ehcache、Hazelcast、Caffeine怎么挑
2.1 主流 JCache 实现对比
集成 JCache 的第一步不是写代码,而是选实现。市面上支持 JSR-107 的实现不少,但各自定位完全不同,选错后面全是坑。我常用的是以下几种:
| 实现 | 定位 | 缓存模式 | 适合场景 |
|---|---|---|---|
| Ehcache 3 | 老牌本地缓存 + JCache 完整支持 | 本地堆内/堆外、磁盘 | 单机应用、Spring Boot 常见选择 |
| Caffeine | 高性能本地缓存 | 本地内存 | 高并发读为主的单机缓存 |
| Hazelcast | 分布式缓存/数据网格 | 集群分布式 | 多实例共享缓存 |
| Infinispan | 分布式缓存 | 集群/本地 | 需要多种一致性和持久化能力 |
个人经验:如果你只是想在 Spring Boot 里把 JCache 用起来,Ehcache 3 是最顺手的,文档全,和 Spring Boot 的兼容性也成熟。Caffeine 虽然性能很猛,但它对 JSR-107 的支持相对收敛,部分高级配置要走自己的CaffeineCacheManager才方便。Hazelcast 适合需要分布式缓存拓扑时使用,但它把“缓存”做成了“数据网格”,复杂度也随之上升。
2.2 Maven 依赖里那点版本细节
选好实现后,依赖别漏。标准 API 和实现是两个坐标,都要加。我用 Ehcache 3 举例:
<dependency> <groupId>javax.cache</groupId> <artifactId>cache-api</artifactId> <version>1.1.1</version> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.10.8</version> </dependency>很多人只加ehcache,忘加cache-api,结果代码里javax.cache的接口根本引用不到。反过来,只加cache-api不加实现,Spring Boot 启动时会找不到具体的CachingProvider,也会报错。
版本上有一点要留神:javax.cache:cache-api的 1.1.1 是 JSR-107 正式版,目前主流。如果你用的是 Spring Boot 3 系列,可能面临javax.cache和jakarta.cache的差异,需要先确认容器里实际加载的是哪一套 API。这块很容易踩坑,建议在引入依赖后立刻打印一下Caching.getCachingProvider().getClass().getName()来确认实现真的加载进来了。
2.3 确认你用的是标准 API 还是实现私有 API
JCache 集成过程中最大的诱惑是用实现私有 API。比如 Ehcache 3 自己有一套org.ehcache.CacheManager,方法名和 JCache 接口很像但类型不同;Caffeine 也有自己的Caffeine.newBuilder()风格 API。一旦你混用,代码就丧失了可移植性,下次换实现必须重写。
我的习惯是:业务代码里只出现javax.cache.CacheManager和javax.cache.Cache,不直接触碰实现类。只有创建CacheManager时,需要指定CachingProvider的实现类名,比如org.ehcache.jsr107.EhcacheCachingProvider,这是不可避免的桥接点。把私有 API 隔离在一个配置类或工厂类中,业务层永远面向标准接口,这是规范的价值所在。
3. Java EE 环境下的 JCache:CDI 接入与默认 CacheManager
3.1 Java EE 中 JCache 的定位和三种获取方式
Java EE 8 体系把 JSR-107 纳入了企业级缓存标准。在完整的 Java EE 应用服务器里,JCache 的接入方式比普通 Java SE 要讲究,因为容器已经替你管理了一部分生命周期、类加载和资源注入。
获取CacheManager通常有三条路径:
- 直接调用
Caching.getCachingProvider(),这是最标准、最通用的方式,适合 Java SE 和 Java EE 都能跑的场景; - 在 CDI 容器里通过
@Produces自己生产CacheManager实例,由容器管理生命周期; - 所在应用服务器如果有内置的 JCache 实现,可能会通过 JNDI 或容器配置暴露默认的
CacheManager。
我实际测试下来,最稳妥的还是第二条路径。因为直接依赖服务器内置 JCache 的 JNDI 名,一旦换个应用服务器,配置就全废。你自己用 CDI Producer 包装一层,后面换实现只需要改一个类。
3.2 用 CDI Producer 暴露 CacheManager
Java EE 里启用 JCache 的经典做法是这样的:
import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.spi.CachingProvider; import javax.enterprise.context.ApplicationScoped; import javax.enterprise.inject.Produces; @ApplicationScoped public class CacheManagerProducer { @Produces @ApplicationScoped public CacheManager createCacheManager() { CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); MutableConfiguration<String, Object> configuration = new MutableConfiguration<String, Object>() .setTypes(String.class, Object.class); if (cacheManager.getCache("orders") == null) { cacheManager.createCache("orders", configuration); } return cacheManager; } }注意一个细节:provider.getCacheManager()默认返回的是进程内单例管理器,多次调用返回同一个实例;createCache时如果缓存名已经存在,直接创建会抛异常,所以要先判断。这里我不建议在 Producer 里把所有缓存都建好,而是只建核心几个,剩下交给业务方按需创建。
3.3 声明式注解在 Java EE 中如何生效
JCache 本身也定义了一套声明式注解,包括@CacheResult、@CachePut、@CacheRemove、@CacheRemoveAll、@CacheDefaults。在 Java EE 环境里,它们的语义和 Spring 的@Cacheable很相似:
@CacheResult(cacheName = "orders") public Order findOrderById(String orderId) { return orderRepository.find(orderId); }但这里有个容易忽略的前提:注解要生效,必须有实现方提供的拦截器或 CDI 扩展在工作。不同实现对这个的支持程度不一样,有的需要显式引入扩展模块,有的需要你配置beans.xml开启bean-discovery-mode="all"。如果只是把注解写上,既没有拦截器也没有代理,方法照常执行,缓存完全不起作用。
所以我在 Java EE 项目里,更推荐先用 CDI Producer 把CacheManager注入到业务代码,然后手动cache.get()和cache.put()。这样虽然看起来多写两行,但至少在启动时就能直观判断缓存是否真的建立,而不用猜注解有没有被拦截。
4. Spring Boot 集成 JCache 的完整落地过程
4.1 原理:Spring Cache 抽象与 JCache 之间的适配层
Spring Boot 的缓存体系不是 JCache,而是 Spring 自己的CacheManager抽象。它允许你接入多种缓存后端:ConcurrentMapCache、RedisCacheManager、CaffeineCacheManager,当然也包括 JCache。
Spring Boot 遇到类路径上有javax.cache的CacheManager和实现时,会自动装配一个JCacheCacheManager。这个适配器的工作就是把 Spring 的Cache接口映射到javax.cache.Cache:Spring 里cache.get(key)会被包装成 JCache 的cache.get(key),Spring 的缓存名则对应 JCache 的缓存名。
这意味着你写的仍然是@Cacheable、@CachePut这些 Spring 注解,但底层执行的是 JCache 标准实现。面试时如果能说出这层适配关系,说明你对两端都不是只停留在表面 API。
4.2 依赖与配置:pom.xml 和 application.yml 怎么写
Spring Boot 集成 JCache 的 Maven 坐标,我在实际项目里固定成下面这套:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>javax.cache</groupId> <artifactId>cache-api</artifactId> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.10.8</version> </dependency>spring-boot-starter-cache是必须的,它会帮我们装配CacheAutoConfiguration。cache-api提供标准接口,ehcache提供具体实现。如果你只加starter-cache不加 JCache 依赖,Spring Boot 会退回到ConcurrentMapCacheManager,此时也能跑,但并没有真正启用 JCache,这点后文专门讲。
配置写进application.yml:
spring: cache: type: jcache jcache: provider: org.ehcache.jsr107.EhcacheCachingProvider config: classpath:ehcache.xml cache-names: orders, users这里最关键的是spring.cache.type=jcache,它强制告诉 Spring Boot 使用 JCache 类型。provider指定实现类名,config指向配置文件,cache-names用来声明启动时必须创建哪些缓存。如果你不写cache-names,缓存会在第一次访问时动态创建,这也容易带来 TTL 失效的问题,后面细说。
4.3 通过 ehcache.xml 定义缓存模板与过期策略
JCache 接口本身不负责具体过期时间,过期策略由实现层面配置。Ehcache 3 的 XML 是最直观的配置方式,我会在resources下放一个ehcache.xml:
<config xmlns="http://www.ehcache.org/v3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.10.xsd"> <cache alias="orders"> <expiry> <ttl unit="minutes">10</ttl> </expiry> <heap unit="entries">10000</heap> </cache> <cache alias="users"> <expiry> <ttl unit="seconds">30</ttl> </expiry> <heap unit="entries">5000</heap> </cache> </config>alias就是缓存名,必须和 Spring 注解里的cacheNames完全一致。expiry中的ttl指写入后固定存活时间,heap限制堆内最大条目数,超过后按实现策略淘汰。
有一点很反直觉:你在@Cacheable注解里是设置不了 TTL 的。注解只管“要不要缓存、用哪个缓存、 key 是什么”,而“缓存多久”必须通过CacheManager创建缓存时的配置决定。如果你用代码创建缓存,写法是:
MutableConfiguration<String, Order> config = new MutableConfiguration<>(); config.setExpiryPolicyFactory(FactoryBuilder.factoryOf( new DurationExpiryPolicy(DurationExpiryPolicy.ETERNAL) ));我个人更推荐 XML 声明式配置,因为它能让运维同学不读代码就能看到每个缓存的存活时间。
4.4 代码实操:@Cacheable、@CachePut、@CacheEvict 示例
配置完成后,业务代码里就可以直接用 Spring 注解了。以订单服务为例:
@Service public class OrderService { private final OrderRepository orderRepository; public OrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Cacheable(cacheNames = "orders", key = "#orderId") public Order findById(String orderId) { return orderRepository.findById(orderId); } @CachePut(cacheNames = "orders", key = "#order.id") public Order save(Order order) { return orderRepository.save(order); } @CacheEvict(cacheNames = "orders", key = "#orderId") public void delete(String orderId) { orderRepository.deleteById(orderId); } }三个注解我各说一句,避免陷入“会写但讲不清用意”的状态:
@Cacheable:先查缓存,缓存没有就执行方法,再把结果放入缓存,适合读多写少的查询场景。@CachePut:无论缓存里有没有都执行方法,并把返回值更新到缓存,适合新增或修改后需要同步缓存的数据。@CacheEvict:执行方法后删除指定缓存条目,适合删除操作,防止缓存里残留旧数据。
key的写法很关键。#orderId表示取方法参数的orderId字段,如果参数是对象,可以写#order.id,这部分用的是 Spring 的 SpEL 表达式。
5. 生产环境最容易翻车的四个细节
5.1 没有实现依赖时的“假缓存”
我第一次在 Spring Boot 里“号称启用 JCache”时就翻过车。当时只加了spring-boot-starter-cache,没加cache-api和实现,结果启动日志无声无息地出现了ConcurrentMapCacheManager的注册信息,缓存也能正常命中,看起来一切正常。但ConcurrentMapCacheManager只是内存里的ConcurrentMap,没有 TTL、没有淘汰策略、没有缓存事件,数据会一直堆在内存里,直到 JVM 重启。
这带来的不是立刻报错,而是事后 OOM。所以启用 JCache 后,我建议你在日志或debug模式下看一眼实际装配的缓存管理器类型。简单的方法是在配置里打印:
@Bean public ApplicationRunner cacheRunner(CacheManager cacheManager) { return args -> System.out.println("CacheManager -> " + cacheManager.getClass().getName()); }看到org.springframework.cache.jcache.JCacheCacheManager才算真的启用成功,看到ConcurrentMapCacheManager就要回去补依赖。
5.2 缓存名对不上,TTL 会静默失效
Spring Boot 的JCacheCacheManager默认支持动态创建缓存,也就是说当你用到@Cacheable(cacheNames = "orders")时,如果ordinates这个缓存不存在,它会顺手创建一个新缓存。
问题在于:动态创建的缓存用的是默认配置,通常没有 TTL,也没有大小限制。如果你在ehcache.xml里写了orders的 TTL 为 10 分钟,但业务代码里拼出来的缓存名是order(少了个 s),那么新缓存不会复用 XML 里的配置,数据永远不过期。
我在项目里踩过这个坑之后,定了两条规矩:一是cacheNames必须使用常量类里的字段,禁止散落字符串;二是启动时强制校验每个用到的缓存名都已在配置中存在,不允许动态创建。
5.3 序列化和 ClassLoader 的坑
JCache 规范本身没有规定缓存值必须实现Serializable,但一旦你用了堆外存储、磁盘持久化或分布式缓存,序列化就是绕不开的问题。Ehcache 3 在默认配置下,堆内缓存可以存储对象引用,堆外和磁盘则要求对象可序列化。
更隐蔽的是 ClassLoader 问题。在 Java EE 和 Spring Boot 的多模块场景中,同一个类名可能由不同 ClassLoader 加载。缓存里存入的是 A 模块加载的Order,取出来时如果反序列化上下文不对,会出现ClassCastException,但日志里表现毫无规律。
我的处理方式是:缓存值尽量用简单 DTO,而不是把 ORM 实体直接塞进去。实体类往往带延迟加载代理、关联集合、序列化特殊逻辑,一旦代理穿透失败,排查起来非常痛苦。直接在缓存层隔离一个值对象,既安全又稳定。
5.4 自调用绕过代理导致缓存不生效
Spring 的缓存注解依赖 AOP 代理。如果你在同一个类里写私有方法,然后在另一个方法里直接调用它,比如:
@Cacheable(cacheNames = "orders", key = "#orderId") public Order findById(String orderId) { return queryDatabase(orderId); } public Order getWithLog(String orderId) { // 这是同一个类内部调用,会绕过代理,缓存不生效 return this.findById(orderId); }this.findById()调用的是目标对象本身的方法,而不是 Spring 生成的代理对象,因此注解不会被拦截。
解决方法也不复杂:要么把被缓存的方法放到另一个 Spring Bean 里,让对方 bean 来调;要么通过AopContext.currentProxy()获取代理对象显式调用。但最省心的还是第一种,类之间互相调用,代理天然生效。这道题如果面试官追问,你能把代理机制讲清楚,是很大的加分项。
6. 面试追问与高分手势:怎么把这道题讲出广度
6.1 追问一:JCache 和 Spring Cache 什么关系
这是一个非常常见的追问组合。直接回答:Spring Cache 是 Spring 框架自己造的抽象层,它定义了CacheManager和Cache接口;JCache 是 Java 标准规范,定义了javax.cache.CacheManager等接口。Spring Boot 通过JCacheCacheManager把 Spring 的CacheManager接口适配到了 JCache 实现上。
做个类比:Spring Cache 是适配器,JCache 是标准插座,Ehcache、Hazelcast 是接入插座的不同电器。你换电器时不需要改开关面板,因为面板规范已经锁定了。
6.2 追问二:多实例部署下 JCache 的缓存一致性
这个问题要小心回答。JCache 规范本身没有强约束多实例之间的数据同步,它更像是一套本地缓存标准。所以如果你直接用 Ehcache 做 JCache,在多个应用实例上部署,每个实例各有一份本地缓存,互相之间不会自动同步,可能出现数据不一致。
解决方向有三个:
- 接受最终一致,配置很短的 TTL,让不同实例的数据尽快过期;
- 换成 Hazelcast 这类分布式 JCache 实现,由集群节点之间同步缓存;
- 引入 Redis 等外部缓存,把本地缓存做成 L1,外部缓存做成 L2,但这就已经超出 JCache 规范范围了。
面试时说出“规范不保证分布式一致性,一致性由具体实现决定”这句话,对方就能明白你没有被规范绑架。
6.3 可复用的完整回答框架
如果要把这道题从头答到尾,我会按这个框架来:
- 先说本质:JCache 是 JSR-107 定义的标准缓存 API,包含
CachingProvider、CacheManager、Cache等核心接口。 - 说集成前提:Java EE 下优先用 CDI Producer 管理
CacheManager;Spring Boot 下加入spring-boot-starter-cache、cache-api和一个 JCache 实现,然后配置spring.cache.type=jcache。 - 说关键配置:Ehcache XML 定义缓存名、TTL 和堆内大小,缓存名必须和注解的
cacheNames一致。 - 说使用方式:业务代码用
@Cacheable、@CachePut、@CacheEvict,底层通过 Spring 适配器调用 JCache API。 - 说风险边界:多实例时规范不保证一致性;缓存值要注意序列化和 AOP 代理问题,动态创建的缓存会丢失 TTL。
我面试时会刻意把第 5 点放进来,因为它是区分“只用过”和“真踩过坑”的分水岭。面试官听到你能主动讲出动态缓存导致 TTL 失效、自调用绕过代理这种实战细节,通常不会再纠结你有没有背过文档。
回到这道题本身,我觉得它最妙的地方就在于“标准”两个字。Java 这门语言从来不缺各种框架,缺的是把复杂生态收敛成统一接口的能力。JCache 不一定是你项目里最高性能的缓存方案,但它确实是所有 Java 工程师都应该了解的底层规范。你把它和 Spring Cache 的关系理顺了,以后再接触任何缓存组件都会很快上手。