ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JCache规范解析:从JSR-107到Spring Boot集成实践

JCache规范解析:从JSR-107到Spring Boot集成实践 前几天有个读者私信我说他面试某厂 Java 高级岗时被问了一道“基础篇”的题JCacheJSR-107在 Java EE 或 Spring Boot 环境中如何集成和启用。他当场愣了一下——平时用的都是 Redis、Caffeine没正经研究过 javax.cache 这套标准 API。其实这道题问得很有水平表面考的是“会不会配依赖”内里考的是你有没有规范意识、能不能在 Spring Cache 抽象之外说清 JCache 的本质。这篇文章我就从面试题角度入手把 JCache、JSR-107 的底层逻辑、Java EE 和 Spring Boot 两种集成路径、以及我实际跑项目时踩过的坑完整捋一遍。1. JCacheJSR-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、ExpiryPolicyJCache 的核心接口并不多但每一个都有明确职责面试时可以按这个顺序讲接口职责生活化类比CachingProvider负责创建和管理CacheManager是桥接 SPI 的入口缓存行业的施工队CacheManager管理一组Cache可以创建、销毁、获取缓存实例一个物业公司管理多栋楼CacheK,V真正存取数据的缓存对象类似Map但带生命周期和事务语义楼里的房间Cache.EntryK,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 groupIdjavax.cache/groupId artifactIdcache-api/artifactId version1.1.1/version /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.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 还是实现私有 APIJCache 集成过程中最大的诱惑是用实现私有 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 环境下的 JCacheCDI 接入与默认 CacheManager3.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 暴露 CacheManagerJava 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(); MutableConfigurationString, Object configuration new MutableConfigurationString, 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-modeall。如果只是把注解写上既没有拦截器也没有代理方法照常执行缓存完全不起作用。所以我在 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.CacheSpring 里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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdjavax.cache/groupId artifactIdcache-api/artifactId /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.10.8/version /dependencyspring-boot-starter-cache是必须的它会帮我们装配CacheAutoConfiguration。cache-api提供标准接口ehcache提供具体实现。如果你只加starter-cache不加 JCache 依赖Spring Boot 会退回到ConcurrentMapCacheManager此时也能跑但并没有真正启用 JCache这点后文专门讲。配置写进application.ymlspring: cache: type: jcache jcache: provider: org.ehcache.jsr107.EhcacheCachingProvider config: classpath:ehcache.xml cache-names: orders, users这里最关键的是spring.cache.typejcache它强制告诉 Spring Boot 使用 JCache 类型。provider指定实现类名config指向配置文件cache-names用来声明启动时必须创建哪些缓存。如果你不写cache-names缓存会在第一次访问时动态创建这也容易带来 TTL 失效的问题后面细说。4.3 通过 ehcache.xml 定义缓存模板与过期策略JCache 接口本身不负责具体过期时间过期策略由实现层面配置。Ehcache 3 的 XML 是最直观的配置方式我会在resources下放一个ehcache.xmlconfig xmlnshttp://www.ehcache.org/v3 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.10.xsd cache aliasorders expiry ttl unitminutes10/ttl /expiry heap unitentries10000/heap /cache cache aliasusers expiry ttl unitseconds30/ttl /expiry heap unitentries5000/heap /cache /configalias就是缓存名必须和 Spring 注解里的cacheNames完全一致。expiry中的ttl指写入后固定存活时间heap限制堆内最大条目数超过后按实现策略淘汰。有一点很反直觉你在Cacheable注解里是设置不了 TTL 的。注解只管“要不要缓存、用哪个缓存、 key 是什么”而“缓存多久”必须通过CacheManager创建缓存时的配置决定。如果你用代码创建缓存写法是MutableConfigurationString, 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 管理CacheManagerSpring Boot 下加入spring-boot-starter-cache、cache-api和一个 JCache 实现然后配置spring.cache.typejcache。说关键配置Ehcache XML 定义缓存名、TTL 和堆内大小缓存名必须和注解的cacheNames一致。说使用方式业务代码用Cacheable、CachePut、CacheEvict底层通过 Spring 适配器调用 JCache API。说风险边界多实例时规范不保证一致性缓存值要注意序列化和 AOP 代理问题动态创建的缓存会丢失 TTL。我面试时会刻意把第 5 点放进来因为它是区分“只用过”和“真踩过坑”的分水岭。面试官听到你能主动讲出动态缓存导致 TTL 失效、自调用绕过代理这种实战细节通常不会再纠结你有没有背过文档。回到这道题本身我觉得它最妙的地方就在于“标准”两个字。Java 这门语言从来不缺各种框架缺的是把复杂生态收敛成统一接口的能力。JCache 不一定是你项目里最高性能的缓存方案但它确实是所有 Java 工程师都应该了解的底层规范。你把它和 Spring Cache 的关系理顺了以后再接触任何缓存组件都会很快上手。
返回列表