用 HashMap 做缓存会引发内存溢出、并发异常和数据过期问题;ConcurrentHashMap 仅解决线程安全,仍需手动实现过期与驱逐;复杂场景应直接使用 Caffeine 等成熟缓存库。

用 HashMap 做缓存会出什么问题?
直接用 HashMap 存缓存数据,短期看能跑通,但很快会遇到三类典型问题:内存持续增长导致 OutOfMemoryError;多线程并发读写引发 ConcurrentModificationException 或脏数据;没有过期机制,旧数据永远不更新。这不是“能不能用”的问题,而是“什么时候崩”的问题。
为什么选 ConcurrentHashMap 而不是 HashMap?
ConcurrentHashMap 是线程安全的,支持高并发读(无锁)、分段写(JDK 8 后为 CAS + synchronized 细粒度锁),比 Collections.synchronizedMap() 性能好得多。但它仍不解决过期和容量控制——它只是缓存的“容器底座”,不是完整方案。
常见误用:
- 把 ConcurrentHashMap 当成带过期功能的缓存(它不支持 TTL)
- 在循环中直接调用 put() + get() 而不处理 compute-if-absent 竞态(推荐用 computeIfAbsent())
手动实现带过期和容量限制的简易缓存
核心逻辑就三点:时间戳标记、LRU 驱逐、读写线程安全。下面是一个可直接运行的轻量实现:
public class SimpleCache{ private final ConcurrentHashMap > cache; private final long expireMillis; private final int maxCapacity; public SimpleCache(int maxCapacity, long expireMillis) { this.cache = new ConcurrentHashMapzuojiankuohaophpcnyoujiankuohaophpcn(); this.maxCapacity = maxCapacity; this.expireMillis = expireMillis; } public V get(K key) { CacheEntryzuojiankuohaophpcnVyoujiankuohaophpcn entry = cache.get(key); if (entry == null || System.currentTimeMillis() - entry.timestamp youjiankuohaophpcn expireMillis) { cache.remove(key); return null; } return entry.value; } public V put(K key, V value) { if (cache.size() youjiankuohaophpcn= maxCapacity && !cache.containsKey(key)) { // 简单驱逐:随机移除一个(生产建议换 LRU 链表) K toRemove = cache.keySet().stream().findAny().orElse(null); if (toRemove != null) cache.remove(toRemove); } cache.put(key, new CacheEntryzuojiankuohaophpcnyoujiankuohaophpcn(value)); return value; } private static class CacheEntryzuojiankuohaophpcnVyoujiankuohaophpcn { final V value; final long timestamp; CacheEntry(V value) { this.value = value; this.timestamp = System.currentTimeMillis(); } }}
立即学习“Java免费学习笔记(深入)”;
说明:
- 过期判断在get()中完成,避免后台线程开销
- 驱逐策略用的是简单随机剔除,真实项目应替换为基于访问顺序的 LRU(比如用LinkedHashMap+removeEldestEntry,但要注意线程安全)
-put()不保证强一致性:若刚好达到maxCapacity,多个线程同时写可能短暂超限什么时候该放弃手写,改用成熟库?
一旦出现以下任一情况,手写缓存就该停了:
- 需要精确的 TTL / TTI(访问后重置过期时间)
- 要求缓存穿透/击穿防护(如空值缓存、互斥锁)
- 需与 Spring Boot 自动集成(@Cacheable)
- 有堆外缓存、多级缓存、分布式同步需求这时直接上
Caffeine:它默认启用 LRU + 过期 + 弱引用清理,API 简洁,性能碾压手写。例如一行代码就能构建带自动刷新的缓存:Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10, TimeUnit.MINUTES).build(key -> loadFromDB(key));手写缓存的价值在于理解原理和应对极简场景;它的危险在于容易被当成“够用”而长期留在生产里——尤其当团队开始加“临时”过期逻辑、手动清理线程、双重检查锁时,就是重构信号。










