news 2026/10/16 10:53:30

JCache缓存驱逐与过期:触发机制、底层差异与生产实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JCache缓存驱逐与过期:触发机制、底层差异与生产实践

最近有小伙伴在准备面试时问到我:JCache(JSR-107)里缓存驱逐(Eviction)和缓存过期(Expiration)到底有什么区别?我反问他怎么理解,他说“驱逐不就是把过期的删掉吗?”——这是很多人看到这道题时的第一反应,但恰恰是这个回答,最容易让面试官觉得你只是背了概念,没有真正理解机制。

在Java后端面试的基础篇里,JCache(JSR-107)能作为独立题目出现,本身就说明两个意思:第一,它考察的是你对Java标准缓存规范的掌握程度;第二,它更想听到的是你对“驱逐”和“过期”这两个底层动作在触发时机、执行路径、设计目的上的本质差异。今天这篇文章,我打算把这两个概念从定义到源码层面,再到生产环境里的实际踩坑,完整拆一遍。无论你是准备面试,还是正在排查线上缓存问题,都值得花十分钟看完。

1. 先搞清楚一个问题:这两兄弟为什么总被搞混?

1.1 从JCache规范里抠定义

JCache(JSR-107)是Java官方的缓存规范,它定义了Cache、CacheManager、Cache.Entry这些核心接口,也给了一套可插拔的ExpiryPolicy。规范里对缓存条目的生命周期管理,实际上包含了两套完全不同的机制:驱逐和过期。

先说驱逐。驱逐的意思是,当缓存中的条目数量或者占用内存达到某个预设上限时,缓存容器主动选择一些条目把它移除掉,从而腾出空间给新条目。这个过程的核心是“容量控制”。被驱逐的条目并不是因为不可用才被删,而是因为“空间不够了”。它可能是刚刚被写入的,也可能是被高频访问的,只是恰好被策略选中,成了牺牲品。

再说过期。过期的意思是,每个条目在创建、更新或者最后一次访问之后,经过一段预定义的时间,它就变成“过期状态”。在过期状态下,这个条目虽然还物理存在,但逻辑上已经失效,不允许再被读取。这个过程的核心是“时效控制”。过期和容量没有任何关系,哪怕缓存里只有一个条目,只要时间到了,它照样过期。

很多同学之所以把这两个搞混,是因为在大多数系统里,过期条目最终也会被删除掉。但请记住:删除只是手段,出发点和触发条件完全不同。就像书架满了,你把几本不常看的书扔掉,这叫腾地方;牛奶过了保质期,你把它倒掉,这叫判死刑。第一个动作不是为了牛奶坏没坏,第二个动作也不管冰箱空不空。

1.2 一句话区分:驱逐是“腾地方”,过期是“判死刑”

用一句话总结:驱逐是在“空间不够”时发生的,目的是给新条目腾位置;过期是在“时间到了”时发生的,目的是保证数据的时效性。被驱逐的条目当时仍然是有效数据,被过期的条目已经是无效数据。

一个更贴切的场景:假设缓存容量是10000条,现在已经有9999条,并且其中5000条在30秒后就会过期。这时你写入第10000条,按规则容量已经到顶,接下来必须再挤掉一条。驱逐机制会不管那5000条还有30秒寿命,直接按照LRU或LFU策略挑一个淘汰,甚至可能挑那条明明还可以用30秒的数据。反过来,如果某个条目没人访问,它的过期时间到了,那么不管缓存里只有10条还是10000条,它都会变成不可用状态。

这两个机制不冲突,可以共存。几乎所有的生产级缓存都会同时开启:既设置容量上限,又设置过期时间。前者保护内存,后者保护数据新鲜度。面试官问本质区别,其实就是看你能不能从“目的”和“触发源”这两个维度把二者切开。

2. 触发机制:到底是“谁”来扣动扳机?

2.1 驱逐的触发:容量上限与访问路径

驱逐的触发点非常明确:写入新条目时发现容量不足。在典型的JCache实现中,每次执行cache.put(key, value)时,实现内部会先检查当前条目数是否达到maxEntries(或者堆内存字节限制)。如果没达到,直接放入;如果达到了,就必须先执行驱逐逻辑,选出一个或多个条目淘汰,再写入新条目。

