news 2026/10/16 13:15:14

JCache缓存驱逐与过期:本质区别与工程实践解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JCache缓存驱逐与过期:本质区别与工程实践解析

高级Java每日一道面试题:JCache(JSR-107)中缓存驱逐与缓存过期的本质区别

说实话,这个问题我面试别人问过,被别人面的时候也被问过。2025年了,Java后端面试还是绕不开缓存三件套:缓存穿透、缓存击穿、缓存雪崩。但真正能把「驱逐」和「过期」讲清楚的候选人,十个里面能有两三个就不错了。大多数人的状态是:知道expire是过期,知道evict是驱逐,但为什么要设计两个机制?触发条件有什么不同?什么场景该用哪个?一问就模糊。

这道题出自2025年5月27日的每日一题系列,基础篇,考的就是JCache(JSR-107)规范里两个最容易混淆的概念。如果你准备高级Java岗位,这题答不好很掉分。如果你已经在用Caffeine、Redis或者本地缓存框架,理解了这两个概念的区别,线上排查问题时会少踩很多坑。

这篇文章不打算只给一个面试标准答案。我会从JCache规范出发,把驱逐和过期的设计原理、触发机制、代码写法、实际踩坑全部串起来讲。无论你是准备面试,还是单纯想把缓存用好,这篇都值得读完。

1. 先把结论亮出来:驱逐和过期是两个维度的东西

1.1 “缓存满了”和“缓存坏了”是两码事

先做个类比。

驱逐(Eviction)就像你家里衣柜塞满了,得把不怎么穿的衣服先腾出去,才能挂新买的。这里的核心矛盾是空间不够了。衣柜本身没坏,衣服也没坏,只是放不下了,需要腾地方。

过期(Expiration)则像是冰箱里的牛奶贴了保质期,时间一到,即使冰箱还有很大空间,你也得扔掉它。这里的核心矛盾是数据不再新鲜了。牛奶在保质期内可能还有用,过期了就是不能用了,跟冰箱空不空没有关系。

一句话概括:驱逐是容量驱动的被动清理,过期是时间驱动的主动失效。两者最终都是把条目移出缓存,但出发点和判断依据完全不同。

1.2 面试官要的“本质区别”怎么答

你回答时不需要绕弯子,JCache规范里这两样东西从根上就是不同的:

  • 触发源不同:驱逐由缓存容量不足触发,过期由条目自身的生命周期时间到点触发。
  • 判断依据不同:驱逐看的是缓存条目总数或占用的内存大小,过期看的是条目的created time、last access time或last update time。
  • 是否可控不同:驱逐策略(如LRU、LFU)由缓存实现决定,且会受容量配置影响;过期策略是显式配置的,你可以精确控制每条数据的存活时间。
  • 监听的语义不同:JCache规范中驱逐对应CacheEntryListener的onEvict回调,而过期触发onExpiry回调,两者是不同的事件类型。
  • 从使用者感知上:驱逐发生时,这个条目仍然“有效”,只是被迫离场;过期发生时,这个条目本身已经失效,是被时间判了死刑。

这段答出来,基础分就拿到了。下面拆开细说。

2. JCache里的驱逐机制到底是怎么运作的

2.1 JCache规范里驱逐的唯一目的是“保容量”

JCache(JSR-107)定义了一个缓存API标准,但驱逐的具体算法并没有规定死。规范只规定了你有权利通过CacheConfiguration设置条目的上限,以及当条目数超过上限时,实现方应该通过某种策略移除部分条目,以保持缓存可用。

看一段典型的配置代码:

import javax.cache.configuration.MutableConfiguration; import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.spi.CachingProvider; CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); MutableConfiguration<String, UserProfile> config = new MutableConfiguration<>(); config.setTypes(String.class, UserProfile.class); // 重点:设置缓存最大条目数为 10000 config.setMaximumSize(10000); Cache<String, UserProfile> cache = cacheManager.createCache("userProfileCache", config);

这串代码看起来平平无奇,但setMaximumSize(10000)就是整个驱逐机制的开关。只要缓存条目数超过10000,实现方就会启动驱逐流程,具体的淘汰算法可能是LRU、LFU,也可能以容量字节数为单位计算,规范里允许各实现自己定义。

