高级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的组合。驱逐发生时会通知相关监听器。
一个比较完整的驱逐流程如下:
- 用户执行
put操作,缓存条目数即将超过maximumSize上限。 - 实现方调用内部驱逐算法,选中一个候选条目(比如LRU选中最久未访问的那个)。
- 实现方触发
CacheEntryListener的回调方法onEvict,传入被驱逐条目的事件对象。 - 如果配置了
CacheWriter(用于写穿透),会先调用writer把数据同步到下层存储,但驱逐事件本身并不要求必须经过writer。 - 条目从缓存内部数据结构中移除。
- 后续
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()这类接口能拿到驱逐次数和过期次数的累计数据。上线前先压一轮流量,把这两个计数器录下来,作为基线数据存好。之后哪天缓存命中率异常下降,对比驱逐次数和过期次数的增量,就能快速定位是配置被改动了,还是数据访问模型发生了变化。这个习惯帮我在好几个项目里省下了大把排查时间,建议你也试试。