这里有一个关键细节:驱逐的过程是“写路径”的一部分。也就是说,触发驱逐的线程是当前执行put的线程。这个动作可能发生在应用业务线程上,而不是某个后台清理线程。这意味着驱逐本身是有代价的,如果容量很小、写入很频繁,业务线程可能会被驱逐逻辑拖慢。有些实现做了优化,比如批量驱逐、异步清理,但从规范设计的角度来说,驱逐发生在写操作触发的路径上。

读操作会触发驱逐吗?严格来说不会。只有可能触发“访问顺序”的更新,比如LRU算法需要把刚访问的条目移到链表头部。但访问操作不会因为空间不足而主动移除条目。真正能让条目在读路径上消失的是过期检查,而不是驱逐。

另一个容易忽略的点:驱逐算法有很多种,常见的有LRU(最近最少使用)、LFU(最不经常使用)、FIFO(先进先出)、随机淘汰。JCache规范并没有强制规定必须用哪一种,而是把具体策略的选择权交给了实现方。所以在面试中,你可以补一句:驱逐是“策略驱动”的,不同算法面对同样的访问序列会淘汰完全不同的条目,但触发时机都是一样的——容量满时。

2.2 过期的触发:时间戳与懒加载/主动扫描

过期机制的触发和驱逐完全不同。它不依赖容量,而是依赖时间戳和调度逻辑。JCache中,每个条目在创建或更新时会计算一个“到期时间点”,通常基于ExpiryPolicy得到。之后,这个条目何时被真正清理,存在两种常见实现。

第一种是懒加载式(lazy),也是最常见的。当客户端执行cache.get(key)时,实现会先比较当前时间和到期时间。如果当前时间 > 到期时间,就判定条目过期,将其移除(或者标记为不可见),然后返回null。换句话说,过期条目的实际删除动作被推迟到了“下一次被访问”时。如果一直没人访问,它就一直占着内存。

第二种是主动式(eager),由后台线程周期性地扫描缓存条目,把已过期的条目批量清理掉。这种方式的优点是内存利用率和命中率更好,但代价是后台扫描会占用CPU,而且扫描频率不好控制。很多实现会在两者之间做折衷,比如本地缓存用懒删除,远端缓存用定时任务。

重点在这里:过期的“触发”可以是读操作(懒检查),也可以是后台定时器(主动扫描),但不依赖于“容量是否已满”。哪怕缓存空荡荡的,只要时间到了,该过期的条目照样被判过期。这跟驱逐的触发源形成了鲜明对比。

2.3 两种触发的本质差异对比表

对比维度驱逐(Eviction)过期(Expiration)
触发条件缓存容量达到上限条目到达预设的存活时间
触发时机通常在写入新条目时在读取时(懒检查)或后台定时扫描时
触发线程执行put的线程,或实现的后台维护线程执行get的线程,或专门的清理线程
淘汰对象可能是任意有效条目,由策略决定只针对已经失效的条目
设计目的保护内存,控制缓存最大规模保证数据时效性,避免读到陈旧数据
判断依据容量、访问频率/顺序/时间时间戳、策略配置
结果影响条目被移除,但移除前仍是有效数据条目被移除或不可见,移除前已无效

这张表基本就是面试官想听到的核心。如果你能把“触发条件”、“触发时点”、“判断依据”这三个维度讲透,这道题的分数基本上就拿到了。

3. 在JCache(JSR-107)API里的落地形态

3.1 标准API里,驱逐为什么没有专属配置接口

很多同学打开JCache的MutableConfiguration时会发现:里面能找到setExpiryPolicyFactory,但找不到setEvictionPolicy。这是不是规范漏了?不是,这恰恰是JSR-107的设计哲学:它希望各类缓存实现能通过一套统一API对外提供服务,但把“如何管理容量”这种实现细节留给各个厂商去扩展。

在标准API中,你可以直接配置过期的策略,比如使用AccessedExpiryPolicy、CreatedExpiryPolicy、ModifiedExpiryPolicy等工厂方法。但是驱逐配置,通常是在CacheManager创建时,或者通过具体实现的CacheConfigurationBuilder来设置。比如某些基于堆的JCache实现,会在构建缓存时允许你指定堆内存大小、条目上限,以及驱逐算法。

所以面试时要清楚:JCache标准对过期是“一等公民”,提供了完整的API;对驱逐则更像一个“建议项”,只要求实现方在容量受控时执行驱逐,但怎么配置、用什么算法,标准不强制。这不是JCache不行,而是它照顾到了不同实现之间的差异。你在项目里如果用的是某个具体实现,需要去读它的配置手册,而不是只盯标准API。