这里有一个很关键的点:JCache的标准API在早期版本中,setMaximumSize是属于MutableConfiguration的常用配置,但并非所有实现都严格实现了这个语义。某些实现会忽略它。所以你在做方案选型时,一定要选一个明确支持setMaximumSize和驱逐策略的Provider。

2.2 驱逐有哪些常见算法,JCache需要你关注什么

常见的驱逐算法,基本就是面试常考的几类:

  • LRU(Least Recently Used):最久没被访问的先淘汰。实现简单,使用广泛。适合访问有局部性的数据。JDK的LinkedHashMap加上accessOrder参数就可以实现一个简化版LRU。
  • LFU(Least Frequently Used):访问次数最少的先淘汰。适合热点数据识别比较准确,但实现复杂,需要维护访问频次,且存在“历史热点永不淘汰”的问题。
  • FIFO(First In First Out):最先进入的条目先淘汰。不考虑访问频率和新鲜度,太粗暴,一般场景不太推荐。
  • Random(随机淘汰):随机选一个干掉。思路简单,但淘汰精度差,生产环境很少直接用。

JCache规范没有强制指定用哪一种,实现方说了算。比如主流的实现会提供可配置的策略选项,让你在创建缓存时指定使用LRU还是LFU。但核心逻辑都是一样的:当容量达到上限时,做出牺牲某个条目的决定,把它移出缓存。

2.3 驱逐的触发流程和控制器的角色

在JCache规范里,每个缓存都有一个“控制器”概念——规范里叫CacheEntryListener和CacheWriter、CacheLoader的组合。驱逐发生时会通知相关监听器。

一个比较完整的驱逐流程如下:

  1. 用户执行put操作,缓存条目数即将超过maximumSize上限。
  2. 实现方调用内部驱逐算法,选中一个候选条目(比如LRU选中最久未访问的那个)。
  3. 实现方触发CacheEntryListener的回调方法onEvict,传入被驱逐条目的事件对象。
  4. 如果配置了CacheWriter(用于写穿透),会先调用writer把数据同步到下层存储,但驱逐事件本身并不要求必须经过writer。
  5. 条目从缓存内部数据结构中移除。
  6. 后续get这个key,命中不了缓存,有可能回源或返回null。

这段流程里有几个细节容易踩坑:

坑点:驱逐不等于删除。如果这个条目之前被修改过但没写回底层存储,驱逐后修改就丢失了。如果你用JCache做本地缓存且配合了写穿透,一定要确认CacheWriter的写入时机,否则驱逐会把脏数据直接丢掉,线上容易出事故。

3. 过期机制:时间一到,神仙也留不住

3.1 JCache的ExpiryPolicy体系

与驱逐不同,JCache对于过期机制给出了非常明确的标准API——ExpiryPolicy接口。你可以为每次get、put、update等操作定制不同的过期时间。

接口长这样:

public interface ExpiryPolicy { Duration getExpiryForCreation(); Duration getExpiryForAccess(); Duration getExpiryForUpdate(); }

三个方法分别对应:

  • getExpiryForCreation:新条目创建时该存活多久。
  • getExpiryForAccess:条目被访问后,还有多久过期(相当于滑动过期)。
  • getExpiryForUpdate:条目被更新后,还有多久过期。

官方还提供了一组现成的策略类,可以直接拿来用,不用自己实现接口:

  • EternalExpiryPolicy:永不过期。
  • CreatedExpiryPolicy:从创建开始计算过期时间,到期就死。
  • AccessedExpiryPolicy:每次被访问,重新计算过期时间。
  • ModifiedExpiryPolicy:每次被修改,重新计算过期时间。
  • TouchedExpiryPolicy:访问或修改都会重新计算过期时间,相当于Accessed+Modified的合体。

3.2 三种过期策略背后的业务语义

面试官通常还会追问:这些策略分别适合什么场景?这里是我在实际项目中的理解。

