☰
JCache标准必考的两种缓存策略:时间过期与容量逐出(LRU)
2026/10/13 5:01:53 网站建设 项目流程

1. 从一道面试题说起:JCache 为什么把这两种策略写进标准

那天在某个技术社群里看到有人讨论这道题,评论区答案五花八门,有人写"TTL和TTC",有人写"定时清除和懒清除",最离谱的还有写"Redis的过期策略"的。看起来很简单的一道题,其实考察的是你对 JCache(JSR-107)规范本身的熟悉程度,以及你到底有没有真正看过缓存标准,还是只停留在使用层面。

先说答案:JCache 标准要求实现必须支持的两种缓存过期策略是——基于时间的过期策略(Time-based Expiration)和基于容量的逐出策略(Capacity-based Eviction,典型实现为 LRU)。

这两者经常被混为一谈,但它们解决的完全是不同层面的问题。基于时间的过期,解决的是"数据在某个时间点之后不该再被读到"的问题;基于容量的逐出,解决的是"缓存空间有限,必须把一部分数据请出去"的问题。一个管数据的时效性,一个管数据的存放资格,缺一不可。

接下来我帮你把这题彻底吃透,从规范本身到源码层面的设计逻辑,再到面试官可能追问的每一个点,一次讲清楚。

2. 先把基础铺平:JCache 里的 ExpiryPolicy 到底在管什么

2.1 三个时间点:CREATED、ACCESSED、MODIFIED

JCache 规范里定义了一个核心接口javax.cache.expiry.ExpiryPolicy,它负责回答一个问题:这条缓存数据什么时候过期?这个接口的实现逻辑全部围绕三个时间点展开:

策略常量含义计时起点
ETERNAL永不过期不存在起点
CREATED从创建/写入时刻开始计时写入时
ACCESSED从最近一次访问时刻开始计时get 操作时
MODIFIED从最近一次修改时刻开始计时put/更新时
TOUCHED从最近一次访问或修改时刻开始计时两者取最新

这里值得注意的是Duration的语义。当你调用ExpiryPolicy的相关方法时,返回的Duration表示的是"从现在起再过多久过期",而不是一个绝对时间点。比如getExpiryForCreatedEntry返回Duration.ofMinutes(10),意味着这条数据在写入后的第10分钟过期,而不是在某个固定的时钟时间过期。

2.2 三种内置实现,默认行为是什么

规范贴心地为开发者提供了三种开箱即用的实现类:

  • EternalExpiryPolicy:所有条目永不过期,等价于ETERNAL常量。
  • CreatedExpiryPolicy:从创建开始计时,等价于CREATED常量。
  • AccessedExpiryPolicy:每次访问都会刷新过期时间,等价于ACCESSED常量。如果一条数据第5分钟被访问了,它的过期时间会重新从访问时刻开始往后推。这有点像很多会话管理里的"滑动过期"。

注意:ModifiedExpiryPolicy和TouchedExpiryPolicy在规范中没有对应的内置实现类,但你可以通过在构造时传入Duration的方式,用内置的三个类模拟部分效果。如果你是做具体产品开发,优先使用标准内置类,避免引入不必要的兼容性问题。

2.3 过期时间从哪里来:三种获取入口

当缓存进行读取或写入操作时,缓存实现会调用以下三个方法之一来决定过期时间:

  1. getExpiryForCreatedEntry()——条目首次创建时调用。
  2. getExpiryForUpdatedEntry()——已有条目被更新时调用。
  3. getExpiryForAccessedEntry()——条目被读取(命中)时调用。

这三个方法的参数分别是Created、Updated、Accessed类型的对象,封装了对应的操作上下文。你在自定义策略时,可以针对不同操作返回不同的过期时长。比如:创建时给10分钟,访问后刷新到30分钟,更新后重置为5分钟——完全由你的业务规则决定。

这里的底层逻辑是:过期策略本质上是一个"决策函数",它接收"发生了哪种操作"这个输入,输出"这条数据还能活多久"这个结果。这就把"什么时候过期"从具体实现中解耦了出来,任何符合规范的缓存产品都能接入同一个策略逻辑。

2.4 过期拦截器机制:懒删除的前提

