ExpiringMap 不适用于高频写入场景,因其惰性过期机制导致清理不及时、内存持续上涨甚至OOM;后台扫描开销随缓存规模线性增长,且缺乏强实时性保障。

ExpiringMap 为什么不能直接用在生产高频写入场景
ExpiringMap 库(如 net.jodah:expiringmap)默认基于惰性过期 + 定时扫描,不是真正“到点即删”。写入压力大时,expirationListener 可能堆积、cleanUp() 调用不及时,导致内存持续上涨甚至 OOM。
- 它依赖后台线程周期性遍历 entrySet,扫描开销随缓存 size 线性增长
- 没有强实时性保障:一个 key 到期后,可能要等下一次
cleanUp()才被移除(默认 60 秒间隔) - 多线程 put/remove 下,内部使用
ConcurrentHashMap+ 额外锁,吞吐量不如原生ConcurrentHashMap - 不支持基于访问时间(access-based)和写入时间(write-based)混合策略,配置项
ExpirationPolicy易混淆
自己写定时清理线程?别直接 new Thread()
手动启一个 Timer 或 Thread 做轮询清理,看似简单,但极易出问题:线程泄漏、未捕获异常导致调度中断、JVM 退出时没 shutdown。
- 必须用
ScheduledThreadPoolExecutor,且设为 daemon 模式:new ScheduledThreadPoolExecutor(1, r -> { Thread t = new Thread(r); t.setDaemon(true); return t; }) - 清理任务里要 try-catch 所有异常,否则一次
NullPointerException就让整个调度停摆 - 不能直接遍历
ConcurrentHashMap.keySet()后 remove —— 这会触发 full lock,应改用computeIfPresent()或remove(key, value)条件删除 - 推荐按段清理:把 key 哈希后分桶,每次只扫 1/10 的桶,避免单次耗时过长
更轻量的替代方案:Caffeine + expireAfterWrite
如果只是需要带过期的本地缓存,Caffeine 是目前 Java 生态最稳的选择,它用 Window TinyLfu + 异步清理,几乎零 GC 压力,且 API 更贴近直觉。
-
expireAfterWrite(10, TimeUnit.SECONDS)是硬限制,到期后首次 get 即失效,不依赖后台扫描 - 自动启用异步 cleanup 线程(共享 ForkJoinPool.commonPool),无需手动管理生命周期
- 支持
refreshAfterWrite实现“逻辑过期+后台刷新”,适合防击穿 - 注意:不要把
Caffeine.newBuilder().maximumSize(0)当无限制用——size=0 表示禁用容量淘汰,只靠过期控制,但内存仍可能涨,得配合recordStats()监控
Cache<String, Object> cache = Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.SECONDS)
.removalListener((key, value, cause) -> log.debug("Evicted: {} due to {}", key, cause))
.build();
自定义 Map 包装类最容易漏掉的三件事
哪怕你决定手撸一个 ExpiringMapWrapper,以下三点不处理,上线后必踩坑:
立即学习“Java免费学习笔记(深入)”;
- 没重写
size():返回的是原始 map.size(),不含已过期但未清理的 key,监控看到的 size 和实际内存占用严重不符 - 没同步
clear():调用 clear 时只清了主 map,忘了 cancel 对应的定时任务或从延迟队列中 remove - 序列化忽略
transient字段:比如用DelayQueue存到期任务,没标transient,反序列化后任务全丢,缓存永久不过期
过期逻辑本身不难,难的是和 JVM 生命周期、GC 行为、监控指标对齐。多数人卡在“以为删了”和“其实还占着”之间。