3.2 过期策略的API:Duration和ExpiryPolicy

JCache中定义了一个Duration类,用来表示时间长度。其中有两个静态常量特别关键:Duration.ETERNAL表示永不过期,Duration.ZERO表示立即过期。这两个常量在配置时会经常用到。

主接口是ExpiryPolicy,它有三个默认方法实现:

  • getExpiryForCreation:新条目创建后多久过期。
  • getExpiryForAccess:条目被访问后,存活时间重置为多少。
  • getExpiryForUpdate:条目被更新后,存活时间重置为多少。

在标准API里,已经内置了几个现成的策略:

  • CreatedExpiryPolicy:基于创建时间,创建后固定duration过期,访问和更新不会重置。
  • AccessedExpiryPolicy:基于最后访问时间,每次访问都会重新计时。
  • ModifiedExpiryPolicy:基于最后修改时间,每次更新都会重新计时。
  • TouchedExpiryPolicy:基于最后访问或修改时间,两者任一发生都会重新计时。

这个设计解决了一个很实际的业务问题:有些数据是“写入后就固定15分钟失效”,有些数据是“只要有人查就一直续命”。如果你需要在项目中自定义规则,也可以自己实现ExpiryPolicy接口,返回不同场景下的Duration。

这里有一个容易被忽略的点:Duration的最小单位、是否支持负数这些细节,不同实现处理可能不一样。但标准里明确ZERO表示条目立即过期,用在getExpiryForCreation上就相当于禁用了缓存。

3.3 一个完整示例:只靠JCache标准API配置过期

假设我们要创建一个保存用户信息的缓存,要求每个条目在创建后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 JCacheDemo { public static void main(String[] args) { CacheManager cacheManager = Caching.getCachingProvider() .getCacheManager(); MutableConfiguration<String, UserInfo> config = new MutableConfiguration<>(); config.setTypes(String.class, UserInfo.class); config.setStoreByValue(false); config.setStatisticsEnabled(true); config.setManagementEnabled(true); config.setExpiryPolicyFactory( CreatedExpiryPolicy.factoryOf( new Duration(TimeUnit.MINUTES, 5))); Cache<String, UserInfo> cache = cacheManager.createCache("userCache", config); cache.put("u_1001", new UserInfo("张三", 28)); UserInfo info = cache.get("u_1001"); System.out.println(info.getName()); } }

这里用CreatedExpiryPolicy,意味着每个新条目从放入缓存那一刻开始算5分钟后过期。之后无论访问多少次,过期时间都不会重置,第6分钟再来拿就是null。

那驱逐呢?标准API没有直接体现,但如果你用的是某个具体实现,通常在构建缓存时需要额外配置资源池。我举一个示意性的伪代码,不代表标准API:

// 示意:在具体JCache实现中通过扩展配置容量和驱逐策略 CacheConfiguration<String, UserInfo> config = newCacheConfigurationBuilder(String.class, UserInfo.class) .withHeap(10000, EntryUnit.ENTRIES) .withEvictionPolicy(EvictionPolicy.LRU) .withExpiryPolicyFactory(...) .build();

核心点是:同一份缓存数据,可以同时被两个机制管理。ExpiryPolicy负责告诉缓存“什么时候条目不可见”,容量配置负责告诉缓存“内存扛不住时优先淘汰谁”。两者互不替代。

4. 实际项目中怎么用?几个容易踩的坑

4.1 只配过期不配驱逐,可能被撑爆

我在之前的一个内部项目里见过真实的线上事故:某服务用本地缓存保存一批短时效的促销数据,只配置了过期策略,没有配置容量上限。理论上数据5分钟后就会过期,内存应该不会爆。但问题出在触发机制上——懒删除模式下,如果过期条目一直没人访问,它们会一直堆积在内存里。当促销活动频繁切换、写入压力大时,旧条目还没来得及被访问触发删除,新条目又不断进来,最终堆内存被撑爆,服务频繁Full GC。

这里就暴露了过期机制的一个固有特性:过期是“逻辑失效”,不保证“物理删除”。如果依赖懒删除,那么条目的实际释放取决于后续的访问行为。所以生产环境里,一定要给缓存设置一个最大容量或者最大堆内存,让驱逐机制作为最后一道防线。哪怕不关心驱逐策略,也必须有个兜底的容量上限。