CreatedExpiryPolicy是最符合直觉的一种“固定过期”。比如运营活动的配置数据,每天凌晨刷新一次,创建后12小时过期,到期后就不应该再被读到。这类数据你不希望它的生命周期因为被访问就延长,否则某些用户可能永远看到旧数据。

AccessedExpiryPolicy就是经典的“滑动过期”。适合会话类数据,比如用户的登录状态。只要用户持续操作,会话就一直有效;一旦用户离开很久没有被访问到,会话自动过期。这种机制最怕的是长时间不活跃的用户会导致会话过期,但滑动的时长设置合理的话,体验还是不错的。

ModifiedExpiryPolicy则适合数据被修改后进入一个新的生命周期。比如一个配置项被编辑后,希望它从修改那一刻再存活30分钟,不受之前创建时间影响。

3.3 过期触发:惰性删除和主动删除之争

过期时间设定之后,触发删除的方式并不是唯一的。JCache规范允许实现方采用不同策略。业界主要有三类。

惰性过期(Lazy Expiration):条目过期后并不立即从缓存中移除。等到有请求访问这个key时,才发现它已经过期了,然后顺手删除并返回null。这样做的优点是省掉了定时扫描的CPU开销,缺点是过期条目在真正被访问前一直占用内存。很多本地缓存框架默认采用这种行为策略。

定期扫描(Periodic Cleanup):后台线程每隔一段时间扫描缓存,把已过期的条目清理掉。优点是比较主动,内存释放及时;缺点是存在扫描间隔期的“延迟过期”,且额外消耗CPU。

主动定时删除(Eager Expiration):为每个条目或者每组条目维护一个定时任务,到期立即删除。优点是精确;缺点是定时任务多了开销很大,绝大多数生产级缓存框架都不会对所有条目做独立定时器。

真正落到JCache实现上,一般是采用惰性过期+定期扫描的组合拳。比如get操作时检查过期,同时后台每几秒扫一次把过期条目清出去。

注意:如果你用JCache做本地缓存,又依赖“过期后立刻从内存消失”去做资源回收,靠默认实现可能不够。要仔细阅读所用Provider的文档,确认是否支持定时清理以及清理间隔是多少。否则内存占用和你预期的不一致。

4. 驱逐和过期的本质区别:五个维度对比

4.1 核心维度对照表

为了帮大家形成肌肉记忆,我把两者的差异整理成一张对比表。面试回答时也可以按这个思路去展开,既有条理又切中要害。

对比维度驱逐(Eviction)过期(Expiration)
触发源容量达到上限,强制腾空间条目生命周期到期,自然失效
判断依据条目数量、内存占用等空间维度CPU时间、创建/访问/修改时间等时间维度
条目自身状态数据仍然是有效的,只是被牺牲数据本身已失效,不可再使用
配置方式设置maximumSize,由实现方决定淘汰算法设置ExpiryPolicy,精确控制各种操作的存活时长
事件类型触发onEvict事件触发onExpiry事件
能否提前预知相对困难,依赖容量水位和访问分布可以精确预知,因为时间是确定性因素
典型场景本地缓存防止OOM,保护进程内存缓存数据保鲜,防止读到脏数据

这张表是回答时的主心骨。你可以先亮表,然后挑重点展开讲,面试官会认为你确实理解得很透。

4.2 为什么不能只靠其中一个机制

很多初级开发者会问:既然有过期了,为什么还需要驱逐?既然有驱逐了,为什么还需要过期?

道理很简单,它们解决的是两类完全不同的问题。

只靠过期,缓存里那些长期有效但不再使用的条目就永远占着内存。假设你缓存了用户画像,一天过期,但用户当天都在频繁访问,过期时间一直滑动,内存里的条目越堆越多。没有驱逐,热点数据覆盖不了老条目,到最后内存直接爆掉。

只靠驱逐,缓存里的数据即使已经过期,只要容量没满,也会一直存在。用户已经注销了、商品已经下架了、配置已经改版了,但缓存里还是旧数据。没有过期,你等于手动给自己挖了一个“永久脏数据坑”。

