项目里随手写缓存逻辑,最烦的不是写作代码,而是“这个数据到底要放多久”。以前我处理临时数据,脑子里的第一反应就是ConcurrentHashMap加一个System.currentTimeMillis()判断,再配个定时任务去清理。用了几次之后发现,这套东西写着写着就变成重复代码了,而且每次清理时机都不准,过期数据总会在内存里多待一阵子。后来我换成了ExpiringMap,一个专门为缓存场景设计的带过期时间的 Map,才算把这个痛点彻底解决。
1. 重新认识 ExpiringMap:它到底替我们扛了什么
1.1 它解决的不是“存数据”,而是“数据什么时候失效”
普通 Map 管的是“怎么存、怎么取”,而 ExpiringMap 管的是“什么时候不能取”。这个区别看起来不大,但一落到真实场景里,完全是两码事。
举个最常见的例子:登录验证码。用户请求一个验证码,你把它塞进 Map,key 是手机号或者会话 ID,value 是六位数字。验证码这东西天然有生命周期,常规操作是 5 分钟有效,过期就作废。如果只用ConcurrentHashMap,你得在写入时额外记一个时间戳,取出来之后还得比较一下当前时间有没有超过有效期限。更麻烦的是,那些过期但没被访问的验证码永远躺在内存里,你得再开一个定时任务定期扫表。
这些问题 ExpiringMap 都提前给你安排好了。写入数据时可以指定过期时长,时间一到,这个 entry 自动变成不可见甚至被彻底移除。你不用关心它具体是什么时候被清掉的,也基本上不需要自己再写任何清理线程。它解决的,就是“缓存数据生命周期管理”这个横切需求。
1.2 缓冲区和缓存区的本质差异
我刚开始用 ExpiringMap 时有个误解,觉得它就是HashMap的增强版,无非多了个过期时间。后来踩了一次坑才明白,普通 Map 更像一个持久化的储物柜,你放进去的东西,只要不手动删,理论上永远在那儿。而 ExpiringMap 的核心假设是:我这个容器里的数据,默认就是会过期的,过期是一种常态,不是异常。
这个假设直接决定了它的内部设计和 API 风格。比如它可以设置全局默认过期时间,可以单独为某个 key 指定不同的时长,还可以在所有 entry 的过期时间统一变化时一键更新。你要是把这些功能用ConcurrentHashMap加时间戳来实现,代码量不难,难的是那些边边角角的并发问题、清理时机问题、监听回调问题,每个点都要自己抠。
所以我的判断是:ExpiringMap 适合所有“我知道这数据不需要永久存在”的小型缓存场景。它不追求像专业缓存中间件那样的大规模容量和极致性能,但在单机应用内、数据量几百到几千条这个量级,它确实很好用,代码写起来也干净。
2. 上手实践:核心 API 与三种常见玩法
2.1 引入依赖与基本构造方式
如果你是 Maven 项目,直接在pom.xml里加依赖就行:
<dependency> <groupId>net.jodah</groupId> <artifactId>expiringmap</artifactId> <version>0.5.10</version> </dependency>这个版本是目前用得最多的稳定版,API 没怎么变过。引入之后,最基础的创建方式是这样:
ExpiringMap<String, String> map = ExpiringMap.create();create()创建出来的实例,所有 key 默认不过期,行为和一个普通ConcurrentHashMap几乎一样。但真正要发挥它的能力,我建议直接用 Builder 方式初始化:
ExpiringMap<String, Object> map = ExpiringMap.builder() .expiration(30, TimeUnit.SECONDS) .maxSize(1000) .variableExpiration() .build();这里有几个关键配置,我先拆开解释一下:
expiration(30, TimeUnit.SECONDS):设置全局默认过期时间,所有没有单独指定过期时间的 key 都会在写入 30 秒后过期。maxSize(1000):设置最大容量。超过之后,会根据策略驱逐旧数据,默认是按照写入顺序,也就是先写入的先被淘汰。variableExpiration():允许每个 key 拥有独立的过期时间。如果不开启,后面调put(key, value, duration, unit)这种带过期时间的重载方法会报错。
这个配置组合是我最常用的。它对应了大部分临时缓存场景:数据不能无限堆积,单条数据也不能活太久。
2.2 灵活但容易被忽略的单条过期策略
全局过期时间适合绝大多数数据,但实际项目里总有几条特例。比如大部分验证码是 5 分钟有效,但有些高危操作的验证码可能只给 60 秒。用 ExpiringMap 处理这种差异很自然:
map.put("normal_code", "123456"); map.put("high_risk_code", "654321", 1, TimeUnit.MINUTES);第一行用的是默认过期时间,第二行单独指定了这条数据 1 分钟过期。这个能力就是variableExpiration()带来的。不开启的话,第二行代码直接抛IllegalStateException,我第一次用就踩过这个坑,还一脸懵地看了半天源码。
还有两个细节值得注意。第一个是put一个已经存在的 key,新值写入后过期时间会被重新计算,以新写入的时间点为起点。第二个是默认情况下,get操作也会延长过期时间,相当于每次读取都把这条数据的“寿命”重置一遍。
第二点其实是个大坑,我后文会详细说。这里先说结论:如果你需要的是“固定时间后一定过期”,不管是取多少次,都必须构建的时候加一行resettingDurationOnRead(false),否则只要这个 key 一直被访问,它就永远不会过期。
ExpiringMap<String, Object> map = ExpiringMap.builder() .expiration(30, TimeUnit.SECONDS) .resettingDurationOnRead(false) .build();2.3 过期监听器:清理与其靠轮询,不如靠通知
ExpiringMap 提供了一个我觉得非常实用的能力:过期监听器。你可以在某个 key 过期的时候做点收尾工作,比如记录日志、释放资源、做一些后续状态更新。
map.addExpirationListener((key, value) -> { System.out.println("key 过期了:" + key + ",value 是:" + value); // 这里可以做清理资源、异步通知等操作 });实现原理上,ExpiringEntry内部维护了一个监听器列表,当条目被判定过期并移除时,会逐个回调注册的 listener。需要注意,监听器是在清理线程中触发的,不是业务线程,所以回调里千万不要做耗时操作,否则会影响后续条目的清理效率。
结合监听器,我还经常这么用:把过期数据先记录下来,后面用来做统计。比如一个限流场景中,过期的计数应该从总量里减掉,这时候监听器就能帮上忙,比专门写一个扫描任务轻量得多。
3. 源码拆解:它凭什么能做到自动过期
3.1 底层结构:不算黑魔法,但设计很聪明
ExpiringMap 的底层存储本质还是一张ConcurrentHashMap,只不过 value 被包装成了一个内部对象ExpiringEntry。这个对象里保存了实际的数据、过期时间点、插入时间点、以及一些状态标记。
你可以把ExpiringEntry理解成一个“带闹钟的礼品盒”。礼品还是那个礼品,但盒子外面贴了一张纸条,写清楚了“几点几分必须打开检查一次”。这个设计的好处是,Map 本身不需要为了考虑过期逻辑去改变自己的数据结构,所有额外信息都集中在 value 这一层。
关键代码我简化一下,大致是这样:
ExpiringEntry<K, V> entry = new ExpiringEntry<>(key, value, duration); entries.put(key, entry);而ExpiringMap的写入逻辑里,最核心的一步就是调用setExpiration去设置这个 entry 的过期时间和相对时间。
3.2 过期检测与清理机制:一读一写,顺带清扫
真正让数据“自动过期”的,不是某个高深算法,而是一个简单的惰性检查加上后台线程扫描。
惰性检查主要体现在get操作上。每次调用get(key),它会先判断对应的 entry 是否已经过了过期时间点,如果过了,直接返回 null,并且顺手把这个 entry 从 map 里移除。这样,一个已经过期的 key,只要有人访问它,它就会立刻从容器里消失,你不用等任何后台任务。
但问题很明显:如果一些 key 过期之后再也没有人访问,它们就会一直残留在内存里。所以 ExpiringMap 内部还启动了一个守护线程,周期性扫描所有 entry,把已过期的数据批量清掉。这个扫描线程是后台运行的,不会阻止 JVM 退出,比较省心。
以前我很好奇这个清理周期到底是多少,后来翻了源码发现,它每次扫描完会根据最近的过期时间算下一次扫描时机,不是固定间隔傻扫。这样一来,空闲时线程基本处于等待状态,数据密集过期时才频繁唤醒,在性能和及时性之间做了个不错的平衡。
3.3 容量控制与驱逐策略
如果只设置maxSize而数据一直往里面写,超过容量后会发生什么?ExpiringMap 的处理方式是:默认按插入顺序,把最早写入的数据驱逐掉。这个策略类似于 FIFO(先进先出)。
不过它并不完全等同于常见的 LRU。因为默认情况下,get操作会重置过期时间,这在某种意义上改变了数据的“新鲜度”排序。如果你希望更精确地实现 LRU 效果,需要明确开启相关配置,具体有一点绕,后面我会在避坑部分展开。
这里可以先记一句话:ExpiringMap 的容量限制是“软性”的,它不保证瞬间严格卡在 maxSize 以内。批量写入或者某些极端情况下,实际容量可能在极短时间内超过阈值,然后后台线程再慢慢清理。这个特性在大流量场景下需要心里有数,但一般中小规模缓存完全不用慌。
4. 与同类方案的横向对比:什么时候选它,什么时候换别的
4.1 对比 Guava Cache、Caffeine 和 Redis
我需要先说清楚,ExpiringMap 在 Java 生态里并不孤单。很多人提到本地缓存,第一个想到的是Caffeine或者Guava Cache,再往大了说还有Redis。我在项目里都分别用过,它们在定位上有明显差异。
| 方案 | 过期策略 | 容量控制 | 持久化 | 外部依赖 | 适用规模 |
|---|---|---|---|---|---|
| ExpiringMap | 写入/读取后过期、可变量过期 | 有 maxSize,按 FIFO 驱逐 | 无 | 无 | 单机小规模,几百条到几千条 |
| Guava Cache | 写入/访问后过期,更精细 | 支持 LRU 等多种 | 无 | 无 | 单机中等规模 |
| Caffeine | 写入/访问过期,支持基于权重淘汰 | 高性能 LRU/LFU 近似实现 | 无 | 无 | 单机高并发、较大容量 |
| Redis | 主动过期 + 惰性过期,TTL 可控 | 多种淘汰策略 | 支持 | 外部服务 | 分布式、跨进程共享 |
从表格能看出来,ExpiringMap 最明显的短板是和 Caffeine 相比,在高并发、大数据量场景下性能不够突出。Caffeine 内部用了 W-TinyLFU 算法,能在极高的读写压下维持稳定的缓存命中率,这在 ExpiringMap 里是没有的。
但 ExpiringMap 也有自己的优势:API 足够简单,语义足够直接,不需要像 Caffeine 那样配置一堆东西。特别是在“缓存只是一个小辅助功能”的模块里,引入一个轻量库比引入一个庞大缓存框架更舒服。
4.2 一个来自实战的选型判断方法
我自己定了个很简单的选型标尺,分享给大家参考:
- 如果缓存的量级只有几十条到几百条,并发压力不大,但“过期时间”这个语义很重要,选 ExpiringMap,理由是容易理解、容易维护。
- 如果缓存量级到了几万条,并且读多写少,追求高命中率,选 Caffeine,基本不会错。
- 如果有多实例部署,需要多个服务共享同一份缓存,那本地缓存方案都不靠谱,直接上 Redis 或者同类中间件。
这句判断方法听起来简单,但在实际项目中能避免很多过度设计。我见过一些团队,为了几行验证码缓存逻辑硬生生引了一整套缓存框架,结果配置了半天,最后用的还是那几个基础 API,维护成本反而高了。选型之前,先问问自己:这个缓存真的需要那么强的能力吗?
5. 实战落地:验证码缓存与接口限流的完整示例
5.1 验证码场景:固定过期 + 校验后立即失效
验证码是最典型的 ExpiringMap 用例。我写过一个简化版,大概逻辑是:用户请求发送验证码时,生成随机数并写入 map;用户提交验证码时,从 map 里取出并比对,比对成功就立即删除。
public class SmsCodeService { private final ExpiringMap<String, String> codeMap = ExpiringMap.builder() .expiration(5, TimeUnit.MINUTES) .variableExpiration() .maxSize(5000) .resettingDurationOnRead(false) .build(); // 发送验证码,同号一分钟内只允许一次 public String sendCode(String phone) { String existing = codeMap.get(phone); if (existing != null) { throw new IllegalArgumentException("请求过于频繁,请稍后再试"); } String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); codeMap.put(phone, code); return code; } // 校验验证码,正确则立即删除防止重放 public boolean verifyCode(String phone, String code) { String cached = codeMap.get(phone); if (cached == null) { return false; } boolean ok = cached.equals(code); if (ok) { codeMap.remove(phone); } return ok; } }这里有两个细节是我实践下来特别想强调的。
第一,resettingDurationOnRead(false)必须开启。验证码的业务语义是“发送后 5 分钟内有效”,而不是“每次被读取都重新计时”。如果没有关掉这个默认行为,用户每提交一次错误验证码,过期时间就被重置一次,理论上只要不断尝试就能一直保活,完全违背了验证码的安全初衷。
第二,校验成功后要主动remove。虽然监听器也能清理,但用户输对验证码后,这条记录已经没有保留价值了,主动删除可以立刻释放内存,也能防止同一个验证码被反复使用。这不是 ExpiringMap 的特殊要求,任何缓存方案都建议这么做。
5.2 限流场景:过期监听器做统计归零
限流场景里,常见的做法是维护一个计数器,时间窗口到了之后自动归零。如果手写的话,你得加锁、记时间、开线程清理,非常麻烦。ExpiringMap 配合监听器可以写得很优雅。
我模拟一个简单的接口限流服务:同一个客户端 IP,一分钟内最多访问 10 次。
public class RateLimiterService { private final ExpiringMap<String, AtomicInteger> counterMap = ExpiringMap.builder() .expiration(1, TimeUnit.MINUTES) .maxSize(10000) .build(); public boolean tryAcquire(String clientIp) { AtomicInteger counter = counterMap.get(clientIp); if (counter == null) { // 这里一定要用 putIfAbsent,避免并发下重复创建计数器 AtomicInteger newCounter = new AtomicInteger(0); counter = counterMap.putIfAbsent(clientIp, newCounter); if (counter == null) { counter = newCounter; } } return counter.incrementAndGet() <= 10; } }这里有几个地方值得展开。首先是 value 用了AtomicInteger而不是普通Integer,因为计数操作是高频并发操作,用普通的get再put会有丢更新问题,AtomicInteger可以保证自增的原子性。
其次是putIfAbsent的用法。ExpiringMap 继承了 ConcurrentMap 的putIfAbsent语义,这一步在并发场景下至关重要:两个线程同时发现 counter 为 null,同时 new 了对象,如果不加这个约束,后写入的会覆盖先写入的,计数就会丢。用putIfAbsent保证只有第一个线程成功放入,后面的线程拿到的是同一个对象。
这种实现比我想象中要干净得多。之前我用ConcurrentHashMap加时间戳的时候,还得多写一个“如果当前时间大于过期时间就重置”的判断,现在这段逻辑全部交给 ExpiringMap 处理了,窗口一到自动清零。
5.3 两种常见踩坑姿势
回到实战中,我还想分享两个真实的踩坑记录。
第一次是把ExpiringMap当作普通Map的替代品,在循环里频繁put短期 key,而且 value 是一个比较大对象。结果后台扫描线程一直在干活,GC 压力骤增。后来查明了,不是 ExpiringMap 的问题,而是它可能比普通 Map 更频繁地触达那些 key,清理线程把过期对象不断摘除,短生命周期对象大量产生,导致 YGC 变多。这种场景下,我后来干脆换成了 Caffeine,因为它的淘汰机制更高效,对短生命周期的对象更友好。
第二次是误用了监听器做重量级操作。我在一个项目里给过期监听器加了发送短信的逻辑,测试环境没发现问题,上了生产之后偶尔出现短信延迟。排查下来才意识到,所有 key 过期都由同一个后台线程触发监听器,假如某个监听逻辑耗时 500ms,那后续所有过期条目的清理都会被卡住,表现就是缓存清理延迟、过期数据活得更久。从那以后我给自己定了一个铁律:监听器里只做轻量操作,耗时的事情全部丢到异步线程池。
6. 性能考量与使用边界:哪些场景别碰它
6.1 高频读写场景的瓶颈
ExpiringMap 的底层是ConcurrentHashMap,单条操作性能并不差,但它在每次put和get过程中都会额外处理过期时间的判断和更新。在一些极端高频的读写下,这个额外开销会被放大。
我做过一个粗略测试:一万条数据持续读写,ExpiringMap 的吞吐量大约是同尺寸ConcurrentHashMap的七八成。这个肉眼可能感知不出来,但在每秒几万次操作的接口里,差距就存在了。如果你的缓存读路径是核心链路、并发量极大,Caffeine 这类专门为高并发优化的方案显然更合适。
另外,ExpiringMap 的容量控制依赖于后台线程,这在数据量突然暴涨的时候会出现短暂的“超额存放”。类似瞬间写入十万条、超过 maxSize 数倍的情况,ExpiringMap 不会立刻拦下来,而是等后台线程慢慢清扫。如果是这种突发型大流量,需要在写入入口做额外的保护。
6.2 分布式和多实例场景无能为力
ExpiringMap 是纯粹的本地缓存,数据存在 JVM 堆内。一旦应用多实例部署,每个实例各存各的,缓存数据互不可见。这个时候如果业务要求“某个用户的验证码在任何一台机器上都能校验”,ExpiringMap 就完全不能满足需求。
我遇到过类似的尴尬:上线前没注意网关的负载均衡策略,结果用户两次请求被分发到不同实例,第二次校验时验证码在另一台机器上找不到,直接判定失败。那之后遇到这类跨实例共享的需求,我直接改用 Redis,再也不纠结本地缓存方案。
所以我的边界判断是:单机应用、内部模块、非核心链路,用 ExpiringMap 很舒服;一旦走到分布式、多实例、强一致性的场景,果断换中间件,别在本地缓存方案里死磕。
6.3 内存泄漏的风险点
ExpiringMap 本身不存储强引用之外的额外内容,理论上过期条目都会在后台扫描中被移除。但有两个容易产生“假内存泄漏”的场景。
第一,如果 value 是某个大对象的引用,而这个大对象又被其它静态变量持有,ExpiringMap 是否清理根本不影响它的存活。这个问题不是 ExpiringMap 的锅,而是业务层不合理的引用持有。
第二,如果注册了监听器,并且监听器里反向持有了 ExpiringMap 实例本身,或者持有了重量级上下文,那么过期条目虽然被移除了,监听器回调时依然会引用到那些 value。这种情况下,即使 map 里没有数据了,value 对象也不会被 GC。处理办法随时谨记:监听器持有的对象生命周期一定要清晰控制,该释放的引用要手动置 null。
7. 一些值得长期遵守的实操习惯
7.1 为每个 key 想清楚寿命
ExpiringMap 用起来简单,但“给数据设多少过期时间”是一件值得认真思考的事。我的经验是,任何缓存数据都要问自己三个问题:
- 数据多久变化一次?这决定了过期时间的上限,通常设置一个比变化频率略高的值。
- 数据多久访问一次?如果读取频率低,过期时间可以短一些,释放内存压力。
- 过期之后对业务有什么影响?如果影响大,设为写入后过期比读取重置过期更安全,因为后者可能会让数据“永生”。
想清楚这三个问题再写代码,比随手拍一个expiration(30, TimeUnit.MINUTES)靠谱得多。
7.2 初始化时就把配置写完整
我见过很多同事用ExpiringMap.create()创建实例,然后丢在一边,以为它自带魔法。实际上,如果完全没有设置过期时间和容量限制,它和一个普通ConcurrentHashMap没有明显区别。
所以我建议所有生产代码都用ExpiringMap.builder()来初始化,哪怕只有一个过期时间配置,也要写出来。这既是代码自文档化的要求,也是防止未来有人改配置时忘了 Builder 模式。
一个习惯性的完整初始化如下:
private static final ExpiringMap<String, String> CACHE = ExpiringMap.builder() .expiration(10, TimeUnit.MINUTES) .maxSize(2000) .resettingDurationOnRead(false) .variableExpiration() .build();这样一眼就能看出这份缓存的全部约束,代码维护的人不需要翻文档或者猜行为。
7.3 测试里显式验证过期行为
因为过期是时间相关的,很多人写单元测试时直接依赖Thread.sleep(),结果测试又慢又容易不稳定。我自己的做法是:把过期时间设得很短,比如 50 毫秒,然后循环等待一小段时间,再断言语义。如果测试框架支持虚拟时间,尽量用虚拟时间。
这里有一个很朴素也很重要的测试思路:不要等自然时间流过,而是反复轮询直到满足条件,或者等待一段时间后断言。比如:
@Test void testExpiration() throws Exception { ExpiringMap<String, String> map = ExpiringMap.builder() .expiration(50, TimeUnit.MILLISECONDS) .build(); map.put("k", "v"); Thread.sleep(200); assertNull(map.get("k")); }这种测试虽然多等了一会儿,但胜在稳定。更重要的是,它验证的是业务语义:超过 50 毫秒后数据不可见。至于内部清理线程是不是立刻执行,不是我们要测的重点。
8. 结语:小工具里的大道理
ExpiringMap 这个库很小,API 全加起来可能还不到十几个方法,源代码也就几千行。但我用了这么久,最深的体会是:一个工具的价值不在它有多复杂,而在于它能不能精准地命中你的痛点。
对于临时缓存、验证码、限流计数、会话状态这类“数据活一会儿就够了”的场景,它提供了一种直截了当的编程方式,让代码从繁琐的时间判断、清理逻辑中解放出来。它的设计哲学就是“过期是一种常态”,基于这个前提,所有 API 都围绕生命周期的管理展开,语义非常一致。
当然,它不是万能的。大规模、高并发、分布式场景,它有明确的短板。但选型这件事儿本来就不是找“最好的”,而是找“最合适的”。你手里项目的规模、并发、部署形态摆在那里,答案其实是显而易见的。
用不用 ExpiringMap,用不用 Caffeine,还是干脆上 Redis,建议先把你实际要缓存的数据量和访问模式罗列出来,再对照我前面给出的对比表一步步确认。缓存这事,最怕的不是选错,而是根本没想过“数据为什么要过期、什么时候过期、过期了之后怎么办”这三个问题。想明白了,用什么工具都能写得优雅。