4.2 只配驱逐不配过期,数据会“老而不死”

反过来,如果只设置容量不设置过期,会遇到另一个问题:缓存永远不会淘汰“过期”数据,因为根本没有“过期”概念。同一个key的数据可能永远是那个旧值,直到它因为容量满了被驱逐。对于用户资料、配置类数据这种更新频率不高的场景影响不大,但对于价格、库存、会话信息这类强时效数据,就可能导致业务读到明显过期的值。

还要注意,驱逐算法一般倾向于保留“最近常用”的数据。如果某个条目已经不被访问,它迟早会被挤出去,但“迟早”不可控。在极端情况下,一个全局热点数据可能长期把另一个重要但不热门的value挤出缓存,导致每次都要回源数据库。所以容量上限解决的是内存问题,过期时间解决的是数据新鲜度问题,两者应该配合使用。

4.3 驱逐策略选型:LRU、LFU还是随机?

JCache本身没强制,但用的实现通常会提供几种策略。LRU最适合“刚访问过的数据大概率还会被访问”的场景,比如用户会话缓存。LFU则更适合“访问频率差异很大”的场景,比如把最热门的商品信息留在缓存里。随机淘汰适合那种访问模式完全不可预测的脱敏数据。

不过要注意,LFU可能存在“历史计数器永不衰减”导致新热点无法上位的问题,有些实现会对计数做衰减处理。LRU则存在顺序扫描型数据污染缓存的问题—比如跑批任务把大量一次性超长列表写入缓存,把真正的高频数据全挤掉了。我在项目里一般首选LRU,如果缓存的数据有明显冷热分层,会升级为LFU;对那种只是暂时防击穿的缓存,随机策略也够用。

另外一个容易被忽略的点:驱逐是按条目数还是按字节数来限制,差别很大。如果缓存保存的是大对象,比如几十KB的列表,按条目数设10000条可能会导致内存超预期。所以更稳妥的做法是同时限制最大字节数或者堆内存占用。具体要根据你用的实现去配置。

4.4 过期时间设置不当引起的缓存雪崩

这个问题在分布式缓存中更常见,但本地缓存也会遇到。如果你把缓存里所有条目的过期时间设成同一个固定值,比如统一5分钟,那么在5分钟整点那一刻,几乎所有条目会一起过期。此时如果有大量请求打过来,全部穿透到数据库或者上游服务,数据库压力瞬时飙升,甚至拖垮应用。

解决办法很简单:过期时间加一个随机偏移量。比如基础过期时间5分钟,然后为每个条目随机增加0到60秒。在JCache里,可以自定义一个ExpiryPolicy,在getExpiryForCreation中生成随机Duration。这样既能保证数据最终会过期,又能错开失效洪峰。

还有一个同类问题:驱逐也可能造成“集中淘汰”。如果容量上限正好被一批新写入触发,LRU可能连续淘汰一大片数据,导致后续访问大面积未命中。应对方式是在写路径上做分批插入,或者使用带有“代价感知”的驱逐策略。

5. 面试官追问时,怎么答出亮点?

5.1 从JCache设计角度谈两者关系

基础回答拿个及格分不难,但要拿高分,建议从JCache规范设计意图切入。你可以这样说:JCache把Expiration提升到了标准接口层面,是因为“数据多快过期”是一个与业务强相关的语义,必须允许每个应用自定义;而Eviction不涉及业务语义,它只是缓存实现为了存活必须做的自我淘汰,所以规范把策略细节下放给实现。这样既体现了你对规范分层的理解,也解释了为什么标准API里不容易看到驱逐配置。

更进一步,可以提一下这两者之间的关联:如果驱逐策略能够感知过期时间,把“即将过期”的条目优先淘汰,内存利用效率会更高。但标准接口里没有强制集成这种“过期感知驱逐”,所以部分实现支持EvictionAdvisor之类的扩展,让你可以在驱逐发生时介入选择。如果你们项目里用到这类特性,可以举例说明。

5.2 结合并发场景说触发线程

面试官如果继续深挖,大概率会问:多线程并发访问时,驱逐和过期分别是谁在做?这里你可以答:驱逐通常发生在写线程中,如果实现采用同步驱逐,写操作可能被阻塞;而过期的懒检查发生在读线程中,读操作在判断过期时需要持有访问锁,可能增加读延迟。后台主动扫描则是在独立的清理线程中执行的,和业务读写线程隔离。