生产环境的缓存系统几乎都是同时启用两个机制:驱逐保内存,过期保鲜度。两者配合,才叫一个完整的缓存生命周期管理。

4.3 给业务场景做取舍的实操建议

如果业务对数据新鲜度要求极高,比如实时库存、配置中心,优先依靠过期机制,把过期时间设置得短一点,避免读到错误数据。这种情况下容量上限也可以设置得大一些,因为大部分条目会很快过期被清掉,不会长期堆积。

如果业务对内存占用极其敏感,比如在JVM堆内做本地缓存,优先依靠驱逐机制。过期时间可以设置得较长甚至永不过期,但maximumSize必须设置得合理,确保进程不OOM。

还有一个折中方案:两者同时严格配置。对内存容忍度较高的场景,把容量上限设大一点;对数据新鲜度要求较高的场景,把过期时间设短一点。所有线程池、内存池、队列的配置,本质都是这种“共同约束”的调优。

5. 实操:一个模拟Demo同时观察驱逐与过期

5.1 准备一套可运行的JCache环境

我们需要一个可用的JCache实现。我本地用的是某开源的JCache实现Provider,注意这类Provider的包不要把公司真实信息代入,但代码结构是通用的。

在pom.xml中加入依赖:

<dependency> <groupId>javax.cache</groupId> <artifactId>cache-api</artifactId> <version>1.1.1</version> </dependency> <!-- 再引入一个具体的JCache实现,比如某兼容JSR-107的开源库 -->

配置一个缓存:最大条目数500,创建后5秒过期,每次访问后滑动3秒。

MutableConfiguration<Integer, String> config = new MutableConfiguration<>(); config.setTypes(Integer.class, String.class); // 容量上限:500条 config.setMaximumSize(500); // 1秒后过期:这里用Duration config.setExpiryPolicyFactory(FactoryBuilder.factoryOf(new ExpiryPolicy() { @Override public Duration getExpiryForCreation() { return Duration.ofSeconds(5); } @Override public Duration getExpiryForAccess() { return Duration.ofSeconds(3); } @Override public Duration getExpiryForUpdate() { return Duration.ofSeconds(5); } })); Cache<Integer, String> cache = cacheManager.createCache("demoCache", config);

这段配置同时启用了容量约束(500条)和时间约束(5秒创建过期、3秒访问滑动)。运行后我们可以通过一个数据观察类打印缓存行为。

5.2 写一个小观察程序:谁先被移走?

// 写入501个条目,最后观察哪个key被驱逐 for (int i = 0; i <= 500; i++) { cache.put(i, "value-" + i); if (i == 499) { System.out.println("已写入500条:" + (cache.isPresent(499) ? "key499存在" : "key499消失")); } } System.out.println("写第501条后,key0是否存在:" + cache.containsKey(0)); System.out.println("写第501条后,key499是否存在:" + cache.containsKey(499));

运行结果通常是:写第501条后,key0可能已经不在了(取决于实现采用的淘汰算法是FIFO还是LRU)。因为容量到顶了,新建的第501条挤掉了老数据。

接下来测试过期:

Thread.sleep(4000); System.out.println("4秒后,key0是否存在:" + cache.containsKey(0)); Thread.sleep(2000); System.out.println("6秒后,key0是否存在:" + cache.containsKey(0));

预期结果:

  • 第4秒时,key0如果未被驱逐,则仍然存在,因为5秒才过期。
  • 第6秒时,key0已不存在,因为创建时间超过5秒,触发了过期。
  • 访问滑动逻辑则表现为:在4秒时get一次key0,再将过期时间从该次访问起算3秒,再过2秒访问仍然存在。

实操建议:在观察程序打印时,顺带用cache.getMetrics()或实现方提供的JMX信息,看驱逐计数和过期计数的变化趋势。两个数字分开统计,再对照业务日志,就能清晰看到是哪条策略先生效。

5.3 监听事件:区分Evict和Expiry的关键

JCache允许你注册CacheEntryListener来监听驱逐和过期事件,这是精确区分两种机制的最佳手段。

