
项目里随手写缓存逻辑最烦的不是写作代码而是“这个数据到底要放多久”。以前我处理临时数据脑子里的第一反应就是ConcurrentHashMap加一个System.currentTimeMillis()判断再配个定时任务去清理。用了几次之后发现这套东西写着写着就变成重复代码了而且每次清理时机都不准过期数据总会在内存里多待一阵子。后来我换成了ExpiringMap一个专门为缓存场景设计的带过期时间的 Map才算把这个痛点彻底解决。1. 重新认识 ExpiringMap它到底替我们扛了什么1.1 它解决的不是“存数据”而是“数据什么时候失效”普通 Map 管的是“怎么存、怎么取”而 ExpiringMap 管的是“什么时候不能取”。这个区别看起来不大但一落到真实场景里完全是两码事。举个最常见的例子登录验证码。用户请求一个验证码你把它塞进 Mapkey 是手机号或者会话 IDvalue 是六位数字。验证码这东西天然有生命周期常规操作是 5 分钟有效过期就作废。如果只用ConcurrentHashMap你得在写入时额外记一个时间戳取出来之后还得比较一下当前时间有没有超过有效期限。更麻烦的是那些过期但没被访问的验证码永远躺在内存里你得再开一个定时任务定期扫表。这些问题 ExpiringMap 都提前给你安排好了。写入数据时可以指定过期时长时间一到这个 entry 自动变成不可见甚至被彻底移除。你不用关心它具体是什么时候被清掉的也基本上不需要自己再写任何清理线程。它解决的就是“缓存数据生命周期管理”这个横切需求。1.2 缓冲区和缓存区的本质差异我刚开始用 ExpiringMap 时有个误解觉得它就是HashMap的增强版无非多了个过期时间。后来踩了一次坑才明白普通 Map 更像一个持久化的储物柜你放进去的东西只要不手动删理论上永远在那儿。而 ExpiringMap 的核心假设是我这个容器里的数据默认就是会过期的过期是一种常态不是异常。这个假设直接决定了它的内部设计和 API 风格。比如它可以设置全局默认过期时间可以单独为某个 key 指定不同的时长还可以在所有 entry 的过期时间统一变化时一键更新。你要是把这些功能用ConcurrentHashMap加时间戳来实现代码量不难难的是那些边边角角的并发问题、清理时机问题、监听回调问题每个点都要自己抠。所以我的判断是ExpiringMap 适合所有“我知道这数据不需要永久存在”的小型缓存场景。它不追求像专业缓存中间件那样的大规模容量和极致性能但在单机应用内、数据量几百到几千条这个量级它确实很好用代码写起来也干净。2. 上手实践核心 API 与三种常见玩法2.1 引入依赖与基本构造方式如果你是 Maven 项目直接在pom.xml里加依赖就行dependency groupIdnet.jodah/groupId artifactIdexpiringmap/artifactId version0.5.10/version /dependency这个版本是目前用得最多的稳定版API 没怎么变过。引入之后最基础的创建方式是这样ExpiringMapString, String map ExpiringMap.create();create()创建出来的实例所有 key 默认不过期行为和一个普通ConcurrentHashMap几乎一样。但真正要发挥它的能力我建议直接用 Builder 方式初始化ExpiringMapString, 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 一直被访问它就永远不会过期。ExpiringMapString, 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 这一层。关键代码我简化一下大致是这样ExpiringEntryK, 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 ExpiringMapString, 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 ExpiringMapString, 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 ExpiringMapString, String CACHE ExpiringMap.builder() .expiration(10, TimeUnit.MINUTES) .maxSize(2000) .resettingDurationOnRead(false) .variableExpiration() .build();这样一眼就能看出这份缓存的全部约束代码维护的人不需要翻文档或者猜行为。7.3 测试里显式验证过期行为因为过期是时间相关的很多人写单元测试时直接依赖Thread.sleep()结果测试又慢又容易不稳定。我自己的做法是把过期时间设得很短比如 50 毫秒然后循环等待一小段时间再断言语义。如果测试框架支持虚拟时间尽量用虚拟时间。这里有一个很朴素也很重要的测试思路不要等自然时间流过而是反复轮询直到满足条件或者等待一段时间后断言。比如Test void testExpiration() throws Exception { ExpiringMapString, 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建议先把你实际要缓存的数据量和访问模式罗列出来再对照我前面给出的对比表一步步确认。缓存这事最怕的不是选错而是根本没想过“数据为什么要过期、什么时候过期、过期了之后怎么办”这三个问题。想明白了用什么工具都能写得优雅。