JCache 的读取操作会触发一个隐藏的函数链。规范里定义了CacheEntryEventFilter和CacheEntryListener体系,但这里要说的不是监听器,而是读取路径上的过期检查。当你调用cache.get(key)时,缓存实现会先执行一次过期判定——调用当前ExpiryPolicy的getExpiryForAccessedEntry方法,然后判断当前时间与过期时间的关系。如果已经过期,这条数据会被视为"不存在",返回null,并触发一个过期删除动作,这个动作在规范中被称为 "expiry entry removal"。

这个设计的关键在于:缓存层不会主动去"扫描"所有数据,而是在访问时做检查。这就造成了两个后果:一是过期的数据不一定被立即物理删除,可能还会占着内存;二是读取路径上多了两步方法调用,理论上有些微性能损耗。JCache 允许实现做优化——比如某些缓存产品在后台维护了一个时间轮,周期性清理过期数据,但标准只要求行为正确,不强制清理时机。

3. 两种必须支持的策略,到底"必须"到什么程度

3.1 规范里的原话是怎么说的

翻看 JSR-107 规范文档,在缓存实现的必须支持项里,明确写了两条与淘汰相关的要求:

第一,必须支持基于时间的过期(time-based expiration)。也就是说,任何声称兼容 JCache 的缓存产品,都必须让用户能够指定一条数据的存活时间,并且到点后这条数据不可再被正常读取。

第二,必须支持容量受限下的逐出(capacity-based eviction)。规范要求缓存实现必须提供一种机制,在缓存条目数达到上限时,按照某种内部策略移除部分条目,默认推荐实现是 LRU(Least Recently Used,最近最少使用)。这不是"可选功能",而是必须提供的功能。哪怕你的缓存产品底层是基于数据库、分布式存储或者其他中间件,只要对外宣称实现了 JCache 标准,你就必须保证当超过容量上限时,缓存自己能做出淘汰决定,而不是报错或者死循环。

3.2 为什么偏偏是这两种,而不是别的组合

要理解这个问题,关键在于区分两个概念:过期策略和淘汰策略。中文里这两个词经常被混着说,但它们在缓存领域是两套截然不同的治理机制。

  • 过期策略关注"数据到时间了,不可用"。它是从数据的生命周期出发,为每条数据定了"保质期"。
  • 淘汰策略关注"空间不够了,谁来腾位置"。它是从缓存容量的全局出发,为整个缓存定了"入住规则"。

一个健壮的缓存系统,就像一家酒店:既有"房卡有效期",到点了房卡作废,这是基于时间的过期;也有"满房时让哪位客人退房",优先请出最久没动静的客人,这是基于容量的逐出。两者服务于不同的利益目标,缺一个都会出大问题。如果只有过期没有逐出,缓存容量会无限增长,最终内存溢出;如果只有逐出没有过期,那数据可能永远不更新,最常见的典型场景就是拿到的还是几分钟前的旧状态,而业务上早就应该失效了。

另外,JCache 标准面向的是统一缓存 API,它需要覆盖最广泛的业务场景。时间过期是缓存最常见的需求,人人都需要;容量逐出则是保障缓存进程存活的基本能力。所以规范性上只能选这两个"最大公约数"作为必须项。至于 LRU、LFU、FIFO 这些更细的淘汰算法,标准推荐实现方至少要支持 LRU,但允许使用其他策略,只要满足"超过容量上限必然触发逐出"这个行为约束。

3.3 面试时最容易踩的坑:拿 JVM 语义套 JCache 语义

我见过不少人在这个问题上翻车,因为脑子里全是 JVM 的WeakHashMap或者 Redis 的maxmemory,然后得出一个错误结论:"缓存过期不就是懒删除加定时删除吗?" 这种回答者往往把"实现方式"和"策略定义"搞混了。

我再强调一遍:JCache 标准所说的两种策略,是 API 层面的行为约束,不是实现层面的具体算法。基于时间的过期,规范只规定"到时间读不到"这个行为,不规定用定时任务还是懒删除来实现;基于容量的逐出,规范只规定"超容量必须淘汰"这个行为,不强制你用某种特定的淘汰算法。你回答"懒删除和定时删除"听起来像是那么回事,但实际上那是实现细节,面试官想听的是 API 和规范层面的答案。