CacheEntryListener<Integer, String> listener = new CacheEntryListener<Integer, String>() { @Override public void onEvict(CacheEntryEvent<? extends Integer, ? extends String> event) throws CacheEntryListenerException { System.out.println("驱逐事件: key=" + event.getKey()); } @Override public void onExpiry(CacheEntryEvent<? extends Integer, ? extends String> event) throws CacheEntryListenerException { System.out.println("过期事件: key=" + event.getKey()); } }; MutableCacheEntryListenerConfiguration<Integer, String> listenerConfig = new MutableCacheEntryListenerConfiguration<>( FactoryBuilder.factoryOf(listener), null, false, true ); cache.registerCacheEntryListener(listenerConfig);

onEvict回调只有在驱逐发生时触发,onExpiry回调只在过期时触发。如果两次回调都出现了,可以再打印缓存条目的创建时间,进一步验证。

如果某个事件你注册了监听却没触发,先检查:

  • isOldValueRequired参数是否设置正确。
  • Provider是否支持CacheEntryEvent的细分类型。
  • 个别实现可能把“过期”也统一用onRemove通知,需要看文档确认。

6. 常见问题与排查技巧实录

6.1 为什么缓存条目过期了还在内存里?

这是本地缓存使用中排在最前面的问题。原因多半是惰性过期没有主动清理。

如果你实现的是基于堆内缓存的场景,过期条目不主动清理时,JVM年老代内存会一直上涨,GC压力变大。解决办法:

  • 检查Provider是否提供“定期清理”或“后台驱逐”选项,打开它。
  • 如果Provider没有相关选项,改掉依赖,用更成熟、支持主动维护的缓存库。
  • 提高对maximumSize的关注,让驱逐定期帮你“扫地”。

6.2 驱逐和过期的事件为什么有时分不清楚?

有些缓存实现设计有简化,把两者统一走同一个“移除”事件通道,导致监听器难以分辨。确认事件类型的办法:

  • 打印event.getEventType(),看是EXPIRY还是EVICT。
  • 在事件回调里读取条目的过期时间配置,对比当前时间,判断是“时间到了”还是“容量不够”。
  • 设置超大容量上限,只保留过期配置,如果仍然触发事件且事件类型是EVICT,那就要怀疑这个实现把过期事件归类到了驱逐事件里,属于实现差异化。

6.3 调优时我踩过的三个实际坑

第一个坑:把maximumSize设得太大。本地缓存里的数据是存在JVM堆里的,塞进几百万条数据,虽然没到OOM阈值,但频繁GC已经让系统RT飙升。后来我把容量上限控制在几十万条,配合短期过期,情况立刻缓解。

第二个坑:过度依赖驱逐做内存保护,而忽略了业务语义。有一个报表查询接口,我把查询结果缓存起来并设了很大的maximumSize,结果老数据一直命中,用户看到的数据永远落后几个小时。最后给缓存加上了10分钟的过期策略,问题解决。

第三个坑:访问滑动过期导致热点数据永不消退。登录会话用了AccessedExpiryPolicy,某个用户持续活跃,会话永远不会过期。结果用户换了设备,旧会话还在,服务端会话表膨胀。后来改成创建时间固定过期,限制单用户最大活跃会话数,才把问题控住。

6.4 面试追问环节怎么应对

如果面试官问“驱逐算法应该选哪个”,不要直接回答LRU最好。正确思路是结合业务访问模型来分析:

  • 热点访问均匀:LRU和FIFO差别不大。
  • 访问具备时间局部性:LRU更优。
  • 热点数据长期稳定:LFU更优。
  • 数据是低频但重要的配置类信息:驱逐本身就不应该发生,应该扩大缓存容量或主动做穿透处理。

如果面试官问“过期时间设置多长合理”,同样没有标准答案,你需要给出一套估算方式。比如业务高峰期QPS为1000,缓存回源的代价是数据库耗时80ms,那么3秒的过期时间你可以接受;1小时的过期时间则可能导致大量陈旧数据。通过压测数据反推,才是合理的。

最后分享一个实际操作中的体会

