☰
ExpiringMap:Java进程内过期缓存Map的优雅实现
2026/10/15 7:17:34 网站建设 项目流程

做Java后端时间长了,你会发现有一类需求几乎每个项目里都会出现:某份数据只该活着那么一小会儿。比如短信验证码,五分钟之后谁再拿它登录都应该直接拒绝;比如接口的防重提交,同一个请求一秒内只能进来一次;再比如短时限流,窗口期一到计数就清零。这类数据本质上是同一个问题——需要一个带过期时间、到期自动失效的存储容器。我以前要么在业务代码里塞一个“创建时间”字段,写判断逻辑时手动比对;要么维护一个ConcurrentHashMap,再配一个定时线程,隔几分钟扫一遍。两种办法都能跑,但都别扭。后来在一个模拟项目X里第一次接触ExpiringMap,从那时起这类问题就基本没再困扰过我了。ExpiringMap是一个面向Java进程内缓存场景的Map实现,你可以像用HashMap一样去put和get,但它额外支持给每个key设置独立过期时间,到期后自动失效,还能在key失效时触发回调。如果你也正在为临时数据的过期管理写重复代码,这篇东西值得读完再动手。

1. 为什么需要ExpiringMap,而不是直接用一个普通Map

很多人在刚开始写Java时,遇到“临时数据”第一反应就是塞进HashMap里。数据放进去之后,确实能读出来,但问题在于普通Map没有“时间”的概念。它只知道key-value的对应关系,不知道这个key是什么时候放进去的,更不知道它该什么时候消失。要让它自动过期,所有逻辑都得你自己补。

1.1 那些“看着像Map,但比Map多一个时钟”的场景

先说几个最常见、几乎每个业务系统都会碰到的场景。

第一个是验证码。登录、注册、找回密码,服务端生成一串数字,存起来,用户在一定时间内提交上来校验。如果验证码永远不过期,那它就是一张永久通行证,安全隐患非常大。常规做法是设置5分钟有效期,过期作废。这个“5分钟”,本质上就是数据自带的生命周期。

第二个是登录会话。用户登录成功之后,服务端生成一个token,标记该用户已认证。这个token不能永久有效,否则哪怕账号密码泄露,攻击者也能无限期使用。常见做法是30分钟或24小时过期。用户每次操作时如果会话快到期了,还可以“续期”,这又涉及滑动过期的问题。

第三个是接口防重。用户提交表单时,前端可能因为网络波动连点两次,后端的写操作就会被执行两次。为了防重,很多人会在内存里记录“这个用户在这1秒内已经提交过了”,这个标记必须在极短时间内自动消失,否则用户连正常提交都会被拦截。

第四个是短时限流。比如某个接口要求单用户每秒最多只能访问5次,最简单的实现就是“这个key在1秒窗口内计数,窗口结束自动清零”。如果没有过期机制,计数就永远累加下去,接口最后谁都访问不了。

这些场景看起来各不相同,但落到技术上其实是一模一样的:一个Map,里面的每一个条目都要有独立的存活时间,到时间后自动消失。这就是ExpiringMap最擅长的事。

1.2 自己造轮子的三个坑

没有ExpiringMap的时候,我也试过自己写。比较初级的方案是“Map + 时间戳”:value不再直接存业务数据,而是包一层对象,里面塞一个expireAt字段。每次get的时候读出来,比一下当前时间和expireAt,判断是否过期。这套方案勉强能用,但坑特别多。

第一个坑是时间戳和业务逻辑纠缠在一起。你本来只想存一个用户对象,结果为了判断过期,不得不再包一层,或者给对象加一个本来不属于它的字段。缓存逻辑混进了业务类里,代码读起来很别扭,后面还要专门做兼容处理。模拟项目X里我曾见过一个订单对象,为了支持“订单超时未支付自动关闭”,硬生生在订单实体里加了一个createTime,然后每处读订单的地方都自己去判断“现在减去createTime是不是超过半小时”。散落一地的过期判断,后来重构时全部推翻。

第二个坑是全局扫描线程有延迟。有人会单独起一个定时任务,每30秒遍历一次Map,把过期的key删掉。这个方案看起来解决了内存占用问题,但清理不够及时。如果过期时间是5秒,而扫描周期是30秒,那这个key实际存活了35秒。延迟误差在某些高时效场景里是致命的,比如验证码,攻击者可能利用这个窗口多试几次。