面试时的标准回答模板可以是这样:"JCache 标准要求实现必须支持两种策略,第一种是基于时间的过期策略,由 ExpiryPolicy 接口定义,允许设置创建后、访问后、修改后等多个时间维度上的到期;第二种是基于容量的逐出策略,当缓存条目数超过配置的容量上限时,实现必须自动淘汰条目,常见的默认算法是 LRU。" 这样答既扣住了规范原文,又说明了两种策略各自的职责。

4. 动手配置:一个 Demo 同时验证两种策略

4.1 环境准备与依赖

写代码验证题目的结论,比口头背答案实在得多。我用标准 API 写一个最小可运行示例,前提是你本地有 JDK 11+ 和一个实现了 JCache 规范的缓存产品依赖。出于可移植性和不绑定特定商业产品的考虑,核心逻辑全部使用javax.cache接口。

<dependency> <groupId>javax.cache</groupId> <artifactId>cache-api</artifactId> <version>1.1.1</version> </dependency>

上面的依赖只有 API 接口,没有实现。实际运行还需要引入一个 JSR-107 兼容实现,比如常用的开源实现类库。你项目里加一个具体实现的依赖后,才能正常创建CachingProvider。

4.2 时间过期策略的配置

看这段代码,演示的是"创建后 5 秒过期":

