1. 面试题背后的考点:为什么JCache的驱逐策略没有“标准答案”
1.1 JSR-107只画了框,没填内容
JCache(JSR-107)是Java官方的缓存API标准,2014年发布最终版本,目标是给Java生态提供一套统一的缓存编程模型。这套规范定义了CachingProvider、CacheManager、Cache、CacheConfiguration、CacheLoader、CacheWriter等核心接口,让业务代码可以不绑定任何具体缓存中间件,像用JDBC一样统一操作缓存。
但很多人没有注意到一个关键设计:JSR-107规范在驱逐策略(Eviction Policy)这里,刻意只画了一个框,没有填内容。javax.cache.configuration.EvictionPolicy本质上是一个空的标记接口,MutableConfiguration里确实提供了setEvictionPolicy方法,但传进去的只是一个占位对象,具体如何驱逐、什么时候驱逐、容量阈值怎么定义,全部交给Provider自行实现。
我当年第一次看JCache源码时也很意外,以为标准API后面会有一套默认实现,结果翻遍javax.cache包,只有接口契约,没有驱逐算法。这背后其实有合理考量:本地堆缓存、堆外内存缓存、分布式缓存,不同实现的驱逐机制和成本差异太大,如果规范强行统一算法,反而会让实现方无法做深度优化。
1.2 面试官问的是“配置容量驱逐”,而不是“什么是驱逐”
面试题里加上“容量”这个词,考察的层次就变了。不是简单问“驱逐策略有哪几种”,而是问“在一个最多只能放N条数据的缓存里,满了之后按照什么规则淘汰旧数据”。这需要你同时掌握三个层面的能力:
- 知道JCache的配置入口在哪里,
CacheConfiguration、MutableConfiguration、CacheManager.createCache之间的关系是什么; - 清楚标准API的能力边界,明白“驱逐策略是非标准化的、由Provider决定的”,这是很多人栽跟头的地方;
- 能落出一段真实可运行的配置代码,至少熟悉一种常见JCache实现,比如Hazelcast或Ehcache。
所以这道题答得好不好,跟背不背题关系不大,取决于你实际动过几次手。下面先补一段JCache标准配置的基础代码,把整个缓存创建的链路串起来,再进入具体的容量驱逐配置。
CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); MutableConfiguration<String, Product> config = new MutableConfiguration<>(); config.setTypes(String.class, Product.class); config.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ONE_HOUR)); Cache<String, Product> cache = cacheManager.createCache("productCache", config);这段代码是纯标准API,在任何实现了JSR-107的Provider上都能跑。但如果你想在创建缓存时直接指定“最多放5万条、满了淘汰最久未使用的数据”,标准API就帮不上忙了,必须借助Provider的扩展配置。
1.3 LRU、LFU、FIFO:容量驱逐里的三种主流算法
在讲具体配置之前,有必要先把驱逐算法的口径对齐,因为面试官很可能在代码之后追问一句“为什么选LRU而不是LFU”。
- LRU(Least Recently Used,最近最少使用):根据条目的最后访问时间排序,优先淘汰最长时间没有被访问的条目。它适合数据热度随时间衰减的场景,比如用户会话、热点商品详情。实现时需要维护一个按访问时间排序的结构,高并发下锁竞争会比较明显,所以很多Provider做的是近似LRU,不是教科书式精确LRU。
- LFU(Least Frequently Used,最不经常使用):根据条目的访问频率排序,优先淘汰访问次数最少的条目。它适合访问频率有明显长尾分布的业务,比如“少部分热点key扛起大部分请求”的场景。缺点是需要维护访问计数器,内存开销更高,而且一个历史上非常热的key可能长期占着容量不退,出现“缓存污染”。
- FIFO(First In First Out,先进先出):按插入顺序淘汰,先进入缓存的条目先被淘汰,完全不考虑访问热度。实现最简单,适合几乎不存在访问热点差异的场景,但对大多数业务来说太粗暴。
从实现难度和业务适配度上看,实际生产中LRU用得最多,LFU在特定热点场景下更优,FIFO一般只出现在后台批处理类缓存中。
1.4 容量驱逐和过期(Expiry)是两件事
这个点必须放在最前面讲清楚,因为面试里至少有一半的人会在这里翻车。过期是时间维度的主动失效,条目的生命周期由ExpiryPolicy决定,到了时间就被标记为过期;容量驱逐是空间维度的被动淘汰,只有在缓存容量达到上限、需要写入新数据时才会触发。
JCache标准API对ExpiryPolicy有非常完整的定义,你可以配置TTL、访问后刷新时间等;但对驱逐策略,规范只提供了占位接口。这意味着什么?意味着“设置了10分钟过期”和“配置了容量驱逐”完全是两个独立维度,不能互相替代。一个容量设置过小的缓存,即使每个条目都设置了60秒过期,仍然可能在几十秒内被大量新key把热点数据挤出去。
后面我会用一个真实踩坑案例来说明这一点,现在先记住结论:生产环境中两者必须同时配置,各自承担各自的职责。
2. 动手配置容量驱逐:以Hazelcast和Ehcache两种实现为例
2.1 用Hazelcast的CacheConfig配置LRU驱逐
Hazelcast是JSR-107 TCK(Technology Compatibility Kit)过得很彻底的主流实现,也是很多中大型项目在分布式场景下直接选用的Provider。它的JCache扩展配置类叫com.hazelcast.config.CacheConfig,这个类实现了JCache标准接口CompleteConfiguration,所以可以直接传给CacheManager.createCache,不需要绕道。
下面是一段完整的配置代码,目标很明确:创建一个名为userSessionCache的缓存,最多存放10万条会话数据,满了之后按LRU策略驱逐最久未使用的条目。
// 1. 获取标准JCache入口 CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); // 2. 使用Hazelcast扩展的CacheConfig CacheConfig<String, UserSession> cacheConfig = new CacheConfig<>(); cacheConfig.setName("userSessionCache"); cacheConfig.setTypes(String.class, UserSession.class); // 3. 配置容量驱逐:最多10万条,LRU策略 EvictionConfig evictionConfig = cacheConfig.getEvictionConfig(); evictionConfig.setEvictionPolicy(EvictionPolicy.LRU); evictionConfig.setMaxSizePolicy(MaxSizePolicy.ENTRY_COUNT); evictionConfig.setSize(100_000); cacheConfig.setEvictionConfig(evictionConfig); // 4. 创建缓存 Cache<String, UserSession> cache = cacheManager.createCache("userSessionCache", cacheConfig);三个核心参数的含义拆开说:
setSize(100_000)是容量上限值,但注意它要跟MaxSizePolicy配合才有效。这里用的是ENTRY_COUNT,表示按条目数限制。Hazelcast的MaxSizePolicy还支持按堆内存占用比例(USED_HEAP_MEMORY_PERCENTAGE)、按堆外内存大小(USED_NATIVE_MEMORY_SIZE)等策略,适合不同存储形态。setEvictionPolicy(EvictionPolicy.LRU)是驱逐算法选择。Hazelcast的枚举里还有LFU、RANDOM、NONE,其中NONE表示容量满了之后不驱逐,直接拒绝新条目写入。生产环境中这个选项要慎用,一旦选错,缓存满后写入会直接失败。getEvictionConfig()返回的是已初始化的对象,直接改属性即可,不需要自己new。但如果你是手动new一个EvictionConfig再塞回去,效果也一样,代码语义上更明确。
这里有个容易被忽略的细节:同一个CacheManager下,对同一个缓存名重复调用createCache会抛异常。生产环境里通常先尝试cacheManager.getCache(name),拿不到再创建;或者用try-catch,在CacheException里兜底。
2.2 用Ehcache 3的资源池配置堆内容量
Ehcache从3.x开始完整支持JSR-107,它的集成思路跟Hazelcast不太一样:不强制你用扩展API,而是让你先用Ehcache底层的CacheConfigurationBuilder构建一个资源池配置,再通过Eh107Configuration包装成JCache标准配置,然后走CacheManager.createCache入口创建。
看代码:
// 1. 构建Ehcache底层的CacheConfiguration org.ehcache.config.CacheConfiguration<String, UserSession> ehcacheConfig = CacheConfigurationBuilder.newCacheConfigurationBuilder( String.class, UserSession.class, ResourcePoolsBuilder.newResourcePoolsBuilder() .heap(50_000, EntryUnit.ENTRIES) // 堆上最多5万条 ) .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(30))) .build(); // 2. 包装成JCache Configuration javax.cache.configuration.Configuration<String, UserSession> configuration = Eh107Configuration.fromEhcacheCacheConfiguration(ehcacheConfig); // 3. 走标准入口创建缓存 CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); Cache<String, UserSession> cache = cacheManager.createCache("userSessionCache", configuration);.heap(50_000, EntryUnit.ENTRIES)表示堆内最多放5万条条目。这里有两个关键认知:
第一,Ehcache的默认驱逐策略不是LRU,而是近似LFU。如果你在面试中能把“Ehcache默认用的是近似LFU,不是LRU”这个点说出来,面试官基本能确认你真正翻过实现,而不只是看了个配置样例。
第二,ResourcePoolsBuilder可以同时配置多层资源池,比如.heap(5000).offheap(200, MemoryUnit.MB).disk(1, MemoryUnit.GB),意思分别是堆内最多5000条、堆外最多200MB、磁盘最多1GB。各层之间的换入换出由Ehcache内部管理,容量驱逐策略在不同层之间的联动语义又不一样,这是扩展话题,面试的时候可以只点一句“如果配了多级资源池,驱逐会发生在层与层之间的换入路径上”,给面试官留个追问钩子。
除了编程式配置,Ehcache还支持XML方式:
<cache alias="userSessionCache"> <resources> <heap unit="entries">50000</heap> </resources> </cache>然后在代码里通过Eh107Configuration.fromXmlResource("ehcache.xml")加载整个配置文件,再创建缓存。这种方式方便运维调整容量,不需要重新编译发版。
2.3 为什么不建议在纯标准API里塞驱逐配置
网上很多博客会教一个“技巧”:直接用MutableConfiguration,调用setEvictionPolicy(new CustomEvictionPolicy()),以为传一个自定义的EvictionPolicy实现就能配置驱逐。实际上这个EvictionPolicy是个标记接口,里面没有任何需要实现的方法,Provider不消费它。你塞进去一个空对象,缓存该怎么样还是怎么样。
所以这道题的“正确姿势”是先确定你的CachingProvider是哪一个,然后去读这个Provider针对JCache的扩展文档。Hazelcast有EvictionConfig,Ehcache有ResourcePools,其他Provider也各有各的入口。把这一点讲清楚,比背一堆API签名更能体现对规范边界的理解。
3. 面试追问环节:容量驱逐的触发逻辑与易混淆点
3.1 容量“满了”到底由谁判定
JCache规范没有定义“容量满”的唯一标准,这给面试官留了非常大的追问空间。同一个“10万条上限”,在Hazelcast里由MaxSizePolicy.ENTRY_COUNT判定,在Ehcache里由heap(100000, EntryUnit.ENTRIES)判定。但如果换一种限制方式,比如按内存占用比例判定,逻辑就完全不同了:一个缓存里全是大对象,可能还没写到5万条,内存占用先爆了。
还有一个隐藏更深的点:即使配置了10万条上限,在并发写入的瞬间,实际条目数也可能短暂超过10万。因为驱逐是在写入路径上“检查容量、执行淘汰”的动作,做不到严格意义上的“写入前先驱逐”。在分布式实现里更是如此,分区分布在多个节点上,全局条目数的统计有延迟,容量上限只能保证“最终趋向于不超过”,不是实时精确值。
3.2 驱逐一次淘汰一条还是一次一批
JCache规范同样没有规定单次驱逐多少条。如果你在面试时主动提这一点,会显得思考层次更高:在一次高并发put触发的容量检查中,如果只淘汰一条,下次put又要触发一次驱逐,写路径的抖动会很严重,而且这种抖动会传染给所有并发线程。所以主流实现一般会一次性淘汰一批条目,比如按批次驱逐直到容量降到阈值以下,或者采用异步批量清理。
这在Hazelcast和Ehcache的源码里都有体现,但实现细节不同。面试时不需要准确说出“Hazelcast每次驱逐多少条”,而是表述为“规范没规定,生产级实现一般会做批量驱逐来降低写路径抖动”,这就够了。
3.3 驱逐会不会触发CacheEntryListener事件
JCache标准API提供了CacheEntryListener,其中CacheEntryRemovedListener监听的onRemoved、CacheEntryExpiredListener监听的onExpired大家比较熟。但驱逐算不算“移除”?规范里写得很模糊,结论是:可能触发,也可能不触发,取决于实现。
我在Hazelcast和Ehcache的不同版本上都观察过不一致的行为。Hazelcast的某些版本里,驱逐和CacheEntryExpiredListener有联动,但换个版本可能行为又变了;Ehcache的堆内驱逐走的是一条内部淘汰链路,不一定会作为标准JCache的REMOVED事件抛给CacheEntryRemovedListener。
所以生产代码里千万不要依赖“监听器一定会收到驱逐通知”来清理外部资源,比如用监听器去删除关联的数据库记录或发MQ消息。你要是依赖了,版本升级分分钟给你来个“静默事故”。
3.4 EntryProcessor执行期间会被驱逐吗
这个问题如果面试官问出来,说明他已经在考察并发安全了。EntryProcessor是JCache定义的一种在缓存条目上执行原子操作的机制,规范要求执行期间必须保证原子性。但驱逐是Provider内部行为,规范没有明确规定“驱逐不能被EntryProcessor打断”。
实际实现中,成熟的Provider会感知当前条目的锁状态。写入路径触发驱逐时,如果某个key正被EntryProcessor锁住,通常会被跳过,等锁释放后再补一次驱逐检查。你可以把这一点作为加分项说出来,然后补一句“但这属于实现细节,标准API不保证,所以如果你的业务同时依赖EntryProcessor和极端容量配置,一定要在高并发压测下验证”。
4. 生产环境里容量驱逐的真实瓶颈与避坑建议
4.1 容量估算是第一步,别拍脑袋写个10万
面试答完代码,真正的生产考验才开始。我把所有调过的JCache容量问题整理了一下,发现最根本的坑是:很多人配置容量时根本没有估算,随手写一个“看起来很大”的数字。
我建议用窗口模型粗算,再用压测校正:
所需容量 ≈ 峰值QPS × 数据在缓存中的平均存活时长
举例:一个订单查询接口,峰值QPS是5000,业务允许订单数据在缓存中最多保留30秒(也就是说DB更新后30秒内可见延迟可以接受),那么容量下限就是5000 × 30 = 150000条。如果每条约2KB,堆内存占用约300MB。这时候你就要判断:这个体量适不适合放堆内缓存?JVM堆够不够?要不要换成堆外?
注意,这只是理论下限。现实中的请求分布不可能完全均匀,热点集中时容量会被快速消耗,所以我一般会在这个基础上再加20%到30%的缓冲。如果是大促期间的新业务,我更建议第一版直接按理论值翻倍配置,再根据监控逐步下调。
4.2 命中率必须监控,否则驱逐策略就是盲人摸象
配置容量驱逐不是一劳永逸的事。JCache标准里定义了CacheStatisticsMXBean,通过JMX可以拿到缓存命中次数、未命中次数、驱逐次数等关键指标,前提是你的Provider启用了统计功能。Hazelcast的CacheConfig有isStatisticsEnabled(),Ehcache也提供对应的统计开关。这些都是JCache标准之外、由Provider实现的JMX暴露方式,建议压测和上线前就打开。
命中率的计算公式很简单:命中率 = 命中次数 / (命中次数 + 未命中次数)。当命中率低于95%的时候,缓存的价值就已经存疑了,你需要立刻去看驱逐计数。如果驱逐次数在一段时间内异常暴涨,说明容量窗口不够,或者出现了大量临时key把热点数据挤出去了。
我的经验是:上线容量驱逐后的前两周,每天都要看命中率曲线。调容量时不要一次调太大,每次涨30%,观察两三天再决定下一轮。这个节奏可以避免一次调太大导致内存超预算。
4.3 一个真实踩坑案例:把容量驱逐当成“过期”用
我接过一个线上问题,现象是用户经常登录状态丢失,排查半天最后落到缓存上:团队把用户Session数据放进JCache,只配置了10万条容量和LRU驱逐,完全没配ExpiryPolicy。表面上看LRU会淘汰最久未使用的Session,似乎“够用”,但问题恰恰出在这里。
压测工具产生的海量临时key把真实用户的Session从LRU队列里挤了出去。LRU只保证“容量不够时淘汰最久未使用”,不保证“Session必须存活N秒”。会话类数据必须靠ExpiryPolicy做时间维度的兜底,容量驱逐只能作为空间维度的安全绳。
这个案例在面试时也很有说服力,能体现你真正理解驱逐和过期的边界。
4.4 堆内还是堆外,决定驱逐的成本和上限
如果你的容量估算结果超过JVM堆的15%到20%,就不建议继续用堆内LRU硬扛了。原因有两个:
第一,大堆的GC停顿不可接受,Full GC会把请求延迟拉上去好几个量级。第二,LRU和LFU自身也要代价——维护访问时间戳或访问计数器需要额外内存,在超大规模缓存里,这部分元数据的开销很可观。
这类场景通常有两条路:一是交给堆外内存,Hazelcast支持MaxSizePolicy.USED_NATIVE_MEMORY_SIZE配合InMemoryFormat.NATIVE,Ehcache也提供了OffHeapResourcePoolBuilder;二是干脆不要用JCache做一级大缓存,把它做成分布式缓存的一层短TTL前置缓存,容量控制在可承受范围内。
但堆外缓存不是免费的午餐,读写都需要序列化和反序列化,驱逐时无法直接拿到对象引用做高效比较,命中率敏感型业务需要仔细压测。
4.5 多级缓存和驱逐联动,别让下游读到旧数据
如果JCache只是你整个缓存链路中的一级,前面有本地热数据、后面有Redis或数据库,那么驱逐就不只是缓存内部的事了。
当JCache因为容量上限驱逐了一条数据,如果下游没有收到任何“这条数据失效了”的通知,下一次请求还是先从JCache里读不到,然后去后面的二级缓存或数据库查,再把结果写回JCache,看起来没什么问题。但如果你在下游也放了一份同样的数据,并且下游那份数据更新更慢,就可能出现“JCache里已经被驱逐、二级缓存里还是旧值”的窗口。
应对方案是在驱逐发生后主动发一个轻量级失效事件,比如把被驱逐的key打进一个消息队列,让下游做无害化处理。前提是你确认Provider真的能稳定触发驱逐监听器,否则就要在get时额外验一次时间戳,或者设置一个比业务容忍上限更短的过期时间来兜底。我在4.3的案例之后,给那个团队定的规矩就是“过期保证业务语义,驱逐保证运行安全,两者缺一不可”。
5. 这道面试题的高分回答结构与话术
5.1 一个可以直接用的“三段式”回答模板
面试不是笔试,不能只对着白板写代码,要结构化表达。我自己面试别人时,最反感两种答案:一种是背了一堆配置项名称,但完全没有层次;另一种是上来就写代码,写完整页白板也说不出为什么这么配。一个好答案应该按“边界、实现、设计”三层往下递进。
第一层,先讲边界,10秒内点出关键:“JSR-107规范把驱逐策略定义为Provider相关的非标准能力,标准API只提供了EvictionPolicy标记接口和CacheConfiguration入口,没有规定具体算法和容量参数。”面试官听到这里,就知道你对规范的定位是清楚的。
第二层,落到实现,讲一个你项目里实际用过的Provider。比如:“我们团队用的是Hazelcast的JCache实现,配置方式是创建一个com.hazelcast.config.CacheConfig,通过setEvictionConfig指定EvictionPolicy.LRU、MaxSizePolicy.ENTRY_COUNT和size,然后通过CacheManager.createCache把配置传进去。”如果你用的Ehcache,就把说法换成ResourcePoolsBuilder.heap和Eh107Configuration.fromEhcacheCacheConfiguration。
第三层,升级到设计,30到60秒。补一个容量估算公式、提一句“驱逐不等于过期,两者要配合使用”、再说一下通过JMX统计命中率和驱逐次数来验证配置合理性。到这一层,你已经和只会背API的候选人拉开了差距。
5.2 面试官可能追问的三个变体
“Ehcache和Hazelcast的配置方式有什么区别?”如果你主用Hazelcast,不要硬编一个Ehcache的答案,可以说:“我用Hazelcast比较多,Ehcache我知道它是通过ResourcePoolsBuilder配置堆内条目数,再用Eh107Configuration包装成JCache配置,但具体到某个版本的API差异我需要翻一下文档确认。”面试官要看的往往是你面对不确定知识时的处理方式,诚实加检索路径是好答案。
“容量满了但所有key刚被访问过,会驱逐谁?”这个追问考察的是对近似算法的理解。你可以说:“教科书式的LRU在这种情况下没法选出淘汰对象,因为所有key的时间戳都很新。实际实现大多是近似LRU或采样LRU,不是全局精确的,不同Provider会通过分片、采样窗口等方式降低维护成本。”能说出“近似”两个字,面试官就知道你理解工程实现和理论算法的差距。
“分布式场景下容量驱逐由谁触发?”这个问题比较大,可以挑一个点切入:“Hazelcast的容量配置默认是按照节点本地生效的,集群里的总数等于节点容量乘以节点数,不是全局一份。分布式环境下驱逐会涉及分区归属和备份同步,复杂度比单机高很多。”如果你们项目其实把一级缓存放在本地堆、分布式缓存交给独立中间件,也可以直说:“我们当时主动避开了JCache的分布式复杂度,本地JCache只做一级短期缓存,最终以外部缓存为准。”这同样是合理的设计取舍。
5.3 别掉进的两个“面试雷区”
雷区一:把驱逐策略和过期策略混为一谈,回答成“设置10分钟过期就是驱逐”。这个我之前提过,一旦被追问“缓存没满但也过了10分钟,算不算驱逐?”“缓存满了但没到10分钟,会淘汰谁?”就露馅了。同样地,把Redis的allkeys-lru当成JCache配置也是不对的,虽然思路相通,但配置入口和参数体系完全不同,不能直接画等号。
雷区二:只回答“LRU淘汰最久未使用”,但说不清LRU怎么衡量“最久未使用”。面试官接着问“读操作会刷新访问时间吗?遍历操作呢?EntryProcessor执行时呢?”就会卡住。这种细节不一定要求你全部回答精确,但至少要有“具体语义看Provider实现”的意识。
最后再分享一个我印象很深的案例。有一次线上告警,缓存命中率从99%掉到82%,排查了半天没发现问题,最后是看到JMX里驱逐次数在一小时内暴涨了几十万次。复盘发现是运营批量导数据,每个请求都生成一个带唯一ID的临时key,把热点商品数据全顶出去了。那次之后我们定了一个规矩:JCache缓存必须同时配置ExpiryPolicy和容量驱逐,容量估算是发版流程的必填项;临时性数据要么走很短的TTL,要么单独放一个容量很小的缓存实例。后来再没有出现过同类事故。希望你下次配容量驱逐的时候,也能想起这句话:驱逐是兜底,不是业务语义。