第三个坑是代码无法复用。今天在登录模块写了一份清理逻辑,明天在支付模块又写一份,后天在限流组件里再写一份。每份逻辑都略有不同,有的用Timer,有的用ScheduledExecutorService,有的干脆在调用方判断。出了问题,排查起来要多处翻代码。这本质上是重复造轮子,而且每个轮子都造得不够圆。

1.3 ExpiringMap的正确“身位”

ExpiringMap既不是一个通用的Map替代品,也不是一门心思要干掉集中式缓存。它正好填补了“小规模、进程内、强时效”这个空档。单机应用里,临时数据的量级通常很小,几千个key顶天了,用不着上外部存储;但这些数据又确实需要一个“到点自动消失”的机制,这正是ExpiringMap的定位。

我把不同方案放在一起做过对比,大致是这样:

方案过期支持清理机制实现成本网络依赖
普通Map无完全人工低无
手写Map+定时清理人工实现自己写线程中高无
ExpiringMap内置框架自动低无
集中式缓存内置框架自动高必须连接服务端

从这个表能看出来,手写方案的成本反而比直接用ExpiringMap更高,而且效果还不一定比它好。集中式缓存要引入额外的服务端、客户端、网络开销,对一个小功能来说多少有点杀鸡用牛刀。

2. 快速上手:把ExpiringMap当普通Map用

知道了它解决什么问题,接下来最关键的事情是先把它跑起来。ExpiringMap的使用成本和HashMap几乎没有差别,核心就是记住一个builder对象,大部分配置都可以在构建时一次性指定。

2.1 引入依赖只需要一步

第一次使用,先确认项目里已经引入了正确的坐标。以Maven项目为例,pom.xml里加上一段依赖:

<dependency> <groupId>net.jodah</groupId> <artifactId>expiringmap</artifactId> <version>按当前release版本号填写</version> </dependency>

版本号我不写死,因为库会持续迭代,放弃旧版本或修复新问题是常态。引入之后,编译一下,能识别到ExpiringMap类就算成功了。这里要注意一下,代码里的import路径要以你实际拉下来的jar包为准,不同版本的包结构可能会有调整,别盲目从网上抄路径。

2.2 最基本的三行代码

ExpiringMap的使用姿势非常简单。先创建一个带默认过期时间的Map,然后像普通Map一样往里面放数据。

ExpiringMap<String, String> cache = ExpiringMap.builder() .expiration(5, TimeUnit.SECONDS) .build(); cache.put("verifyCode", "123456"); Thread.sleep(6000); System.out.println(cache.get("verifyCode")); // null

这段代码里,builder().expiration(5, TimeUnit.SECONDS)的意思是:这个Map里所有没单独指定过期时间的key,统一按5秒过期。put进去的verifyCode,5秒内能取到值,5秒后再get,返回的就是null。

很多人第一次看到这个写法会有一个疑问:get返回null,怎么区分“key不存在”和“key已经过期”?答案是:在缓存场景里,这两者往往不需要严格区分。调用方关心的是“我能不能拿到有效值”,只要拿不到,就是无效的,逻辑上都按“不存在”处理。

2.3 单个key覆盖全局规则

全局默认过期时间只适合“这批数据生命周期一致”的情况。实际情况往往是同一个Map里,A数据要5秒过期,B数据要30秒过期,C数据可能希望手动控制。ExpiringMap允许在put时单独指定过期时间,这个优先级高于全局默认值。

ExpiringMap<String, String> tokens = ExpiringMap.builder() .expiration(30, TimeUnit.SECONDS) .build(); tokens.put("admin-token", "token-A", 10, TimeUnit.SECONDS); tokens.put("report-token", "token-B", 5, TimeUnit.MINUTES);

admin-token虽然放在默认30秒过期的Map里,但它自己指定了10秒,所以它实际存活时间就是10秒。report-token指定了5分钟,同理也会覆盖默认值。如果需要中途修改某个已经存在的key的剩余时间,可以用expire方法:

tokens.expire("admin-token", 60, TimeUnit.MINUTES);

