免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

JCache规范中Cache的put与get方法:键不存在时的行为深度解析

JCache规范中Cache的put与get方法:键不存在时的行为深度解析 2025年5月18日的每日一道面试题落点在 Java 基础篇题目是JCacheJSR-107规范里Cache 接口的 put 和 get 方法在键不存在时的行为是什么单看这句问法很多同学第一反应是“这有什么好问的get 返回 nullput 就是塞进去呗”。但如果面试官接着追问“get 返回 null 一定代表键不存在吗”“put 和 putIfAbsent 在并发场景下有什么区别”“为什么 JCache 要把 put 设计成 void而不是像 Map.put 一样返回旧值”你就会发现这个“基础题”根本不好答。这道题适合所有准备高级 Java 岗位面试的开发者也适合日常在用 Spring Cache、Ehcache 3、Hazelcast 的同学。它表面考 API 记忆实际考的是对规范契约、并发语义和实现差异的综合理解。今天这篇笔记我会按三个层次把它拆开先讲规范的原始意图再亲手写一段验证代码给你看最后把面试时容易翻车的扩展点全部过一遍。1. 面试官丢出 put 和 get到底在考你什么1.1 从 JSR-107 的定位说起JCache 的全称是 JSR-107由 JCPJava Community Process制定目标是给 Java 生态提供一个统一的缓存 API。在它出现之前缓存领域完全是一盘散沙Ehcache 有自己的一套 APIGuava Cache 有自己的一套写法Infinispan、Redis 客户端也各有各的调用方式。业务代码一旦选了某个缓存实现想换一个就得改大量代码。JSR-107 做了一件很像 JDBC 的事把 CacheManager、Cache、Cache.Entry、CacheLoader、CacheWriter、ExpiryPolicy 这些概念统一成一套标准底层实现随便切换业务代码面对的是 javax.cache 包下的稳定接口。JSR-107 于 2014 年发布 1.0 版本后来更新到 1.1目前主流版本是 1.1.1。代表性实现有 Oracle 主导的 Reference ImplementationRI、Ehcache 3、Hazelcast、Infinispan、cache2k 等。Spring Framework 从 4.1 开始也对它提供了支持所以我们经常在 Spring Boot 项目里把 JCache 配成底层缓存抽象业务代码里只看到 Spring 的 Cacheable 注解实际工作的是某个 JSR-107 实现。理解了背景你就能明白为什么面试官喜欢拿“put 和 get 在键不存在时的行为”来开刀。Cache 接口的核心语义本质上是把一组键值对存进去、取出来而键不存在是一个需要被精确区分的状态。规范对它的定义直接决定了 containsKey、putIfAbsent、getAndRemove 这些相关方法为什么存在也决定了你在写缓存穿透保护代码时应该用哪个方法、不应该用哪个方法。1.2 面试官想听的是什么层次的答案这道题如果只答一句话面试官基本不会给高分。高级岗位的考察点从来不是“记没记住 API”而是“能不能把接口契约、实现细节和工程场景三者对齐”。我平时给组里同学做模拟面试时会把答案分成三个层次。第一层是背答案get 不存在返回 nullput 不存在就插入。第二层是懂规范get 返回 null 存在二义性有可能是键不存在也有可能是键映射到了 null要区分必须用 containsKey。put 在键不存在时执行新增在键存在时执行覆盖方法本身没有返回值所以拿不到旧值。第三层是懂实现和并发不同缓存实现对于 null 值的支持策略并不完全一致底层存储结构也决定了 put 的并发表现如果你要写“不存在才插入”的逻辑应该用 putIfAbsent而不是先 containsKey 再 put后者存在并发窗口。大部分面试者能说清楚第一层少部分能说到第二层能把第三层讲明白的人通常都有过真实项目经验。这篇笔记的核心目的就是帮你把这三个层次的内容全部补齐保证你在面试现场即使被层层追问也不会卡壳。2. 键不存在时get 与 put 的规范契约拆解2.1 getnull 是返回值更是二义性的来源先看 javax.cache.Cache 接口对 get(K key) 的定义返回当前缓存中指定键映射的值如果缓存里没有这个键的映射返回 null。这句话本身没有任何歧义问题是它后面还跟着一句补充说明——如果缓存中某个键显式映射到了 nullget 同样返回 null。也就是说null 这个返回值同时承担了“没有这个键”和“这个键的值就是 null”两种语义。为什么规范要允许这种情况因为 JCache 的设计参考了 java.util.Map 和 java.util.concurrent.ConcurrentMap 的约定。Java 集合框架里Map.get 也是这么设计的返回值等于 null 时你永远无法直接判断键是否真的存在必须再调 containsKey。JCache 的 Cache.get 沿用了这一套约定。但你得留个心眼规范只定了接口是否真的允许你往缓存里放 null 值由实现自己决定。有些实现底层用了 ConcurrentHashMap 做存储ConcurrentHashMap 本身就不允许 null value所以这类实现会在 put 或 get 时直接抛 NullPointerException。另一些实现明确支持 null 值于是 get 返回 null 就有了真实的二义性。这是面试里非常经典的一个扣分点。很多人只知道 get 不存在返回 null却说不清“null 不等于不存在”这个细节。你在回答时可以直接抛出这句话从接口契约层面来说get 返回 null 只能说明这个键“没有被映射到一个非 null 的值”要判断键本身是否存在请用 Cache.containsKey。这样答面试官立刻知道你认真读过 Javadoc。2.2 put新增、覆盖以及一个被忽略的 void再看 put(K key, V value)。它的行为描述很直接把指定的值关联到指定的键上如果这个键之前已经有映射那么旧值被替换成新值如果之前没有映射就新增一个映射。这句话翻译得很通俗真正的考点在方法签名——put 的返回值是 void而 ConcurrentMap.put 返回的是旧值。JCache 之所以把 put 设计成 void我个人的理解是它想让 API 职责更干净。put 只负责写不负责把旧值带回来如果你确实想知道旧值是什么去调 getAndPut(K key, V value)它会把旧值作为返回值给你。这个设计也顺带减少了误用概率Map.put 的返回值经常被忽略但它在并发语义里其实隐含了一次读取操作而 JCache 把读和写拆得更彻底。在“键不存在”这个前提下put 的另一个容易被人忽略的细节是put 不保证“检查 写入”是一个原子组合操作。单看一次 put它确实是原子的要么新增成功要么覆盖成功但你如果写“先 containsKey 判断不存在再 put 写入”这两个动作之间完全可能被其他线程插入最终导致两个线程都以为自己是第一个写入的人。规范里真正为“只有不存在才写入”准备的原子方法是 putIfAbsent(K key, V value)。2.3 一张表对照键存在的几种状态为了把 get、put、containsKey 的关系彻底讲清楚我整理了一张对照表面试的时候也可以直接在脑子里调出来用缓存的真实状态get 返回值put 之后会发生什么containsKey 返回值键完全不存在null新增映射缓存条目数加一false键存在值为非空对象 vv覆盖成新值条目数不变true键存在值显式为 null仅实现支持时null覆盖成新值条目数不变true键已过期但尚未被清理null相当于键不存在put 执行新增false键存在但当前线程 query 时加载失败read-through 模式null 或抛出异常取决于实现与配置false这张表里前两行是基础语义第三行是规范留给实现的扩展空间第四行涉及到 ExpiryPolicy 的懒过期处理第五行则是 CacheLoader 与异常处理的结合。实际项目里大多数问题都出在把第四行误解成第一行或者把第二行当成第三行来处理。你先记住一个最简单的工程结论凡是 get 返回 null 之后还要做“键是否真的不存在”判断的场景都把 containsKey 用起来除非你 100% 确定当前实现不允许 null 值。3. 动手验证跑一个最小 JCache 用例看清真实行为3.1 引入依赖别把 API 和实现搞混看再多文档都不如跑一段代码印象深刻。这里我用 Maven 给你准备一个可以直接 copy 的最小工程配置。JCache 的标准 API 在 javax.cache:cache-api 里这只是一个接口 jar 包没有任何实现逻辑所以我们还要额外引入一个实现。RIReference Implementation是我用得最多的验证实现它最贴近规范适合做这类行为确认。dependency groupIdjavax.cache/groupId artifactIdcache-api/artifactId version1.1.1/version /dependency dependency groupIdorg.jsr107.ri/groupId artifactIdcache-ri-impl/artifactId version1.1.1/version /dependency引入依赖时最容易犯的一个错误是只引入 cache-api然后在运行时报 ClassNotFoundException。cache-api 是接口层cache-ri-impl 才是实现层两个都要拿。如果你在 Spring Boot 里用 JCache 做底层实现可能还要引入 spring-boot-starter-cache并在配置里指定 Provider不过那属于另外一套场景今天的验证直接跑 RI 就够了。3.2 最小验证用例get、put、containsKey 一次跑通下面这段代码是我验证队列里常驻的一段覆盖了今天题目涉及的所有核心场景。流程很直接先拿 CachingProvider再拿 CacheManager创建一个新的 Cache然后依次调用 get、put、containsKey、putIfAbsent、getAndRemove。import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; public class JCacheBehaviorDemo { public static void main(String[] args) { CacheManager cacheManager Caching.getCachingProvider().getCacheManager(); CacheString, String cache cacheManager.createCache(demo, new MutableConfigurationString, String()); // 1. 键不存在的场景 String miss cache.get(name); System.out.println(get 不存在的 key - miss); System.out.println(containsKey 不存在的 key - cache.containsKey(name)); // 2. put 一个键值对 cache.put(name, zhang); System.out.println(put 之后 get - cache.get(name)); System.out.println(put 之后 containsKey - cache.containsKey(name)); // 3. 再 put 同一个 key覆盖旧值 cache.put(name, li); System.out.println(再次 put 之后 get - cache.get(name)); // 4. putIfAbsent 在键已存在时的行为 boolean absent cache.putIfAbsent(name, wang); System.out.println(putIfAbsent 已存在的 key - absent , 当前值 - cache.get(name)); // 5. getAndRemove 返回旧值 String removed cache.getAndRemove(name); System.out.println(getAndRemove - removed , 之后 containsKey - cache.containsKey(name)); cacheManager.close(); } }这段代码的运行结果和你直觉预期会非常一致get 不存在的 key 打印 nullcontainsKey 打印 falseput 之后 get 返回 zhang再次 put 之后 get 返回 li说明覆盖生效putIfAbsent 返回 false当前值仍然是 li最后 getAndRemove 返回 li缓存里不再包含 name 这个键。我特别建议你亲手跑一遍因为 RI 会在某些细节上打破你的直觉。比如当你想验证“键存在但值为 null”的场景时RI 可能会因为内部存储方案直接抛异常而换一个实现又允许你放 null。你在面试场上如果能讲出“我实际验证过我们项目用的 XX 实现它的行为是……”说服力会强很多。3.3 再深一层get(K, Callable)、putIfAbsent 与并发JCache 的 Cache 接口里还有一个很容易被忽略的方法get(K key, Callable? extends V valueLoader)。它和 get 一样要传 key区别是当键不存在时会执行业务传入的 Callable把返回值作为新值写入缓存然后返回这个值。第二次再 get 同一个 key就直接命中缓存不会再执行 Callable。这个方法和 CacheLoader 的 read-through 模式不一样它更轻量不需要在创建 Cache 时做复杂配置非常适合“某一个 key 不存在时用本地方法回源”的场景。我写一个简单示例CacheString, String cache cacheManager.createCache(callable-demo, new MutableConfigurationString, String()); // 第一次 getkey 不存在执行 Callable 并写入缓存 String v1 cache.get(order:1001, () - loadFromDb(order:1001)); // 第二次 getkey 已存在不再执行 Callable String v2 cache.get(order:1001, () - loadFromDb(order:1001));这里有个并发细节值得展开规范并没有强制要求多个线程同时调用 get(K, Callable) 时只执行一次 Callable实现优秀的缓存会尽量做成 single-flight即多个线程等待同一个加载结果但写得不严谨的实现完全可能让每个线程都执行一次 Callable。你在面试时如果碰到这个问题可以补一句JCache 的规范层面给出了这个方法的语义但没有承诺全局只执行一次所以不要把它当成一个标准化的防并发穿透方案。4. 扩展考点CacheLoader、ExpiryPolicy 与并发写入4.1 read-through 模式下 get 的行为会被“悄悄改变”前面我讲的 get 语义都是基于最朴素的 Cache 接口定义。但在真实企业级项目里很少有人会直接创建不带任何配置的裸缓存更多是配了 CacheLoader 或者 CacheWriter。这时 get 和 put 的行为就不再是单纯的“读缓存”和“写缓存”了。CacheLoader 是 JSR-107 里用于 read-through 模式的组件。创建缓存时如果通过 CacheConfiguration 装配了 CacheLoaderget 方法在键不存在时按具体实现的不同可能自动触发 CacheLoader 去底层数据源加载数据加载完成后把数据放回缓存再把结果返回给调用方。这个行为的好处是业务代码感知不到回源过程坏处是它把“键不存在”和“需要回源加载”混在了一起。如果你本意是想判断缓存里到底有没有这个键你就不能用 get 的返回结果作为依据否则就会把底层数据源不存在的情况和缓存未命中的情况搅在一起。CacheWriter 则是 write-through / write-behind 模式里负责写到底层数据源的组件。当缓存配置了 CacheWriterput 方法不再只更新缓存里的条目它还会触发外部存储的写入。如果外部写入失败部分实现会抛出异常缓存条目是否回滚取决于实现。这个点非常重要因为日常开发里经常有同学在测试环境配置了 write-through然后调 put 发现数据被写进了数据库当场懵掉。我在面试时喜欢把这个扩展点讲成一句总结接口契约只是最底层的约定真正运行时行为由 CacheConfiguration 里的 CacheLoader、CacheWriter、ExpiryPolicy 一起决定。大家回答“put 和 get 在键不存在时的行为是什么”时最好先声明自己在哪个配置前提下讨论。4.2 过期条目的 get返回 null 不等于键一定不存在JCache 的 ExpiryPolicy 提供了三种过期时间维度CreatedExpiry 表示从条目创建开始计时AccessedExpiry 表示每次访问后重新计时ModifiedExpiry 表示每次修改后重新计时。缓存项的过期判断通常不会像数据库一样准点扫描而是采用懒删除策略get 或 containsKey 访问某个键时实现才去检查它的过期时间如果已经过期就当它不存在顺手做一次清理。这个机制带来的一个有意思现象是一个明明被 put 过的键在过了过期时间之后get 返回 nullcontainsKey 也返回 false看起来就像从来没有被 put 过一样。你在做问题排查的时候如果发现 get 一直返回 null不要只想着“是不是没插入成功”也要检查一下 ExpiryPolicy 是不是设得太短了或者缓存工厂方法里是不是不小心配了一个全局过期时间。面试里如果被问到“缓存过期和数据一致性怎么处理”这一类问题可以把 JCache 的过期语义和延迟删除机制结合起来回答。尤其要强调一点过期之后 get 返回 null这个 null 在语义上等价于键不存在所以如果业务层拿它去查了一次数据库可能造成一次额外的回源请求在高并发场景下这道防线没做好就是所谓的缓存穿透。4.3 putIfAbsent 才是并发下的正确姿势我自己见过很多真实项目里的并发写法不是 put(null) 就是 containsKey put很少用 putIfAbsent。原因很简单很多同学是从 Map 的底层思维迁移过来的习惯性认为“先判断再写入”是最自然的写法。但 JCache 的 Cache.putIfAbsent 就是为了解决这个并发窗口而生的。拿最经典的一个场景举例多个线程同时处理同一个订单号如果缓存里还没有这个订单的状态就初始化一个默认状态如果已经有状态就忽略其他线程的初始化。错误写法是这样的if (!cache.containsKey(orderId)) { cache.put(orderId, initStatus); }线程 A 和线程 B 同时执行到 containsKey两个都发现键不存在然后 A 先 putB 后 put最终 B 的 initStatus 覆盖了 A 写入的值。虽然看起来结果可能一样但你已经失去了“首次写入者胜出”的控制权。正确的写法是boolean firstWrite cache.putIfAbsent(orderId, initStatus); if (firstWrite) { // 当前线程才是第一个写入的人 } else { // 其他线程已经写过了 }putIfAbsent 在键不存在时写入并返回 true在键已存在时不做任何修改并返回 false。这个语义和 ConcurrentMap.putIfAbsent 是一致的整个“检查 写入”是一个原子操作。回答这道题时主动把这个点抛出来面试官的认可度会明显上升。5. 实战避坑与现场应答思路5.1 我在项目里用 JCache 踩过的三个坑第一个坑是把 get 返回 null 当成“数据源里一定没有这个数据”。我们有段时间做了一个商品信息的缓存查询接口逻辑是先查缓存缓存没命中再查商品服务。压测时发现某个不存在的商品 ID 会被反复打到商品服务上导致服务端压力很大。后来排查原因很简单缓存里完全没有这个键每次 get 都返回 null业务层就把 null 理解成“需要回源”但回源之后发现商品服务也不存在于是每次请求都走一次全链路。解决方案是对商品服务透传的“不存在”结果做了缓存兜底同时把查询入口改成先 containsKey 判断再决定是否需要回源。第二个坑是配置了 CacheLoader 但没意识到 get 会自动加载。我们在一个订单系统里给缓存配置了从历史库加载订单明细的 CacheLoader结果某个统计接口在遍历大量订单号时因为频繁触发未命中加载把历史库的连接池打满了。当时看代码get 方法的写法没有责任问题出在配置层read-through 模式把“查一下缓存”悄悄地变成了“查缓存 查历史库”。这个教训让我后来在项目里约定读多写少的纯查询场景可以放心用 read-through但对调用量巨大的访问路径要非常克制地使用 CacheLoader最好在初始化缓存时就把热点数据主动 load 进来。第三个坑和序列化相关。在用 Hazelcast 这类分布式 JCache 实现时put 进去的对象会经过序列化再保存。业务方修改了 get 出来的对象属性以为缓存已经跟着变了实际上改的只是本地的反序列化副本缓存里的原对象纹丝不动。这类问题在测试阶段很难发现往往要等到线上出现数据不一致才暴露。后来我们的统一规范是任何取出来的对象都是只读的要改就重新 put 一个新对象回去。5.2 面试现场我会怎么组织这段答案如果面试官让我回答这道题我会按“契约、实现、并发”三个维度组织语言控制在两分钟左右“从 javax.cache.Cache 接口的契约来讲get 方法在键不存在时返回 null但这个 null 有歧义因为如果实现允许存 null 值它也可能是那个键对应的真实值。要区分这两种情况必须调 containsKey。put 方法在键不存在时执行新增映射在键存在时执行覆盖方法返回值是 void如果想拿到旧值应该用 getAndPut。然后我想补充两个容易被忽略的点第一如果缓存配置了 CacheLoader实际运行时 get 在未命中时可能会触发回源加载接口语义会被 read-through 模式扩展第二高并发下如果需要‘只有不存在才写入’的语义应该用 putIfAbsent而不是 containsKey put 的组合因为后者存在竞态窗口。”这段话说完面试官基本就知道你不仅理解规范而且看过实现、做过实践。剩下的时间是你主动掌控节奏的机会你可以顺势把 CacheWriter 和 ExpiryPolicy 串进去讲也可以反问一句面试官关注的是本地缓存还是分布式缓存场景。能走到这一步这道题就算答透了。
返回列表