我在实际排查一个本地缓存导致的线上问题时就深刻体会过:驱逐和过期这两个机制看起来都是“删缓存”,但运维层面的处理方式完全不同。驱逐发生后,系统感知不到数据有问题,顶多去回源一次,整体可用性可以接受;而过期发生则意味着读到的数据一定是旧的,这种时候需要检查业务方的数据版本控制是否到位,而不是简单加容量就能解决。

后来我在设计缓存方案时,都会先明确回答三个问题:缓存数据能不能接受延迟?内存能不能被长期占住?回源的成本高不高?三个问题答完,再决定过期时间怎么配、容量上限怎么设、驱逐和过期谁优先。这套思路用到现在,基本没出过大篓子。

另一个小技巧:用JCache时,可在创建缓存管理器的时候开启统计信息功能,像cache.getMetrics()这类接口能拿到驱逐次数和过期次数的累计数据。上线前先压一轮流量,把这两个计数器录下来,作为基线数据存好。之后哪天缓存命中率异常下降,对比驱逐次数和过期次数的增量,就能快速定位是配置被改动了,还是数据访问模型发生了变化。这个习惯帮我在好几个项目里省下了大把排查时间,建议你也试试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 13:13:53

从零搭建论文记录体系:命名规范、元数据与检索复用实践

1. 从零搭建一套能扛住三年的论文记录体系很多人第一次听到“论文记录”这四个字&#xff0c;脑子里浮现的就是一个文件夹&#xff0c;里面塞满了一堆命名混乱的PDF&#xff0c;时间一长连自己都忘了哪篇读过、哪篇没读、哪篇的结论能用在当前项目里。我见过太多人把文献管理等…

作者头像 李华
网站建设 2026/10/16 13:13:27

上下文工程实战:如何组织输入让大模型输出质量翻倍

1. 为什么“喂给AI什么”比“用哪个AI”更值得花时间大多数人第一次接触大模型时&#xff0c;注意力都放在“选哪个模型”上——参数多大、跑分多高、价格多便宜。但真正上手做过几个项目之后你会发现&#xff0c;同一个模型&#xff0c;换一种上下文组织方式&#xff0c;输出质…

作者头像 李华
网站建设 2026/10/16 13:13:16

C++作用域解析运算符:::化解变量隐藏与多态调用困局

1. 从变量隐藏到多态困局&#xff1a;:: 到底能解决什么先抛一个我经常在面试和 code review 里见到的场景&#xff1a;某个类里定义了一个成员函数&#xff0c;函数内部又出现了和成员变量同名的局部变量&#xff0c;结果程序跑出来的数值完全不对&#xff1b;或者子类重写了父…

作者头像 李华
网站建设 2026/10/16 13:13:15

Argyll在Windows下生成显示器ICC配置文件的实战指南

简介&#xff1a;这是Argyll CMS 2.1.2的Windows 64位安装程序压缩包&#xff0c;面向摄影师、设计师和印刷业者&#xff0c;用于解决显示器、打印机等输出设备的色彩管理问题。包内共349个文件&#xff0c;整体大小31.83MB&#xff0c;包含exe安装程序、icm/ccmx校色设备配置数…

作者头像 李华
网站建设 2026/10/16 13:11:58

一条技术路线吃掉整个生成赛道:H3 的全模态野心拆解

一条技术路线吃掉整个生成赛道&#xff1a;H3 的全模态野心拆解 【免费下载链接】MiniMax-H3 MiniMax H3 是一个通用的全模态生成系统。它支持对由文本、图像、视频和音频组成的多模态上下文进行统一理解&#xff0c;并能生成分辨率高达 2K、时长可达 15 秒的带原生立体声音频的…

作者头像 李华
网站建设 2026/10/16 13:09:54

AI智能体安全评估实战:基于MITM的OpenClaw红队攻击测试

我以前评估AI智能体的安全&#xff0c;习惯直接从提示词下手&#xff0c;各种越狱样本、护栏绕过、诱导输出测了个遍。这套玩法在纯对话场景下还算够用&#xff0c;可一旦面对真实部署的智能体&#xff0c;尤其是像OpenClaw这种自带工具调用、能读写本地文件、能执行命令、能发…

作者头像 李华