1. 为什么需要 ExpiringMap:手动清理缓存的老路有多痛
做 Java 服务的这几年来,我几乎在每个业务系统里都要和"带过期时间的缓存"打交道。早期的做法特别粗暴:用一个ConcurrentHashMap存数据,再配一个定时任务或者Timer轮询清理,又或者在每次读取的时候顺手检查一下条目的创建时间,超了就删除重新加载。这套方案在数据量小、业务简单的场景下还能跑,但一旦数据量上来,问题就层出不穷。
先说定时清理的痛。假设你开一个后台线程每 5 分钟扫一次 Map,把超过 10 分钟的数据删掉,那么这些数据在最坏情况下会在内存里存活 15 分钟。你明明设的是 10 分钟过期,实际的"隐身存活期"却远超出预期。如果你把扫描周期缩短到 1 分钟,线程频繁遍历一个大 Map 又会带来明显的性能开销,每次扫描都是 O(n) 的复杂度,数据量上了几十万条之后,这种无脑遍历对 GC 和老年代的压力都是实打实的。
再说到点读取清理。有些同学会在get()方法里判断时间戳,这样确实能让过期的数据在"被访问"的那一刻消失,但麻烦在于:如果一批数据写入后根本没人读,它们就会一直赖在内存里。缓存池里堆了几十万条已经过期的垃圾数据,占着堆内存不说,还拖慢了遍历、序列化这些操作。更头疼的是,你希望缓存失效后能"自动重新加载"新数据,这种手动时间戳方案做起来就很别扭:判断过期-删除-回源-写入,这一套代码在每个业务方都要重复写一遍,写在get()里逻辑还会越来越臃肿,写着写着就成了一坨"所有事情都掺在一起"的经典烂代码,我见过太多人在这条路上踩得浑身是泥。
理论上,这种场景适用的工具必须解决几个核心问题:数据什么时候算过期?过期了谁来清理、什么时候清理?清理动作是否需要回调通知?增加新数据时能否设置不同于全局默认的独立过期时间?内存是否该有上限、达到上限时如何处理?如果一个 Map 容器能把这些设计内置好,我就可以把"缓存逻辑"从业务代码里彻底剥离出去,让业务方只关心两件事:拿数据和拿不到数据怎么办。
我找了一圈现成的方案,ExpiringMap就是在这种需求下进入视野的。它是一个基于 Java 的 Map 实现,所有功能都围绕"过期"这个核心展开,API 非常直白,没有强依赖 Spring 或者 Redis 这些重量级组件,你只需要引入一个 JAR 包,就能像用 HashMap 一样愉快地操作它。它提供了可控的过期时间、条目级别的独立过期设置、过期监听器、容量上限、懒加载等能力,单看这些特性,简直就是为"谁都能上手的中小型缓存需求"量身定做的。
2. ExpiringMap 的核心机制:过期策略与执行原理
2.1 同步过期与异步过期,到底有什么差别
ExpiringMap 最核心的设计,是把"过期检查"变成了 Map 内部一个可以配置的开关,而不是让业务方写一堆杂乱的清理逻辑。它支持两种过期执行方式,一个叫同步过期(sync),一个叫异步过期(async)。
同步过期的意思是:当你调用get()方法时,ExpiringMap 会顺路检查一下跟这个 key 关联的ExpirationListener(如果你配了)以及条目状态,发现超时的条目会当场移除并触发回调。这种方案的好处是简单直观,不需要任何额外线程,适合条目量少、过期检查开销可以忽略不计的场景。但它的缺陷也很明显:一个拥有几十万条数据的 Map,每次get()都要做一次过期扫描,这个操作如果太频繁,反而会把get()的延迟拉高。
异步过期则更优雅:ExpiringMap 内部维护一个调度机制(或者是基于延迟队列的变体,不同版本实现有差异),定期对超时条目进行清理,你不用在每次访问的时候承担检查成本。后台清理线程会按设定的间隔扫描,把到期条目清除掉并触发监听器回调。异步模式把"清理"这个动作从主线程中摘出去了,读操作变得更加轻盈,代价是 Map 内部多了一条工作线程,所以你需要考虑这条线程的生命周期管理、以及存活数据短暂超出过期时间的情况(后台线程调度间隔内的误差)。
实际项目中的选择逻辑,我自己总结得很朴素:如果是小型服务或者原型验证阶段,我倾向用默认的同步策略,省事、无侵入;如果 Map 中的数据规模会上万甚至更高,而且读请求非常集中,我会毫不犹豫切到异步过期,让清理动作别挡在业务线程前面。
2.2 可变过期时间:全局默认值不是唯一的选择
大多数缓存工具在使用时的逻辑是"一视同仁":统一设置一个过期时间,所有条目都按这个时间走。但 ExpiringMap 给了我一种非常灵活的处理方式——可变过期时间(variable expiration)。
什么意思呢?就是你可以为某个 key 单独设置它的过期时长,覆盖全局默认值。举个例子,在一个接口鉴权场景中,大多数 token 的缓存时间设置 5 分钟就足够了,但某类特权用户希望 token 的缓存时间能拉长到半小时。用 ExpiringMap 的话,你可以在写入时直接给这条 user token 指定 30 分钟的过期时间,其他条目仍然跟随全局的 5 分钟策略。
这种设计思路对于真实业务是很有价值的,因为在实际系统里,不同类型的数据对新鲜度的要求千差万别,如果只能用一套全局过期时间,要么选择更新频率高的值,让所有数据都频繁失效回源,白白增加存储压力;要么为了少部分低频数据拉长全局过期时间,导致真正需要新鲜数据的部分拿到了陈旧结果。有了可变过期,我就可以按业务维度拆分策略,合理地平衡新鲜度和成本。
2.3 过期监听器是怎么工作的
ExpiringMap 提供了ExpirationListener接口,当条目因为过期被移除时,你可以收到一个回调通知。这个机制用好了能做出很多"额外动作"。
比如说我之前做过一个模拟的配置中心客户端,配置数据缓存在本地 Map 里,过期后监听器负责去远端拉取最新配置并重新放入缓存。整个逻辑从"业务代码里判断-重建缓存"变成了"监听器里专注做一件事",职责边界变得特别干净。再比如说某些统计数据需要周期性落盘,也可以在过期监听器里触发打包写入。
但这里有一个必须提醒的坑:监听器回调在同步过期模式下会执行在调用get()的线程上。也就是说,如果监听器里的逻辑很慢(比如要访问网络、操作数据库),它会把调用方的线程一起拖慢。我在初期实践时就在监听器里直接做了远程 HTTP 调用,结果高并发下打出了毛刺,后来老老实实把回调里的重逻辑丢进了另一个线程池去执行,才消停。
3. 实测:从 builder 到生产配置的关键 API
3.1 第一步:引入依赖和基本读写
ExpiringMap 的使用方式非常接近 Guava 的风格,一切从builder()开始。以 Maven 项目为例,引入依赖后,写一个最基础的带过期时间的 Map 只需要这么几行代码:
ExpiringMap<String, UserProfile> cache = ExpiringMap.builder() .expiration(10, TimeUnit.SECONDS) .build(); cache.put("user-1001", userProfile); // 10 秒后这条数据将被视为过期 UserProfile profile = cache.get("user-1001");expiration方法设的是全局默认过期时间,单位为毫秒或通过TimeUnit指定。如果你不设置,ExpiringMap内的数据默认永不过期,这时候它基本上就是一个带线程安全的 HashMap。所以如果你忘记了调expiration(),别指望它能帮你清理任何数据。
构建完 Map 后,put(key, value)、get(key)、remove(key)、containsKey(key)这些接口和HashMap保持一致,老 Java 程序员可以零成本迁移。如果你需要同时放入多条数据,还可以使用putAll()方法。ExpiringMap 自身是线程安全的,在get/put这类常规操作上不需要额外做同步控制。
3.2 给单个条目设置独立的过期时间
可变过期时间是通过put(key, value, ExpirationPolicy, long duration, TimeUnit timeUnit)这类重载方法来实现的。比如我想把某条数据设定为访问后 5 分钟过期,而不是从写入开始算的 10 秒,可以这么写:
cache.put("special-key", data, ExpirationPolicy.ACCESSED, 5, TimeUnit.MINUTES);ExpirationPolicy有两种取值:CREATED表示从写入时间开始算过期,ACCESSED表示只要条目被访问,过期时间就顺延。这种"每次访问都续期"的能力在某些频繁访问的数据上特别有用,相当于给缓存里的数据做了一个基于访问热度的自动续期逻辑。
需要留意的是,基于访问的过期策略意味着"过期时间是一个动态值",每次get()命中时都会刷新内部时间戳。这个刷新操作本身就是有开销的,数据量极大并且读频率极高的时候,访问刷新会引起较多的可见性竞争。我自己的实测场景下,单个 JVM 内几万条数据、读 QPS 达到几千时问题不大,但如果 QPS 上到十万量级,就要重新评估这个策略是否扛得住。
3.3 最大容量和驱逐行为
缓存最怕的一件事情是"写多读少但条目不删除",最后堆内存被打满。ExpiringMap 提供了maxSize配置,可以在达到容量上限时触发条目驱逐。用法如下:
ExpiringMap<String, byte[]> cache = ExpiringMap.builder() .expiration(1, TimeUnit.MINUTES) .maxSize(10000) .build();当 Map 大小达到 10000 时,再有新数据写入,会触发驱逐逻辑,全量或分批次地清掉一批条目,腾出空间。具体驱逐哪些条目,不同版本的行为不完全一样,有些会基于过期时间优先移除最早过期的条目,有些则会作为内部策略的简单换出,所以千万不要依赖驱逐顺序来做业务判断。
我记得有个朋友在项目里直接把maxSize当成"满了之后最多再多放一个"来理解,结果测试发现写入第 10001 条数据时,Map 内部做了一次批量删除,一下少了上百条,"刚写入的数据也被清掉了",当场就懵了。大家一定要明白,maxSize是内存保护机制,不是精确的容量上限,它的目标是"尽量别让你的堆内存炸掉"。
3.4 快速遍历与读取某些辅助信息
ExpiringMap 在keySet()、values()、entrySet()这些方法上尽量保持了 Java 集合生态的兼容性,但直接遍历过期条目这件事,偶然会让人困惑。比如我用map.entrySet().size()计算条数,统计结果可能会包含那些"已经过期但还没被后台线程清理掉"的条目,因为不同策略下清理动作并不同步。如果你需要精确的活跃条数,最好的方式是先手动触发一次过期清理(部分版本支持expireEntries()方法,或者在 get 之前做一次访问),然后再统计。
另外,ExpiringMap 还提供了getExpectedExpiration()之类的辅助方法,可以拿到某个 key 预期剩余存活时间,这在做监控指标、预测缓存命中率这类场景里非常实用。你可以在定时上报任务里把这些指标捞出来,画成曲线,直观看出缓存健康度。
4. 和 Guava Cache 等方案对比:什么时候用 ExpiringMap 更合适
4.1 一张表说清楚主流方案的区别
我在实际技术选型时,经常被人问到:ExpiringMap 和 Guava Cache 有什么关系?跟 Caffeine 比又差在哪儿?为了把这个事情说透,我做了一张简洁的对比表,下面放出来供大家参考。
| 对比维度 | ExpiringMap | Guava Cache | Caffeine |
|---|---|---|---|
| 核心模型 | 带过期能力的 Map | 带加载能力的缓存框架 | 高并发优化后的缓存框架 |
| 自动加载 | 需要自己判断后回源 | 内置CacheLoader自动加载 | 内置CacheLoader/AsyncCacheLoader |
| 过期策略 | 创建后过期、访问后过期 | 写后过期、访问后过期 | 写后过期、访问后过期 |
| 监听器 | 有过期监听器 | 有移除监听器 | 有移除/过期监听器 |
| 最大容量 | 有maxSize | 有maximumSize | 有maximumSize |
| 并发性能 | 线程安全,但标准实现定位偏轻量 | 优秀 | 极高 |
| 学习成本 | 非常低 | 中等 | 中等偏高 |
| 适合场景 | 轻量缓存、临时缓存、本地小型存储 | 需要自动 load 的缓存 | 高并发、大缓存量的场景 |
光看表象,三者都能做"带过期的 Map",但定位差异很明显。Guava Cache 的强项是"自动加载":只要在构建时塞进一个CacheLoader,get(key)在缓存不存在时就会自动调用加载逻辑,业务方完全不用写"判断为空再回源再 put"那套样板代码。ExpiringMap 没有这种机制,它更老实——自己 put 进去的东西自己管理过期,取不到就是取不到,回源逻辑请你自觉写好。
我个人的选型经验是这样的:如果你的使用场景本来就简单,比如某个临时缓存只在一次请求生命周期里存在,或者你的 Map 数据来源是外部消息队列,根本不存在"回源"一说,那 Guava Cache 的那套加载能力反而像个累赘,要少学不少 API;但如果你的核心诉求是"缓存为空时自动从数据库加载并缓存结果",那 Guava Cache 或 Caffeine 是更顺手的工具,ExpiringMap 需要你自己实现那段load-if-absent逻辑,代码写多了容易分心。
4.2 什么时候我会专门切到 ExpiringMap
这几年我或我身边团队遇到过的适合用 ExpiringMap 的典型场景,大致归成三类:
一种是非常场景化的小型缓存。比如网关层需要做短暂的时间窗口限流,每个用户 IP 对应一个计数器,窗口过了就丢掉。这种数据需要"短、平、快"的过期语义,直接用一个 ExpiringMap 既能支撑过期又能同时维护计数,比引入 Caffeine 后再配置各种淘汰策略要直观得多。
另一种是 Unit Test / 集成测试阶段给测试代码提供"自动过期"的临时存储。测试中经常要模拟"缓存 2 秒后失效"的行为,ExpiringMap 这种轻依赖的特性能让测试代码非常干净,不把一堆无关的缓存框架概念卷进来。
还有一种更典型的场景就是"热点凭证/一次性 token"之类不需要自动加载、但绝不能活太久的数据。我做过一个模拟项目,里面存了一批一次性下载链接,链接生成后 5 分钟内有效,用掉或过期就删。这种场景业务逻辑里本来就有"生成新链接"的步骤,不存在"回源加载"一说,ExpiringMap 的简洁直接就成了最匹配的选项。
反过来说,如果业务代码里已经大面积使用 Guava Cache 了,我通常不会为了"减少一行代码"而混入两套缓存体系。选型最重要的原则之一,是保持技术栈的一致性。ExpiringMap 更合适的地方在于它足够轻、足够小、足够"你说了算",而不是因为它比 Guava 更强大。
5. 踩坑排查:过期不生效、内存增长与线程安全的实际问题
5.1 过期时间为什么迟迟不生效
有一个用户曾经提过一个问题:设置的过期时间是 10 秒,可数据过了 1 分钟还在 Map 里。是不是 ExpiringMap 有 bug?其实大概率不是,而是他用了同步过期策略后,拿不到数据就顺手调用了get,但get只检查当前访问的 key 是否过期,其他 key 的过期清理要等到下次内部调度。
这个现象背后的逻辑是:ExpiringMap 并不会"定时扫描所有条目"——至少在同步策略下,它倾向于惰性检查。数据虽然逻辑上过期了,但实际从 Map 中移除可能要等到一次触发检查或后台线程轮询到来。如果你在测试中发现"Map 里还残留着过期数据",再叠加一个map.size()或keySet()遍历,就会看到完全不确定的残留数量,这是正常现象,不代表容器坏了。
要加速过期清理,可以在构建时配置异步策略并调小调度间隔。部分版本还支持手动触发清理入口,在测试时可以主动调用,让过期条目立刻被扫掉。检查自己的版本支持哪些方法,最直接的方式是看构建器的哪些公开方法返回了调度相关的配置项。总之一句话:理解"逻辑过期"和"物理移除"之间的时间差,是玩转 ExpiringMap 的第一个必修课。
5.2 内存不降反升?监听器里别做长任务
我之前遇到过一个内存持续增长的 Case,排查到最后发现处理逻辑里有个隐蔽操作:过期监听器收到回调后,又把过一个期的 key 原样放回了 Map。这等于某条数据永远"死而复生",Map 里堆了一堆本应消失的条目,内存自然只升不降。
还有一种容易犯的错误是在监听器回调里执行了大对象的创建或者引用了外部类的实例,导致本应被 GC 回收的对象一直被监听器对象间接引用。特别是在频繁过期的场景下,每秒钟都会产生大量回调对象,这些对象如果被集合类保存太久,GC 压力会急剧上升。我的建议是:监听器只做轻量级动作(比如计数、记录日志、发个信号),真正耗资源的数据重建工作丢到独立线程池去做。这一点在异步过期策略下尤其重要,因为后台清理线程的执行上下文是整个进程共享的,一个慢监听器会拖累所有条目的清理进度。
5.3 线程安全:不要自己再加锁
ExpiringMap 内部对并发读写做了处理,可以安全地被多个线程共享使用。我曾经见过有开发者不放心,在使用时额外加了一层synchronized或者ReentrantLock,结果反而造成了严重的竞争热点。尤其在高并发读场景下,外面加的一把全局锁,把 ExpiringMap 内部本来可以并发执行的读操作全部串行化了,性能直接掉了一个量级。
正确的做法是先写一个压测程序,确认单靠 ExpiringMap 自身能否达到你的 QPS 要求,再决定要不要额外保护。绝大多数场景下它是够的。如果你硬是要加保护,也应该用读写锁或者分段锁的思路,而不是一股脑全局锁。这里还要提醒一个细节:put操作在触发容量驱逐、或者触发过期清理时,消耗会明显变高,如果你在峰值流量下大量写入,最好提前压测,确认put的 P99 延迟是否在你的接受范围内。
5.4 和 Spring 容器等生命周期共处的问题
ExpiringMap 的异步过期模式内部会维护工作线程,是一个明显的"有资源"对象。在 Spring Boot 这类容器环境下,我建议把它直接注册成一个单例 Bean 交给容器管理,并且要特别注意应用关闭时清理线程的终止。在我的实践里,团队直接用最简单的@Configuration构建一个ExpiringMap实例,通过@Bean暴露,最后在@PreDestroy方法里触发对应的清理逻辑,避免出现线程还挂着、JVM 却已经开始卸应用的情况。
如果你的应用是多实例部署,还要考虑 ExpiringMap 是本地缓存,天然不带分布式一致性。多实例各自维护各自的缓存,同一份数据在不同实例上的过期时间点飘忽不定,这种差异在一些严格场景(比如"必须保证每次生成的 token 只在一个实例上有效")会被放大。一般的做法是给业务数据加上一定的"允许不一致窗口",或者接受短时间的差异,只要不产生数据正确性问题就行。
6. 我的落地建议与扩展玩法
6.1 几个容易忽略却提升体验的小技巧
先分享一个我在实践中非常常用的组合拳:ExpiringMap 结合函数式接口,做一个小工具类把"读缓存-过期回源-写缓存"封装起来。思路是先定义一个Supplier<T>作为加载函数,内部逻辑先查 Map,命中就直接返回;没命中就调用Supplier.get(),拿到结果后再放回 Map。虽然 ExpiringMap 自身没有CacheLoader,但通过这一层封装,我照样可以得到"自动加载"的体感,而且代码控制在 20 行以内。
操作细节上要注意 key 的生成规则。用 ExpiringMap 做缓存时,key 如果设计成乱七八糟的长字符串,内存消耗会比想象中高很多,还容易造成大量无意义的长期引用。我一般会定义一个短小的 Key 类,重写equals和hashCode,或者直接用紧凑的字符串拼接规则。这一点在maxSize很大的场景里尤其重要,因为每个条目的 key 和 value 都强引用在 Map 内部,key 的设计直接影响 GC 扫描成本。
另外,给缓存的过期时间设置留一个"外部可配置"的口子,能避免频繁改代码上线。最简单的方式是读取环境变量或者配置文件来设置默认过期时长,而不是在代码里写死10这样的数字。我在多个项目里都是这么干的:默认 5 分钟,测试环境嫌弃太慢就调成 10 秒,线上需要"热数据"时把整体过期时间调小。这个成本极低,却能在关键时刻救命。
6.2 与本地缓存框架之外的组合玩法
ExpiringMap 虽然定位是轻量 Map,但它可以和其他组件组合出非常实用的方案。比如说搭配一个阻塞队列,实现带"宽限期"的异步批量处理:数据先放进 ExpiringMap,等一段时间没被消费就自动触发一个回调,把数据汇总进队列。这本质上就是拿"过期监听器"当"延时触发信号"用,在很多业务里比引入重量级的消息中间件更轻快。
再比如说拿它做二级缓存的前置层。后端原本直接打数据库或某个远程服务,在中间放一层 ExpiringMap 作为热点缓存,命中率上来之后再把长期缓存落到 Caffeine 或者 Redis。通过两级缓存,既能享受 ExpiringMap 的简单性和快速失效,又能借助更强的缓存框架做大规模存储。配合前面说的外部可配置过期时间,你可以随时在"短 TTL 保证新鲜"和"长 TTL 提高命中率"之间动态调整。
6.3 性能调优与监控指标经验
ExpiringMap 不像 Caffeine 那样自带丰富的统计接口,所以如果你想观测它的命中率、活跃条目数,通常需要自己埋点。我常用的做法是写一个定时任务,每隔 30 秒调用一次size()、getExpectedExpiration(),配合自定义的命中计数器,把指标暴露给 Prometheus 或打进日志。数据量上来之后,你会明显观察到"过期调度间隔"和"内存占用波形"之间的关系,这能帮你反过来校准过期策略。
在做性能调优时,有一个关键参数值得反复测试:异步过期的调度间隔。间隔太短,后台线程频繁扫描,CPU 耗在空转检查上;间隔太长,缓存里的过期数据残留时间变长,内存占用增加,命中旧数据的风险变大。我在一个模拟项目中把调度间隔从 1 秒调到了 30 秒,CPU 占用降了接近一半,但内存占用峰值上升了大约 15%,最终根据业务容忍度锁定了 5 秒这个中间值。这种调优没有银弹,最好的办法就是埋点压测,让数据说话。
6.4 我的最终建议
如果你需要在 JVM 项目里找一个"带过期时间的 Map"作为轻量方案,ExpiringMap 确实是一个非常简单、没有心智负担的选择。它不会让你的代码结构变得复杂,也不会把 Spring、Redis 这些外部依赖强加给你,很多小型场景直接拿来用就行。但如果你面对的是高并发、超大容量、需要复杂淘汰策略的生产级缓存需求,我建议认真考虑 Guava Cache 或 Caffeine,毕竟它们承载的缓存理念更系统、更完整。
最后一个经验分享:任何一个缓存工具,我们真正调的都不是 API,而是对"数据生命周期"的理解。多问一句"这条数据该活多久"、"过期后谁来负责消失"、"操作失败时要不要兜底",远比你选哪个库重要得多。我自己这几年从中收获最大的事情,就是养成了在任何缓存方案落地之前先把这些边界问题想清楚的思维习惯。希望这篇文章能让你在评估 ExpiringMap 时少走一些弯路,直接把精力集中在真正有挑战的业务代码上。