前阵子在准备面试题的时候被一个看似基础的问题给套住了JCacheJSR-107里缓存驱逐Eviction和缓存过期Expiration到底有什么本质区别第一反应是“这俩不都是把数据干掉吗”可真要你说清楚触发机制、适用场景、代码层面的表现突然发现脑子里的概念是糊成一团的。后来翻了规范、源码和各种实现才把这俩概念彻底理清楚。这篇就当一份梳理笔记也给正在刷“每日一题”的朋友做个参考。这个题目之所以值得认真拆是因为它考的不只是两个名词的定义而是你对缓存底层运作机制的理解深度。面试官不会满足于“驱逐是空间不够踢数据过期是时间到了删数据”这种一句话答案他更想听到的是两者的触发源、判定方式、执行时机、对业务的影响分别是什么以及落到JCache这种标准化接口上时你会怎么写配置、怎么取舍。如果你正在准备中高级Java岗位面试或者在工作中经常跟本地缓存、分布式缓存打交道这篇文章应该能帮你把这块知识拼图补完整。1. 面试题拆解为什么这两个概念总被搞混这道题看起来只是基础篇的一道“送分题”但据我观察能干净利落讲清楚的人真不多。大多数人的状态是知道LRU是淘汰知道TTL是过期但被问“驱逐和过期在JCache里的核心差异是什么”时就卡壳了。卡壳的原因很典型——两个概念在表象上重叠度太高底层机制却是两套完全不同的逻辑。我们先还原一下面试场景。面试官大概率会这样追问“你的缓存配置里同时设置了最大条目数和过期时间那一条数据被踢出去你能判断它是被驱逐了还是过期了吗”“一个条目还没到过期时间但它无人访问会不会被清理如果会是谁在什么条件下清理的”“JCache里Eviction和Expiration分别对应哪些接口和策略如果缓存实现不支持某种驱逐算法你的程序会怎样”这些追问的底层逻辑就是逼你去区分“空间维度”和“时间维度”这两条完全独立的缓存生命周期线。我的梳理结论是这样的缓存驱逐Eviction本质是空间管理策略。缓存容器有容量上限当写入导致容量超标时容器需要按既定规则移除一部分条目。这个动作的触发条件是“容量不足”跟时间、新鲜度没有关系。缓存过期Expiration本质是时间管理策略。每个条目拥有生命周期从创建开始计时到达某个阈值后该条目变为“不可用”。后续访问时要么返回null要么触发重新加载。这个动作的触发条件是“时间到达”跟容量没有关系。在JCacheJSR-107的语境里这两者属于不同层面的机制规范对它们也不是同一个处理路径。后面我会逐个拆开。2. 概念层面的本质区别空间管理 vs 时间管理如果只背一句话答案可以这么说Eviction管的是“缓存能装多少”Expiration管的是“数据能活多久”。但面试要的是把这句话展开成体系。2.1 缓存驱逐容量约束下的被动淘汰Eviction的核心出发点是物理限制。任何缓存容器能占用的内存、能保存的条目数都是有限的当你不断往里面写数据总量会冲破设定上限。这时缓存必须做出选择牺牲哪些数据保住哪些数据。这是被动的、由系统条件触发的行为。驱逐策略解决的是“踢谁出去”的问题。业界常见的几种算法LRULeast Recently Used最近最少使用把最久没有被访问的条目踢出去。适合“一段时间内频繁访问的数据大概率未来还会被访问”的局部性规律。LFULeast Frequently Used最不经常使用统计访问频率把访问次数最少的踢出去。适合热门数据相对稳定的场景但实现成本高要维护计数且要考虑计数老化。FIFOFirst In First Out先入先出按插入顺序淘汰。实现最简单但完全没有考虑数据热度和新鲜度效果通常最差。在JCache规范里Eviction本身被定义为一套可选功能容器可以在容量达到上限时移除条目但规范并没有限定必须用哪种算法。这意味着不同厂商实现可以自由选择——比如某实现默认用LRU某实现支持自定义驱逐策略。这一点在面试时值得强调JCache只是定义了“驱逐”行为的存在算法的落地细节是交给实现的。驱逐动作有几个典型的微观特征触发条件与写入操作强相关。通常是在执行put或putAll操作后发现当前容量超过上限才触发驱逐。被驱逐的条目大概率还在有效期内。它单纯因为空间不够被牺牲不代表它已经没用了。驱逐是战略性的。缓存为了提高整体命中率必须有选择地牺牲部分数据换取更有价值数据的存留。2.2 缓存过期时间约束下的主动失效Expiration的核心出发点是业务语义。数据源的数据是变化的比如商品价格、用户会话状态缓存里的副本不可能永远等同于数据源。我们需要给缓存数据设定一个“保质期”到期后这条数据就不许再被当作有效数据使用。过期的核心概念有两个TTLTime To Live条目从创建到过期允许存活的总时长。TTITime To Idle条目如果持续被访问可以一直续期一旦空闲超过阈值就会过期。JCache规范里没有直接用“TTL”这个术语作为API命名但它定义了ExpiryPolicy接口里面有三个关键方法getExpiryForCreation创建条目时的默认过期时间。getExpiryForAccess每次访问条目时可以返回一个新的过期时间实现“滑动过期”效果。getExpiryForUpdate更新条目时可以重新计算过期时间。这个接口设计其实很值得玩味它没有强制你使用固定过期时间而是把决策权交给你。你可以实现固定TTL也可以实现TTI还可以实现“创建后10分钟过期但访问一次再延长5分钟”这类业务规则。过期条目的归宿也有讲究。一个条目到了过期时间它在逻辑上已经不存在了但物理上可能还占着内存。缓存容器负责在合适的时机进行清理常见的处理方式是惰性删除lazy访问时检查是否过期过期就返回null并顺手删除。优点是开销小缺点是过期数据可能长期滞留占内存。定期清理scheduled后台线程定时扫描批量移除过期条目。优点是内存释放主动缺点是需要额外线程开销。我见过不少人以为“过期时间到了数据立刻就会被移除”实际情况通常是“时间到了只是访问时发现它过期了”真正的内存释放要等后续操作。这个认知偏差在问题排查里影响很大——你可能会发现缓存明明设置了过期时间内存指标却迟迟不降。2.3 用生活类比强化理解如果觉得概念太抽象可以用一个生活场景来锚定。假设你有一个地下储物间地方有限只能放100个箱子这个储物间就是一个缓存容器。“放新箱子但没有空位了只能按标签上写的最近一次使用日期把最久没用过的几箱东西扔出去”——这就是Eviction。空间不够了大家还没过保质期但必须有人让位。“箱子侧面写着保鲜期30天到期后哪怕储物间空荡荡你再去拿的时候也会发现它已经不能用了”——这就是Expiration。空间不是问题但物品本身失效了。两者唯一相似的地方是“结果上数据都没了”但一个是“被容器迫于容量的淘汰”另一个是“数据自身生命周期的终结”。3. 触发机制与执行路径的差异JSR-107落地视角概念说清楚后重头戏是触发机制的底层差异。这也是面试最容易深挖的点因为Eviction和Expiration在实现层面走的是完全不同的代码路径。3.1 Eviction的触发链路写在写入路径里从JCache实现角度去看驱逐触发通常沿着这样一条链路走调用者执行cache.put(key, value)。缓存内部先完成条目写入放进存储结构。写入后检查当前条目数或内存占用是否超过阈值。如果超过阈值调用驱逐策略选择要移除的条目。对被选中条目执行删除并触发相应的监听器如果配置了CacheEntryListener。这里有个关键点驱逐检查是同步的至少从外部看put操作在返回前容器已经完成了“是否驱逐、驱逐谁”的决策。这意味着这条路径直接影响到写操作的延迟因此驱逐算法的效率非常重要。如果驱逐策略要做复杂的计算或遍历写性能会肉眼可见地变差。不同实现的驱逐粒度也不同。有的实现是“插入时逐条检查”有的是“内存占用达到阈值后按批次驱逐一批条目”。比如遇到写峰值时某些实现可能一次驱逐几十条。还有一点容易忽略有些JCache实现把驱逐做成了异步后台任务。这种方案降低了对写路径的侵入性但代价是容量可能在短时间内超标对资源控制要求高的场景就要小心。这块在实际使用时要看具体实现的文档面试时也建议主动问一句“你们线上用的实现是同步驱逐还是异步驱逐”能让面试官看到你的实战敏感度。3.2 Expiration的触发链路分散在访问路径和后台任务里Expiration的触发源分散得多标准实现普遍会区分“逻辑过期”和“物理删除”。逻辑过期发生在读路径上调用者执行cache.get(key)。缓存根据ExpiryPolicy判断该条目是否已过期。如果过期缓存不返回该值而是当作“没有这个条目”处理。如果条目处于“需要重新加载”的模式可能会调用CacheLoader重新加载再把新值返回给调用者。这个过程中条目可能还没有被真正从内部数据结构里删掉只是对外不可见了。物理删除则通常由后台清理任务完成定时扫描后台线程每隔一段时间遍历条目把已过期的条目从存储中移除。惰性删除的补充在写入新值时顺手检查相邻条目是否过期。从触发时机这个维度看Eviction是被“写”驱动的Expiration是被“时间”驱动的且它的可见性变化可以在“读”的时候就发生。二者触发路径差异很大。3.3 一个容易被忽视的细节被驱逐还是被过期业务感知完全不同对业务代码而言驱逐和过期带来的后果不一样。被过期的条目意味着数据源那边很可能已经有新数据了。此时重新加载获取到的是新值。被驱逐的条目数据源那边数据可能原封未动只是缓存没有容纳它重新加载得到的有可能还是旧值。也就是说过期通常伴随着“数据刷新”语义而驱逐更多是“缓存容量调度”语义两者对下游系统的意义完全不同。如果在面试里能把这一点说出来基本就能跟那些只背概念的人拉开差距。4. 代码里的真实体现配置、策略与可观察行为光说理论还是有点虚直接在代码层面看一下JCache里怎么配置这两种机制。4.1 准备一个JCache环境JCache是Java标准规范实际使用时需要引入实现了JSR-107的缓存提供方。常见的有Oracle的Reference Implementation、Hazelcast、Ehcache等不同实现的API都在javax.cache这个包下用法基本一致。我习惯用Maven引入一个实现来做本地实验。配置一个缓存管理器通常长这样import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.AccessedExpiryPolicy; import javax.cache.expiry.Duration; import java.util.concurrent.TimeUnit; public class JCacheDemo { public static void main(String[] args) { CacheManager cacheManager Caching.getCachingProvider() .getCacheManager(); MutableConfigurationString, String config new MutableConfiguration(); // 设置过期策略访问后10分钟过期滑动过期 config.setExpiryPolicyFactory(() - new AccessedExpiryPolicy( new Duration(TimeUnit.MINUTES, 10))); // 开启写穿透如果配置了CacheLoader config.setWriteThrough(true); CacheString, String cache cacheManager .createCache(demoCache, config); cache.put(key1, value1); // 第一次访问会重置过期计时取决于策略 String value cache.get(key1); System.out.println(value); } }ExpiryPolicyFactory是配置过期逻辑的入口这里用的是“每次访问后重置10分钟”的滑动过期策略。如果改成CreatedExpiryPolicy就是“创建后固定10分钟过期”期间不管访问多少次到点就过期。这两种策略对应了TTI和固定TTL的区别。4.2 Eviction的配置在JCache里显得有点“含蓄”Eviction的配置在JCache标准API里并不像ExpiryPolicy那么显式。规范定义了条目数上限等概念但很遗憾通用标准接口里没有一个类似“setEvictionPolicyFactory”的东西。实际情况下你更多是通过缓存提供方特有的配置去设置。常见的几种方式通过系统属性或配置文件设置默认容量。通过CacheManager的provider特定配置类设置最大条目数。某些实现支持自定义EvictionStrategy接口。比如某实现中你可以在创建缓存时指定“最多10000条”这样的大小限制而驱逐算法通过提供方配置决定。这种设计差异经常被面试官拿来考察候选人对标准化的理解JCache本身只定义行为不限定算法使用方如果想锁定某种驱逐算法往往要依赖具体实现的能力。我个人的经验是——如果你只是用标准JCache接口做开发尽量不要把业务强依赖在“默认驱逐算法是LRU”这种假设上因为换了实现方行为可能就变了。4.3 代码实验如何观察驱逐和过期的差异如果自己想做实验验证两者的区别可以设计一个简单的压测场景创建一个最大容量只有5的缓存。使用CreatedExpiryPolicy设置10秒过期。先连续写入10个不同的key超出容量观察哪些key还在。再单独写一个key等待10秒后访问观察返回结果。写入10个key时容器会因为容量超限执行驱逐之前写入的某些key会被替换。这里的现象是“新数据把旧数据挤掉了”走的是驱逐的逻辑。随后单独写入的那个key10秒后访问返回null走的是过期的逻辑。这个实验能直观看到同样的“数据消失”一个因为空间一个因为时间。常见的一个测试误区和我说一下有人测试驱逐时会把容量和过期混在一起配置然后看到数据没了就笼统认为是“缓存失效”根本分不清是哪条链路在起作用。要准确观察建议把实验拆开一次只测一个变量。5. 实战排查与面试追问易错点、参数选择和避坑指南这一部分算是我在实际项目和准备面试过程中积攒的一些经验遇到类似问题可以直接参考。5.1 两个概念组合使用时的优先级问题实际系统中Eviction和Expiration不是互斥的而是同时存在的。一个条目可能同时面临“容量不够被踢走”和“时间到了要过期”两个风险。值得注意的点在于组合后的逻辑一个还在有效期内的条目遇到容量上限时可能被驱逐。一个已经过期的条目它内存还占着位置也可能被驱逐算法扫描到并先一步移除。一个被驱逐的条目如果重新加载后过期时间重新计算那么它在时间维度上相当于“新生”。在配置时你要想清楚自己的缓存模型如果主要矛盾是“数据源变化快”优先级应该放在Expiration的精细调参上。如果主要矛盾是“内存不够”那重心要放在Eviction的容量规划和算法选择上。两者都要兼顾时建议先按业务数据大小估算容量再按数据新鲜度要求设置过期时间不要拍脑袋随便填。5.2 面试追问JCache里驱逐和过期谁先执行这是我在面试中被问到过的一个偏门问题当时愣了一下。不同的JCache实现执行顺序并不完全一致但常见的处理顺序是这样的写入时先判断条目本身是否要过期例如更新一个已过期条目的值时旧值直接作废。写入后触发容量检查如果需要则执行驱逐。这个顺序是有道理的先处理掉失效数据再做容量调度。如果反过来可能出现“刚把一条有效数据驱逐了留下一条过期数据占着空间”的低级问题。不过这不是JCache规范强制要求的顺序实践里还得以具体实现行为为准。5.3 参数选择为什么“调大容量”不一定是解药遇到缓存命中率下降第一反应往往是“把容量调大”。这确实是最直接的方法但不能无脑调。我踩过的一个坑某个服务把缓存容量调得很大内存倒是没爆但驱逐很久没触发导致大量过期数据堆积后台清理线程又没跟上用了很长一段时间的“陈旧缓存”。后来定位下来问题在于我把过期时间设置得太长并且容量大得让驱逐机制形同虚设。数据量大但更新频繁的场景合理策略应该是“适度容量建设过期扫描根据数据热度决定驱逐算法”。容量大小不是孤立参数要和过期时间、访问模式一起考虑。5.4 常见问题排查速查表现象可能原因排查思路缓存命中率低但内存占用不高过期时间设置过短或驱逐策略过于激进看访问日志统计数据实际存活时间适当调大TTL内存持续上涨但命中率也没改善过期数据堆积惰性删除没及时清理检查后台清理任务配置考虑设置更短的清理扫描间隔缓存写延迟突然抖动驱逐策略计算量过大或容量阈值太小触发频繁驱逐换更高效的驱逐算法调大容量或改用异步驱逐刚写完的数据马上读不到容量太小写入后被立即驱逐创建临时缓存验证最大容量设置或换一种驱逐策略设置过期时间后数据仍在误把驱逐配置当成过期配置或根本没有配ExpiryPolicy检查ExpiryPolicyFactory配置确认Duration生效5.5 一条印象深刻的线上问题一次整理缓存相关的问题时见过一个有趣的线上故障。某服务缓存配置了1800秒过期时间但业务量一上来命中率掉到60%以下数据库压力陡增。大家一开始都认为是缓存容量不够忙不迭地加内存结果效果不明显。后来细查发现问题出在驱逐策略上。默认使用LRU但这个场景的数据访问模式是“周期性突发”——数据在某个时间段内被大量访问之后很久不再访问。LRU按“最近访问时间”淘汰数据导致刚刚度过热点期的数据被迅速淘汰下次周期到来时又要重新加载。换成LFU策略后命中率明显回升。这个案例至少说明两件事一Eviction的选择不是越小越好也不是越大越好而是要匹配数据访问特征二出问题时别急着怪容量或过期时间先确认到底是哪条机制在起作用。6. 基于JCache规范的源码级理解如果面试聊得很深面试官可能还会从JCache规范本身去问。我建议有时间的话你把javax.cache的核心接口看一遍会有更体系化的理解。6.1 规范中Eviction的定义位置JSR-107规范文本中对Eviction的描述集中在缓存条目移除的上下文里。规范指出缓存是一个存储键值映射的容器当容量达到限制时可以对部分条目进行移除。规范还定义了Cache.Entry这个接口用于扫描缓存条目。缓存实现可以在迭代条目时对条目施加驱逐策略。规范里有一句话很关键——它明确表示实现可以选择是否支持条目驱逐若不支持则需要显式说明。这使得“驱逐”在JCache里有一定可选性。6.2 规范中Expiration的定义位置Expiration在JCache里对应ExpiryPolicy接口这个接口定义了条目策略的三种时机是标准API不像驱逐那样含糊。ExpiryPolicy的核心返回值是Duration代表从现在到过期的剩余时间。特别值得注意的是Duration可以是无限长永不过期也可以为0立即过期这给业务设计提供了巨大弹性。JCache的ExpiryPolicy不是通过缓存配置类里的一个简单timeToLive参数实现的而是通过工厂方法注入。这种方式的好处是策略可编程化你可以根据业务来决定不同key的过期时间甚至可以动态判断。比如不同用户的会话时长不一样普通的TTL参数做不到但自定义ExpiryPolicy完全可以实现。我第一次看到ExpiryPolicy的时候最大的感悟是规范设计者显然是经历过“用一个固定TTL打天下”的痛苦才会把过期逻辑抽象成一个策略接口。这种设计上的粒度差异值得写进面试答案里。6.3 CacheLoader、CacheWriter与两者的联动JCache里还有一对重要角色CacheLoader和CacheWriter。“读穿透”与“写穿透”模式与驱逐、过期的联动也是面试偏爱的话题。当缓存get发生未命中包括过期导致未命中时CacheLoader可以加载最新数据到缓存中。当缓存写操作发生时CacheWriter可以同步更新数据源。一个经典联动场景缓存条目过期了但数据源数据其实没有变化用户下一次访问会造成一次“浪费的加载”。如果加载开销很大可以考虑把过期时间调长或者用滑动过期刷新访问时效。如果缓存条目被驱逐则同样的重新加载现象也会发生。在业务模型上你可以对“过期加载”和“驱逐加载”做不同的埋点用来观测两条机制各自对系统的影响比例。6.4 实现差异的举例与选型思路不同的JCache实现行为差异可能很大。有的实现默认不开启驱逐需要显式配置有的实现把过期清理线程设计成仅在写操作触发时顺带处理有的实现支持自定义驱逐策略接口。这些差异带来的结果就是——哪怕你的代码完全基于标准JCache API编写迁移底层provider时性能表现可能天差地别。因此选型时不要只看兼容标准API更要关注默认驱逐策略是什么是否允许自定义过期清理是惰性还是后台线程线程数、扫描间隔是否可调容量统计是按条目数还是按内存占用估算这些问题决定了缓存行为是否符合业务预期。面试时聊到这些会显得你对“标准化接口背后的实现选型”很有意识。7. 刷这道题的一点心得最后分享一点个人复习体会。这道题表面上是JCache的基础题但一层层拆下去会牵扯出很多平时没想过的细节比如规范的强制性定义和可选性定义的区别、策略接口的设计哲学、实现层面的差异陷阱、线上排查时的优先级判断等等。我个人建议如果你正在准备面试把“Eviction vs Expiration”当作一个引子顺着这条线去把本地缓存、分布式缓存的内存模型、淘汰算法、失效策略都过一遍。面试官问一道基础题真正想看的往往是你能否从一个基础概念出发展示出系统化的知识深度和实战经验。至于实际操作层面不管是做内存缓存还是做数据一致性方案记住一句我常年挂在嘴边的话把空间问题留给驱逐把时间问题留给过期配置时分开看排查时分开查不要混为一谈。