这行代码的意思是:不管admin-token之前还剩下多少时间,从现在这一刻开始重新计算60分钟。这个能力很重要,后面讲滑动过期时会用上。

2.4 判断key是否有效,别过分依赖containsKey

使用过程中的第一个小坑,是判断key是否存活。有的同学习惯写containsKey,发现key明明是5秒过期的,为什么6秒后containsKey还是返回true,于是怀疑功能有bug。

这其实和清理机制有关。ExpiringMap通常采用“懒过期 + 定时清扫”结合的方式来移除条目:get的时候会先判断时间戳,发现过期就返回null;但如果没有get动作,后台清理也不可能每一毫秒都扫一遍全表,所以存在一个很短的窗口期,Map内部仍然保留着那个过期的key。

所以,判断业务数据是否有效,最稳妥的方式是直接get,用返回值是否为null来判断。不要为了“避免取出大对象”而使用containsKey,这是典型的本末倒置。

提示:containsKey这类“只看有没有”的方法,在某些版本里可能返回尚未被清扫到的过期key。业务判断请以get的返回值为准。

3. 深入一下:过期监听和后台清理

会用基础API之后,等于拿到了80%的能力。剩下20%的价值,藏在过期监听器、清理机制和过期策略里。搞懂这三块,很多看似奇怪的行为就都能解释通了。

3.1 expirationListener可以让“过期”从存储问题变成业务事件

ExpiringMap有一个很贴心的能力:当某个key真正过期并准备从Map里移除时,可以触发一个回调。这意味着“key过期”不再只是存储层面的事,而变成业务可以感知、可以响应的一个事件。

举个例子,模拟项目X里有一个分布式锁模块,锁的持有人信息保存在本地Map中,要求10秒后自动释放。如果不使用监听器,我们还得额外开一个线程去轮询Map。用了监听器之后,代码可以这样写:

ExpiringMap<String, String> locks = ExpiringMap.builder() .expiration(10, TimeUnit.SECONDS) .expirationListener((key, value) -> { // 模拟释放锁 releaseLocalLock(key.toString()); }) .build(); locks.put("order:1001", "node-A");

当order:1001在Map里存活超过10秒并触发清理时,releaseLocalLock就会自动被调用。这个模式非常适合“临时资源超时回收”类场景,比如未支付订单的占位释放、限流窗口结束后的计数重置、本地缓存的异步回写。

不过要注意,监听器默认是在清理线程里同步执行的。如果监听器逻辑太慢,会拖慢整个Map的清理进度。所以对于耗时操作,要么把逻辑丢到自己的线程池,要么使用异步监听器,让回调在独立的线程中执行,避免阻塞关键路径。

3.2 清理机制:懒过期 + 定时清扫

从外部看,ExpiringMap就像一个会自动丢垃圾的储物柜。但从内部看,它靠的是两个机制配合。

第一个机制是懒过期。每个key放进去时,内部会记录一个过期时间点。当调用方执行get时,它会先比较当前时间和过期时间点;如果发现已经过了,就直接返回null,同时触发清理。这种判断方式特别快,不需要扫全表,对get操作的性能影响微乎其微。

第二个机制是定时清扫。如果某个key过期后一直没人get,那懒过期就永远不会触发它,它会一直留在内存里。所以ExpiringMap内部还有一个后台任务,每隔一定间隔扫描一下,把那些“已经到期但还没被读走”的key主动清掉。这个扫描周期很短,正常使用中几乎感知不到。

我用一个生活类比来理解这套设计:懒过期满打满算是一个“查账式清理”,只有在有人来取东西时才发现这个柜子过期了;定时清扫则是“保洁阿姨定期巡逻”,哪怕没人来取,到点后也会把过期柜子收走。两者配合,既保证了读取速度,又避免了内存长期占用。

3.3 两种过期策略:固定过期和滑动过期

ExpiringMap支持两种很实用的过期策略。第一种是固定过期,也就是某个key从放进去那一刻开始算,存活N秒后失效。第二种是滑动过期,意思是“只要这个key一直有人在读,就不断往后顺延过期时间”。

滑动过期在很多场景下特别有用。比如登录会话,希望用户每操作一次,会话有效期就重新计算30分钟;如果用户一直活跃,会话永远不失效;一旦用户超过30分钟没操作,会话才自动断掉。用人话来说,就是“用着就不算超时”。

实现的思路并不复杂,利用前面提到的expire方法即可:

if (sessionCache.get(token) != null) { // 用户正在活跃,续期30分钟 sessionCache.expire(token, 30, TimeUnit.MINUTES); }

判断到有效会话后,顺手把它剩余的生命周期重置为30分钟。这样代码逻辑非常直白,后续维护的人一眼就能看懂“每次操作续一个完整的半小时”。如果一开始就设计成“固定30分钟过期”,后续要改成滑动过期,改动面会大很多,这也是我建议你在建Map前就先想清楚业务语义的原因。

4. 实战落地:我是怎么把它放进项目里的

用ExpiringMap写demo很容易,但想在生产环境里用得稳,有几个经验是必须提前知道的。我踩过不少坑,这里挑最典型的四个讲。

4.1 包一层工具类,隔离具体实现

不要把ExpiringMap直接散落在各个业务代码里。最推荐的做法是包一层非常薄的本地缓存工具类,对外只暴露put和get方法。这么做有两个好处:第一,业务侧不需要关心过期时间、线程安全这些细节;第二,将来就算想换一种缓存方案,业务代码也不用动。

我写过一个简化版的结构,大致是:

public class LocalCache { private final ExpiringMap<String, Object> store = ExpiringMap.builder() .expiration(5, TimeUnit.MINUTES) .build(); public <T> void put(String key, T value, long ttl, TimeUnit unit) { store.put(key, value, ttl, unit); } @SuppressWarnings("unchecked") public <T> T get(String key) { return (T) store.get(key); } }

这样封装之后,业务代码里只依赖LocalCache,完全不依赖ExpiringMap的具体类型。后来模拟项目X要把验证码这一类高频临时数据迁到集中式缓存时,我只需要改LocalCache内部实现,业务侧一个字符都不动。这个收益在项目初期看不出,等到系统变大后非常值。

4.2 千万别缓存“会变”的对象

这是所有缓存方案里最容易踩的坑,不只是ExpiringMap特有的。

如果你把一个对象直接作为value放进去,然后在缓存外部修改了这个对象的内部状态,Map是感知不到的。因为过期时间只跟key绑定,跟value里放了什么没有任何关系。下次从缓存里get出来,拿到的可能是被改动过的、已经不符合预期状态的脏数据。

模拟项目X里就出过类似事故:我们把一个“用户等级信息”对象放进了缓存,后续支付模块为了计算折扣,直接把这个对象的字段改了。结果另一个模块再从缓存里读同一个对象,读到的等级已经错了,订单金额全乱。后来彻底改成“缓存里只放不可变对象,或者只放对象ID,需要完整数据时再去数据库查”。

注意:Map的过期时间只和key相关,和value的内部状态无关。value一旦被外部改写,Map完全不知道,下次get就是脏数据。

4.3 控制容量,别把大对象直接丢进去

ExpiringMap毕竟是进程内Map,它比集中式缓存更轻,但内存上限就是你JVM堆的上限。如果往里面塞大对象,或者塞了太多key,GC压力会明显上升。别指望“反正会自动过期”就能无限往里放,清理需要时间,堆积速度超过清理速度,内存就会失控。

我的习惯是在项目里加一个简单的容量监控。用ScheduledExecutorService定时打印当前缓存size:

ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(); monitor.scheduleAtFixedRate(() -> { if (cache.size() > 5000) { log.warn("local cache size too large, current={}", cache.size()); } }, 1, 1, TimeUnit.MINUTES);

这不是为了追求精确监控,而是为了尽早发现异常增长。如果缓存量长期不动、只增不减,多半不是ExpiringMap的问题,而是过期策略或监听器那里出了问题,让key没有得到及时清理。

4.4 给每个缓存区域规划合理的默认过期时间

创建Map时,先把业务场景的过期时间想清楚。验证码5分钟、防重1秒、限流窗口按接口定义、登录会话按活跃策略定义。不要所有Map都用同一个默认值,更不要随手写一个大数字。

之前见过一个案例,某团队为了省事,把限流Map的默认过期时间从1秒改成了1分钟,理由是“不想频繁创建Map”。结果限流效果完全失效,用户在一分钟内连续提交了十几次都没被拦住。过期时间不是配置调优可有可无的参数,它本身就是业务规则的一部分。

5. 高频问题排查实录

再好的工具,使用过程中总会遇到一些看起来不对劲的情况。我把这几年碰到的典型问题整理成一个速查表,每个都附带排查思路和解决方案。

5.1 为什么key过期了,内存却在涨

这是最常被问到的问题,也是新手最容易被吓到的一个。答案往往不是ExpiringMap坏了,而是你没真正理解清理机制。

首先确认是不是时间窗口问题。某个key的过期时间是5秒,但后台清扫每100毫秒才跑一次,那么在4.95秒到5秒之间看到它还在Map里,是非常正常的。这种情况不需要处理,继续等下一次清理就好。

其次检查你的过期时间是不是真的设置正确。之前遇到过一个项目,把expiration(300, TimeUnit.SECONDS)写成了expiration(300, TimeUnit.MILLISECONDS),结果key存活时间从5分钟缩水到0.3秒,误以为缓存一直在丢数据,实际是时间单位错了。反过来,如果你把单位写成了HOURS,那内存当然会长时间不降,因为数据本来就没过期。

最后实在查不出来,就dump一下堆快照,看看ExpiringMap里的value是什么类型的对象。很多所谓的“内存泄漏”,其实是有人把大集合、大字符串塞进了缓存,又给了很长的过期时间。这不是泄漏,是使用方式不当。

5.2 监听器抛异常之后,清理好像被卡住了

监听器默认在清理线程里执行,一旦监听器内部抛出未捕获的异常,这个清理线程可能会被中断,后续的key就没人清扫了。表现就是Map里的过期key越攒越多,内存不断上涨。

解决办法有两个。一是监听器内部自己做防御,任何可能抛异常的操作都包上try-catch:

.expirationListener((key, value) -> { try { doSomething(); } catch (Exception e) { log.error("expired callback failed, key={}", key, e); } })

二是对于复杂逻辑,使用异步监听器,把耗时的、易出错的环节丢到专用线程池,避免和清理线程抢时间。两条建议最好同时上,防御要写在入口处,隔离要做到架构上,这样才不会因为一个回调业务拖垮整个缓存的清理节奏。

5.3 弱引用配置是个双刃剑

ExpiringMap为了应对内存敏感场景,通常支持把key或value设置为弱引用。开启之后,JVM的垃圾回收器可能在你完全不知情的情况下把对象回收掉,然后get返回null。这个行为和“过期”很像,但本质完全不同:这不是时间到了,而是GC干了活。

所以,如果你的value对象比较大,而且不要求长期驻留,可以考虑弱引用;但如果value是业务上必须持久到指定时间的,千万别开这个配置。我建议大部分业务场景保持默认的强引用,不要为了“省内存”去追求这些高级开关。内存不够,正确做法是先控容量,而不是让GC替你决定数据还该不该活着。

5.4 什么时候该放弃ExpiringMap

ExpiringMap再方便,也有它的边界。它只是单机进程内的一个Map,不是万能的缓存解决方案。

如果出现下面几种情况,我建议你趁早换方案:

情况为什么ExpiringMap不合适
多个服务实例需要共享同一份缓存每个实例各存各的,数据不一致
服务重启后缓存不能丢内存型结构,重启即清空
缓存量级很大,几十万甚至上百万key全部堆在JVM内存里,GC压力巨大
需要非常复杂的LRU、LFU淘汰策略不是它的设计重点
需要跨进程通知缓存失效完全做不到

换而言之,如果只是验证码、防重标记、单机限流、临时token这类小规模临时数据,ExpiringMap用得很舒服。一旦需求开始往“共享、持久化、大容量”方向发展,它就不再是合适选择,引进更专业的缓存中间件是必然之路。

做技术选型从来不是看哪个框架更高级,而是看哪个方案和当前业务体量匹配。我最后一次重构时,把所有临时数据的入口收敛到一个叫LocalCache的类里,后来想把验证码迁到集中式缓存,只改了这一个类。如果你现在正好要接入ExpiringMap,我的第一建议就是先做这层薄薄封装,多花半小时,省下后面无数次来回改代码的功夫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询