多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JCache接口键不存在时get与put行为详解及避坑指南

JCache接口键不存在时get与put行为详解及避坑指南 后台总有读者在准备Java面试问得比较多的一道基础篇题目就是今天要聊的JCacheJSR-107中 Cache 接口的 put 和 get 方法在键不存在时到底是什么行为。题目确实只有一句话但这句话背后牵出的知识点一点都不基础。我在面试候选人时十个人里差不多只有三四个能答对一半能完整讲清规范语义、读穿透read-through和 null 约束的人就更少了。这篇就把这道题彻底拆开从规范到实现从标准答案到项目里的真实避坑一次说透。无论你是正在冲刺 Java 后端岗位的候选人还是已经在用 Caffeine、Ehcache、Redis 但没认真读过 JCache 标准的开发者这篇都值得看完。因为这个问题的妙处在于它看起来是 API 的查字典题实际上考的是你能否读懂标准接口背后的设计意图以及能否把规范语义迁移到真实的高并发缓存场景中。1. 这道键不存在的题到底在考什么1.1 JCache在Java生态中的位置先说清楚 JCache 是什么。JSR-107 是 Java 官方的缓存 API 规范对应的包名是javax.cache核心接口就是javax.cache.CacheK, V。很多开发者知道 Spring Cache、知道 Caffeine、知道 Ehcache但对 JCache 的印象反而比较模糊这是很可惜的因为它是 Java 生态里唯一的缓存标准抽象。它的定位有点像 JDBC你写的代码只依赖标准接口底层实现可以随时切换。Ehcache 3 实现了 JCacheHazelcast、Infinispan 实现了 JCacheCaffeine 官方也提供了 JCache 适配器Spring Cache 同样支持把 JCache 作为底层 provider。也就是说一旦你掌握了javax.cache.Cache的语义你就在不同缓存产品之间拥有了一套通用语言。面试官考这道题恰恰是因为它属于标准接口的知识。初级工程师往往只看方法名认为get就是查一下、put就是塞进去忽略了标准接口的文档里其实定义了一堆实现细节。而这些细节直接决定你在生产环境会不会写出有隐患的缓存代码。1.2 从Map思维到Cache思维的关键转变我面试时发现答错这道题的人多半是把javax.cache.Cache直接等同于java.util.Map来理解了。这个思维惯性是最大的坑。java.util.Map是数据结构javax.cache.Cache是带策略的组件。两者在行为上有几个非常关键的差异对比项java.util.Mapjavax.cache.Cachenull key / null valueHashMap 允许一个 null key 和多个 null value规范明令禁止key 或 value 为 null 时抛 NullPointerExceptionput 返回旧值Map.put 在有旧映射时保证返回旧值规范语义上可能返回旧值但允许实现因性能原因不返回未命中时的扩展行为Map.get 只是查一下配置了 CacheLoader 时get 可能触发读穿透加载数据生命周期没有过期概念支持 TTL、驱逐策略、失效监听等是否抛运行时异常一般不抛可能抛 CacheException如底层缓存服务器不可用从 Map 思维到 Cache 思维核心是理解键不存在不是一个静态事实。在 JCache 里一个键不存在可能有几种情况从来没写过、写过但已过期、被 LRU 等驱逐策略移除、在分布式环境下还没同步过来。所以面试官问键不存在时行为是什么其实是在试探你能不能意识到这层复杂性。2. 键不存在时 get 返回什么规范和直觉的差距2.1 规范语义返回 null不抛异常先给最直接的结论根据 JCache 规范Cache.get(K key)在键不存在时返回null并且不会抛异常。CacheString, String cache createCache(); String value cache.get(key-does-not-exist); System.out.println(value); // 输出 nullJDK 层面不会抛任何异常这个行为看起来和Map.get一样但有一个前提条件传入的 key 本身不能是 null。如果调用cache.get(null)规范要求实现必须抛出NullPointerException。这一点很多面试者会漏掉。那这个null该怎么理解它代表的是未命中不是出错。缓存命中与否是一种正常状态所以用返回值表达而不通过异常表达。这是我特别想强调的一点良好的接口设计会把正常但没结果和异常分开null是前者CacheException才是后者。如果面试官问get 未命中会不会抛异常答案是不会除非底层缓存本身出问题了。2.2 read-through 模式下get 可能悄悄触发加载这里是最容易和Map.get拉开差距的地方。JCache 支持一种叫读穿透read-through的配置当你创建缓存时指定了CacheLoader并且把ReadThrough配置设为 true那么在get未命中时Cache 实现会自动调用你配置的 loader 去加载数据。调用链大致是这样的应用层调用cache.get(key)Cache 实现先在自身存储中查找命中直接返回 value未命中检查配置若支持 read-through则调用CacheLoader.load(key)loader 返回非 null把它写入缓存并返回该值loader 返回 null则get返回 null且不对 null 做缓存。这意味着什么意味着一个看起来只读的get在未命中时可能悄悄发起一次数据库查询或远程接口调用。流量稍大这就是缓存穿透的温床。我在项目里就踩过类似的坑。排查一个接口的数据库压力时发现某个热点数据的缓存明明很少命中日志里却频繁出现穿透查询。翻代码才发现当时为了方便把所有 key 都放进了 read-through 缓存里结果每个未命中请求都会打一次 DB。后来优化方案是把热点 key 的加载逻辑改成显式控制而不是完全依赖 get 去触发 loader。所以回答这道题时如果你能主动补一句如果配置了 read-throughget 在未命中时会尝试加载加载不到才返回 null面试官眼里你的水平会立刻不一样。2.3 getOrDefault 与 containsKey 的正确使用姿势Cache接口还提供了一个 default 方法V getOrDefault(K key, V defaultValue)。因为 JCache 不允许 null value它的语义和Map.getOrDefault很接近实现基本是V value get(key); return value ! null ? value : defaultValue;但它内部照样走的是get也就是说如果配置了 read-throughgetOrDefault一样可能触发 loader。如果你只是想知道这个 key 有没有值不想额外加载那应该用containsKey。if (cache.containsKey(key)) { // 只判断存在性不会触发 CacheLoader }一个小建议在需要拿值和判断是否存在两种场景交错出现时优先考虑能否在一次get内解决避免先containsKey再get的两次访问。比如你要做一个不存在则加载的逻辑直接用get判断 null 更高效但如果你的本意是只要没有就返回默认值不要打扰底层存储那就用containsKey配合默认逻辑。3. put 方法在键不存在时返回什么这里才有真正的坑3.1 第一层答案put 在键不存在时返回 null再来回答 put 的部分。Cache.put(K key, V value)也是一个很直接的定义把 key 关联到 value如果这个 key 之前已经有映射旧值会被替换如果之前没有映射put 返回null。String previous cache.put(order:1001, PAID); // key 不存在时previous null表示之前没有旧值 String previous2 cache.put(order:1001, REFUNDED); // key 存在时按规范语义previous2 应该是 PAID和 get 一样key 为 null 或者 value 为 null 都会抛NullPointerException。正因为 JCache 不允许 null value所以 put 返回 null 在这里是无歧义的它不会出现 Map 里那种旧值本来就是 null得靠 containsKey 区分的尴尬。3.2 规范允许 put偷懒返回值可能是 best-effort这是整道题里区分度最高、也最容易被人忽略的细节JCache 规范对 put 的返回值并不是强保证。什么意思从javax.cache.Cache的接口语义来看put 方法确实应当返回旧值但规范同时允许实现出于性能原因选择不返回旧值此时实现会返回 null。也就是说put 返回 null 可能表示没有旧映射也可能表示有旧映射但实现没帮你取回来。我见过不少开发者把 put 的返回值当成业务判断依据比如String oldStatus cache.put(key, newStatus); if (oldStatus null) { // 想当然地认为这是第一次写入于是去执行某些初始化逻辑 }在 HashMap 上这么做没问题在 JCache 的某些实现上oldStatus 可能恰好是 null而实际 key 早已存在。这种代码要是跑到生产环境大概率会在并发场景下埋一个很隐蔽的 bug。那为什么规范要留这个口子核心是性能。对一个分布式缓存来说put 时顺便把旧值带回来可能需要额外的网络往返或序列化成本对一些内存型实现来说返回旧值可能又涉及拷贝。规范允许实现量力而行把精准性留给了另一个方法。3.3 getAndPut 与 putIfAbsent把不确定变成确定如果你确实需要拿到旧值并写入新值的原子语义JCache 提供了专门的getAndPutUser previous cache.getAndPut(user: userId, updatedUser); // 这里拿到的 previous 才是被承诺的旧值getAndPut的定位就是原子地执行返回旧值 设置新值它不像 put 那样允许实现偷懒。所以规范的建议很明确业务上需要依赖旧值做判断时别用 put用 getAndPut。另一个高频方法是putIfAbsent它只在 key 不存在时才写入并且返回 boolean 表示是否写入成功boolean firstWrite cache.putIfAbsent(coupon: couponNo, user) ; if (firstWrite) { // 说明这次抢券是第一个成功的 } else { // 说明别人已经先写入了做冲突处理 }这三个方法放一起看逻辑就非常清晰了方法键不存在时键存在时典型场景put返回 null写入成功按规范返回旧值但不保证精确普通的写缓存不依赖返回值做决策getAndPut返回 null写入成功一定返回旧值且原子更新需要旧值做业务判断、审计、计数等putIfAbsent返回 true写入成功返回 false不覆盖幂等控制、防重、抢单占位4. 不同 JCache 实现下的行为差异与答题模板4.1 从实际接触到的实现看方言差异标准接口最怕的就是每个实现都有自己的脾气。从我的实际观察来看Ehcache 3、Hazelcast、Infinispan、Caffeine 的 JCache 适配器等主力实现整体都遵循规范但在 put 返回旧值这个细节上的策略并不完全一致。这不是说哪个实现做错了而是规范给了空间。内存型实现返回旧值的成本相对低实现方自然会去补全这个语义分布式缓存返回旧值可能意味着一次额外的跨节点读取实现方就更有动力把 put 的返回值做成尽力而为。这也正好印证了 3.2 节说的千万不要把 put 的返回值当作跨实现的可靠约定。我在做技术选型时一般会这样判断如果缓存只承载加速读的职责写入方根本不在乎旧值那用 put 完全没问题如果缓存参与了业务流程的状态判断比如要判断是不是第一次写入那就无条件改用getAndPut或putIfAbsent把对实现策略的依赖降到零。标准接口的最大价值是让你在不同实现之间平滑迁移而不是让你赌某个实现会给你什么额外保证。4.2 面试回答模板三段式把分数拿满很多读者问我有没有可以直接背的回答方式。我建议按下面这个三段式来组织既简洁又有层次。第一句直接给契约结论get 在键不存在时返回 null不抛异常put 在键不存在时返回 null表示之前没有旧映射。第二句给规范层面的补充但 put 的返回值不是强保证JCache 规范允许实现出于性能原因不返回旧值所以如果业务需要精确拿到旧值并更新应该用 getAndPut而不是依赖 put 的返回值。第三句给加分项另外JCache 不允许 null key 和 null value传 null 会抛 NullPointerException如果缓存配置了 read-throughget 在未命中时还可能触发 CacheLoader 加载加载不到才会返回 null。这样回答下来从结论到细节再到延伸面试官基本能判断你是真的理解了这个标准而不是背了两行 API 文档。4.3 由这道题引出的其他接口行为这类基础题经常被面试官顺势延伸。比如containsKey在键不存在时返回 false并且它不会触发 read-through 加载remove(K key)在键不存在时返回 false不会抛异常clear()清空缓存后所有 get 都会返回 null。这些行为在规范里都遵循同一个设计原则正常状态通过返回值表达key 为 null 才走异常路径。还有更深入的invoke和CacheEntryProcessor可以在缓存内执行原子操作避免先 get 再 put的竞态。如果你已经能聊到这里说明你对 JCache 的理解已经超出大部分候选人了。不过这些属于进阶玩法面试基础篇通常不需要展开太深。5. 高频追问与项目里的真实避坑5.1 高频追问速查表我把这道题相关的追问和答案要点整理成了表格背熟它面试足够用了。面试官追问答案要点key 为 null 会怎样get/put/putIfAbsent 等方法在 key 为 null 时抛 NullPointerExceptionvalue 可以为 null 吗不可以JCache 全局禁止 null value写入 null 会抛 NullPointerExceptionget 返回 null 一定是没写过吗不一定可能是写过但已过期、被驱逐或者 loader 返回了 nullput 返回 null 一定是没旧值吗不一定规范允许实现出于性能原因不返回旧值怎么区分缓存没值和缓存根本没存应用层无法完全区分containsKey 也只能反映当前时刻可结合业务版本号或过期策略设计get 会不会触发 DB 查询如果配置了 read-through 和 CacheLoader未命中时会触发 loader从而可能查 DB5.2 实战JCache 不能存 null缓存穿透怎么处理项目里最常见的连锁问题是这样的业务查库结果为 null本来想把 null 缓存起来避免下次再查结果发现 JCache 不支持 null value直接抛异常。于是代码往往退化成只有非 null 才写缓存结果就是那个导致 null 的 key 永远穿透到数据库。我常用的一个方案是定义占位对象把查无此值也变成一个可缓存的标记。比如public final class NullMarker { public static final NullMarker INSTANCE new NullMarker(); private NullMarker() {} }写入时这样处理Object value cache.get(key); if (value null) { User user userMapper.findById(id); if (user ! null) { cache.put(key, user); } else { cache.put(key, NullMarker.INSTANCE); } } else if (value instanceof NullMarker) { // 说明之前已经查过结果是不存在 return null; } else { return (User) value; }这种方案的代价是 value 的类型被污染了要么把泛型放开为 Object要么在业务层再做一层封装。如果项目里这种空值缓存需求很多我倾向于包装一个CacheResultT之类的结构体显式区分命中数据命中空标记未命中三种状态比散落的 NullMarker 更好维护。对于热点高并发场景还可以在缓存前面加布隆过滤器做第一层拦截但布隆过滤器有误判率而且维护成本不低我会谨慎使用。占位对象方案虽然朴素却足够通用也不需要额外组件。5.3 我的面试观察与最后建议这道题我自己在面试中会当作梯度测试来用。能答出第一层结论的人说明对 API 有基本记忆能主动提到 put 返回值可能不精确、推荐用 getAndPut 的人说明读过规范且踩过坑能再补一句 key 为 null 会抛 NPE、read-through 会影响 get 行为的人基本可以放进对缓存有体系化理解的池子里了。我不太建议为了面试去死记硬背这一段回答因为你只要真的用 JCache 写过一两个带 CacheLoader 的缓存再在并发环境里被 put 的返回值坑过一次这些细节自然就刻在脑子里了。阅读规范文档的习惯比背 API 重要得多。下次再遇到类似键不存在时行为是什么的问题试着先想三件事返回值表达什么、异常表达什么、配置会不会改变默认行为。想通这三个维度面试官无论怎么追问你都能站得住。
返回列表