最近有小伙伴在准备面试时问到我: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突然取不到值了”。但只要你从触发时机去反推,就永远不会答错:主动跑去问“是不是容量满了才删?”,那就是驱逐;问“是不是时间到了才删?”,那就是过期。在真实的排查工作中,我通常先看一眼缓存命中率,如果命中率下降和写入量高峰期吻合,多半是驱逐;如果命中率下降发生在某几个固定时间点附近,则多半是过期或缓存雪崩。理解这两个机制的底层差异,不只是为了应付面试,它真的能帮你更快地定位线上问题。