再补一句:因为懒检查是在读路径上判断“时间是否超了”,所以即使并发线程同时读到同一个已经过期的key,最终应该只有一个线程能够完成回源更新,其他线程要想办法避免重复加载。这一点在JCache实现中通常和putIfAbsent或原子操作配合,而不是靠过期机制本身保证。这能体现你对缓存一致性的理解深度。

5.3 外部比较:Caffeine、Redis里的不同叫法

最后可以展示一下你的知识广度。比如Caffeine通过maximumSize或者maximumWeight控制驱逐,通过expireAfterWrite和expireAfterAccess控制过期。Redis则单独用maxmemory-policy控制最大内存和驱逐策略,用每个key的TTL控制过期。从中可以看到,不管缓存组件怎么设计,底层都是这两种不同维度的管理:一个管“能不能装得下”,一个管“数据还灵不灵”。

对比的时候可以点一句:Redis的惰性过期和定期过期与JCache的懒检查/主动扫描完全同构。所以你掌握了JCache的机制之后,再看其他缓存组件,会发现核心思想都是共通的。

最后再说一点个人体会。实话说,驱逐和过期之所以容易混,是因为它们最终都表现为“某个key突然取不到值了”。但只要你从触发时机去反推,就永远不会答错:主动跑去问“是不是容量满了才删?”,那就是驱逐;问“是不是时间到了才删?”,那就是过期。在真实的排查工作中,我通常先看一眼缓存命中率,如果命中率下降和写入量高峰期吻合,多半是驱逐;如果命中率下降发生在某几个固定时间点附近,则多半是过期或缓存雪崩。理解这两个机制的底层差异,不只是为了应付面试,它真的能帮你更快地定位线上问题。

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

RAGFlow本地RAG框架:开箱即用的文档问答系统

简介&#xff1a;RagFlow-main 是一个面向大模型研发与工程化实践的开源框架代码库&#xff0c;适用于AI算法工程师、深度学习开发者及高校科研人员&#xff0c;聚焦于流图驱动的大规模模型训练与部署优化。资源包含564个文件&#xff0c;以97个Python核心模块、95个TypeScript…

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

MCP实战:为Claude Code接入Google实时搜索

Claude Code 用久了&#xff0c;最让人难受的不是它写代码的能力&#xff0c;而是它查不到新东西。模型的知识有截止时间&#xff0c;你问它某个库的最新版本、某个报错的最新解法、某个框架刚发布的新接口&#xff0c;它只会基于记忆和推理来回答&#xff0c;经常给出一个看起…

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

净化门厂家怎么选?一线采购的选型逻辑与考察要点

干了这么多年净化工程采购&#xff0c;经常有人上来就问一句“净化门厂家哪家好”&#xff0c;说实话这个问题我特别怕。净化门这东西&#xff0c;看着就是一扇门&#xff0c;实际拆开讲&#xff0c;门板材质、填充物、密封结构、五金件、自动化配置&#xff0c;每一项都能差出…

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

iOS卡片堆叠无限滑动实现原理与工程实践

1. 项目概述&#xff1a;为什么“卡片堆叠无限滑动”成了iOS界面设计的高频刚需 最近在好几个实际项目里&#xff0c;都碰到了同一个需求&#xff1a;用户要像翻扑克牌一样&#xff0c;一张张滑动浏览内容&#xff0c;每张卡片带轻微位移和透明度渐变&#xff0c;滑到最前时自动…

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

C#人脸识别实战:基于ViewFaceCore轻松实现人脸检测与比对

简介&#xff1a;面向 C# 开发者的开源人脸识别库 ViewFaceCore 基于 SeetaFace6 进行封装&#xff0c;并以 NuGet 包形式分发&#xff0c;支持 .NET Standard 2.0、.NET Core 2.0 和 .NET Framework 4.6.1 及以上环境&#xff0c;调用方只需少量代码就能完成人脸检测、人脸对比…

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

开源AI多格式内容生成系统:从PPT到海报的架构与实战

上周帮某团队赶一个产品发布会的全套物料&#xff0c;需求方给了主题和三个核心卖点&#xff0c;要求输出一份二十页左右的演示文稿、两张社交媒体宣传图、一张竖版营销海报。放在以前&#xff0c;这个工作量从提纲到排版至少得折腾一两天&#xff0c;但这次从拿到主题到四套物…

作者头像 李华