import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.CreatedExpiryPolicy; import javax.cache.expiry.Duration; import java.util.concurrent.TimeUnit; public class TimeExpiryDemo { public static void main(String[] args) throws InterruptedException { CacheManager cacheManager = Caching.getCachingProvider() .getCacheManager(); MutableConfiguration<String, String> config = new MutableConfiguration<>(); // 核心:设置创建后 5 秒过期 config.setExpiryPolicyFactory( CreatedExpiryPolicy.factoryOf(Duration.of(5, TimeUnit.SECONDS))); Cache<String, String> cache = cacheManager.createCache("timeCache", config); cache.put("k1", "v1"); System.out.println(cache.get("k1")); // 输出 v1 Thread.sleep(6000); System.out.println(cache.get("k1")); // 输出 null,因为已过期 } }

这里有个细节值得注意:CreatedExpiryPolicy.factoryOf(...)返回的是一个Factory<ExpiryPolicy>,而不是策略实例本身。MutableConfiguration的所有配置项都使用工厂模式,目的是为了让缓存实现可以按需创建策略对象,避免在多个缓存之间共享可变状态。如果你在配置时不小心直接new CreatedExpiryPolicy(...),编译能过,但那不是标准写法,可能在部分实现中不被识别。

4.3 容量逐出策略的配置

接下来验证第二种策略——容量逐出。JCache 标准中,容量配置是对每个缓存单独设置的,通过setCacheEntryListenerConfiguration之外的一条独立配置接口来声明上限。

import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.EternalExpiryPolicy; public class CapacityDemo { public static void main(String[] args) { MutableConfiguration<String, String> config = new MutableConfiguration<>(); // 永不过期,避免时间过期干扰容量测试 config.setExpiryPolicyFactory(EternalExpiryPolicy.factoryOf()); // 设置容量上限:最多 3 条 config.setCacheCapacity(3L); CacheManager cacheManager = Caching.getCachingProvider().getCacheManager(); Cache<String, String> cache = cacheManager.createCache("capacityCache", config); cache.put("a", "1"); cache.put("b", "2"); cache.put("c", "3"); // 超出容量,必须触发逐出 cache.put("d", "4"); System.out.println(cache.size()); // 在标准 LRU 实现下,这里应该是 3 } }

注意,不同缓存实现暴露容量的 API 不完全一致。部分实现通过MutableConfiguration的子类来提供setCacheCapacity,标准核心 API 里并没有统一这个方法。这也是历史遗留问题——JSR-107 规范将"容量"留给了实现方扩展,因为不同缓存产品的容量计算模型差异太大:有的按条目数量,有的按字节大小,有的两者兼有。但这不妨碍规范对"必须支持容量逐出"这个行为要求的约束力。你要是在面试中谈到配置的具体方法,最好补充一句"容量上限的配置方式在不同实现中略有差异,这是符合规范的实现自由度"。

4.4 验证 LRU 行为的测试方法

如果你想知道自己的缓存产品到底用的什么淘汰算法,可以做一个简单实验:先写满容量,然后访问一次最早写入的数据,让它变成"最近有使用过"的状态,再写入一条新数据触发逐出。如果最早写入的那条数据还在,而另一条数据被移除了,说明实现采用的是 LRU 或类似考虑访问顺序的策略;如果最早数据没了,可能用的是 FIFO 或随机淘汰。

这个测试在面试时说出来很加分,因为它证明了你不只是背规范,还亲手验证过实现行为。

4.5 一个容易忽略的 API 细节:size() 的语义

在上面的容量示例中,我用了cache.size()来判断逐出是否触发。但这里面有一个坑:Cache.size()返回的是当前缓存中未过期条目的数量,它并不保证是底层物理存储的条目总数。如果配置了时间过期,那些已经过期但还没被物理清理的条目,可能仍然占用内存,但不会计入size()。

更稳妥的做法是使用cache.iterator()遍历实际条目,或者直接get(key)判断某些 key 是否已被逐出。我自己的习惯是:容量验证场景全部用iterator()来收集 key 列表,避免被size()的语义误导。

5. 面试追问环节:那些比答案本身更重要的点

5.1 追问一:"如果一个条目既设置了过期时间,又赶上容量满了需要逐出,谁优先?"

这是面试官最喜欢深挖的问题。正确理解是:两种情况处理路径不同,不存在谁先谁后的竞争关系。读取操作时会先做过期检查,发现过期直接视为不存在并删除;写入操作时会先判断容量,如果满了就触发逐出,逐出策略的目标候选是"最久没被使用"的条目,不管它是否已经过期。所以你完全可能看到这样一种情况:缓存里明明有条数据已经过期了,但还没被访问过,又写入了新数据,此时被逐出的反而是另一条"还没过期但很久没人用"的数据。

5.2 追问二:"时间过期策略到底有几种?不是四种吗?"

如果你面试时这么回答,大概率会被追问到卡壳。题目问的是"缓存过期策略",很多教科书和面试题库把 ExpiryPolicy 的四个常量误解为四种策略,但这四个常量描述的是"何时开始计时"的规则,不是策略的分类。真正的策略分类只有"时间过期"和"容量逐出"这两大类。CREATED、ACCESSED、MODIFIED、TOUCHED是在时间过期这个大策略下的四种计时模式。可以把这道题理解为"宏观政策"和"微观参数"两个层次,面试题问的是宏观层面,你如果只说微观,就是答偏了。

5.3 追问三:"既然标准只强制了 LRU 推荐,自定义淘汰算法怎么做?"

规范留了口子。MutableConfiguration中可以通过一些扩展 API 或 SPI 机制包装自定义的淘汰控制组件。核心思路是实现淘汰器逻辑时,让它在缓存条目数达到上限时被调用,你可以在其中按自己的业务规则选取受害条目。常见的自定义规则包括:按业务权重淘汰(比如订单数据比商品数据优先淘汰)、按条目大小淘汰(大对象优先淘汰)、按过期时间剩余量淘汰(快过期的优先淘汰)。不过日常开发里,除非你有极其特殊的访问模式或专业的缓存团队,否则默认的 LRU 已经能覆盖绝大多数场景。

5.4 追问四:"为什么实际项目中不直接用标准 API 里的策略?"

实话实说,标准定义的是最小必要能力,生产环境的缓存选型往往看中高可用、持久化、集群模式下的一致性等能力。标准 API 相当于一个"基础设施级别的约定",保证你的代码可以平滑切换任意符合 JCache 规范的缓存产品。但一旦你用到某些产品独有的高级特性(比如跨节点复制、持久化到磁盘、原子锁操作),代码就难免与具体产品耦合。所以我在多个大型项目里的经验是:业务代码层面向 JCache 标准 API 编程,把产品特有的能力封装在缓存工厂层,后面要换缓存产品时可以只改动工厂实现类,业务层几乎零改动。

6. 实战中验证标准:自己写一个最小 LRU 内核

你在面试里如果能把底层原理讲得一清二楚,会碾压大多数只背题面答案的人。就算你所在项目不使用任何第三方缓存产品,手写一个满足标准要求的极简缓存也能帮你深入理解 JCache 设计者的思考。

6.1 数据结构:自链表 + HashMap

LRU 最经典的实现方式是"双向链表 + HashMap"。链表维护访问顺序,每次访问或更新,把对应节点移动到链表尾部(最新位置)。容量满时,直接从链表头部移除节点——头部就是最久未被访问的一条。

import java.util.HashMap; import java.util.Map; public class MiniLRU { private static class Node { String key; String value; Node prev; Node next; Node(String key, String value) { this.key = key; this.value = value; } } private final int capacity; private final Map<String, Node> map = new HashMap<>(); private Node head; // 最久未使用 private Node tail; // 最近使用 public MiniLRU(int capacity) { this.capacity = capacity; } public String get(String key) { Node node = map.get(key); if (node == null) { return null; } moveToTail(node); return node.value; } public void put(String key, String value) { Node existing = map.get(key); if (existing != null) { existing.value = value; moveToTail(existing); return; } Node newNode = new Node(key, value); if (map.size() >= capacity) { removeHead(); } map.put(key, newNode); appendToTail(newNode); } private void moveToTail(Node node) { if (node == tail) return; unlink(node); appendToTail(node); } private void unlink(Node node) { Node prev = node.prev; Node next = node.next; if (prev != null) prev.next = next; else head = next; if (next != null) next.prev = prev; else tail = prev; node.prev = null; node.next = null; } private void appendToTail(Node node) { if (tail == null) { head = tail = node; } else { tail.next = node; node.prev = tail; tail = node; } } private void removeHead() { map.remove(head.key); if (head == tail) { head = tail = null; } else { head = head.next; head.prev = null; } } }

6.2 为什么标准要用 Factory 模式,LRU 核心却不用

上面这个手写 LRU 没有用任何工厂模式,因为它是一个独立的内聚工具类。JCache 的Factory模式解决的是另一个问题:缓存实现需要在不同时间点、不同上下文中创建策略实例,而且不希望策略持有全局状态导致线程安全问题。比如CreatedExpiryPolicy本身是无状态的,多个线程可以安全共享同一个实例,所以用工厂在缓存创建时构建一个实例就够了。但 LRU 是有状态的结构,它的状态就是节点链表,它天然需要每个缓存实例持有独立的 LRU 结构,不能共享。理解了这一点,你在设计自己的缓存层时,就知道哪些组件需要多例、哪些可以单例复用。

6.3 从这个内核看 JCache 的工程设计思路

你写完这个 MiniLRU 后再回头读规范,会发现 JCache 的设计者做了非常好的抽象:时间过期策略和容量逐出策略是两个正交的维度,它们不互相干扰。在这个手写版本里,我只实现了容量逐出维度;给每个Node加一个expireAt字段,就同时支持了时间过期维度。你完全可以在容量没满的情况下,按expireAt判断读取时是否返回null。两种策略一组合,就是一个能应对大多数业务场景的本地缓存。

这也正是 JCache 标准的魅力所在:它把"缓存"这个老兵种做了彻底的接口化抽象,让实现了这个标准的产品之间可以平滑替换。你学这套东西,不是在背标准条文,而是在理解缓存系统的通用骨架。

7. 关于这道题,我最后想补充几句

JCache(JSR-107)一直是我觉得被低估的 Java 标准之一。很多人在 Spring Cache 注解里用了@Cacheable和@CacheEvict,却很少去了解底层是基于什么 API 构建的——实际上 Spring Cache 可以适配 JCache,这层关系搞清楚以后,平时那些"为什么缓存配置在某些实现里不生效"的疑问都会豁然开朗。

回到这道面试题本身。如果你的目标是准备面试,背诵"基于时间的过期策略和基于容量的逐出策略"这个答案只是一层皮;真正拉开差距的是你能不能解释清楚"为什么偏偏是这两种""它们之间的边界在哪""实现层面会遇到什么坑"。本文上面这些内容,就是围绕这三点展开的。

如果面试官继续深挖底层,建议你往这三个方向准备:ExpiryPolicy的加载机制在不同缓存产品中如何调度;LRU 在并发读写下如何保证线程安全(通常用读写锁或 ConcurrentHashMap + 双向链表的变体);JCache 的监听器机制与过期事件的关系。这几个点都打通以后,你就不再是背题,而是真正具备"缓存专家"的视角了。

我在实际项目里的体会是,别把 JCache 当成一个"为了面试而学"的知识点。就算将来你做分布式缓存,本地缓存层的设计依然离不开时间过期和容量逐出这两个基本维度。标准给出来的不是参考答案,而是工程上的最佳实践沉淀。看懂规范底层的取舍逻辑,胜过收藏一百篇框架使用教程。

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

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

立即咨询