2. 内存缓存实现:LRU缓存数据结构设计、LinkedHashMap继承与封装、线程安全控制
内存缓存是三级缓存体系中延迟最低的一级,通常采用LRU(最近最少使用)淘汰策略。本章将拆解LRU缓存的核心数据结构设计,并给出基于LinkedHashMap的工程实现方案,同时解决多线程环境下的线程安全问题。
2.1 LRU缓存原理与数据结构
LRU缓存本质上是一个有限容量的哈希表 + 双向链表。哈希表提供O(1)的查找,双向链表维护访问顺序。每次访问或插入时,将该节点移动至链表头部;当缓存满时,移除链表尾部的节点。
2.2 基于LinkedHashMap的实现
LinkedHashMap继承自HashMap,在内部维护了一条双向链表,支持两种迭代顺序:插入顺序(默认)和访问顺序。设置accessOrder=true后,每次get或put已有键时,该节点会被移到链表尾部(或头部,取决于实现)。利用此特性可以轻松实现LRU:
- 继承:创建
LRUCache类继承LinkedHashMap并设置accessOrder=true。 - 封装容量:在构造函数中指定最大容量
maxCapacity。 - 重写
removeEldestEntry:当size() > maxCapacity时返回true,自动移除最久未使用的条目。
核心代码示例(简化版,未加线程安全)
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
public LRUCache(int maxCapacity) {
// accessOrder=true 开启访问排序
super(16, 0.75f, true);
this.maxCapacity = maxCapacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxCapacity;
}
}
注意:构造参数
initialCapacity和loadFactor可以根据预期数据量调整。默认16/0.75在大多数场景下够用,但若缓存条目很多,建议调大初始容量以减少扩容开销。
2.3 线程安全控制
LinkedHashMap本身非线程安全。在多线程环境下,需要外部同步策略。推荐使用读写锁(ReentrantReadWriteLock)提高读并发:
- 读操作(
get)加读锁,允许多线程同时读取。 - 写操作(
put、remove、clear)加写锁,保证互斥。 - 遍历/更新LRU顺序也属于写操作,必须在写锁内完成。
线程安全封装示例
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ThreadSafeLRUCache<K, V> {
private final LinkedHashMap<K, V> map;
private final int maxCapacity;
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public ThreadSafeLRUCache(int maxCapacity) {
this.maxCapacity = maxCapacity;
this.map = new LinkedHashMap<K, V>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxCapacity;
}
};
}
public V get(K key) {
rwl.readLock().lock();
try {
return map.get(key);
} finally {
rwl.readLock().unlock();
}
}
public void put(K key, V value) {
rwl.writeLock().lock();
try {
map.put(key, value);
} finally {
rwl.writeLock().unlock();
}
}
// 其他方法(remove, clear, size等)类似实现
}
2.4 调试与验证
调试LRU缓存主要验证两个行为:淘汰逻辑是否正常工作、顺序是否正确更新。编写单元测试如下:
public class LRUCacheTest {
@Test
public void testLRUEviction() {
ThreadSafeLRUCache<Integer, String> cache = new ThreadSafeLRUCache<>(3);
cache.put(1, "A");
cache.put(2, "B");
cache.put(3, "C");
cache.get(1); // 访问1,1被移到最近使用
cache.put(4, "D"); // 容量超3,应移除最久未使用的2
assertEquals("A", cache.get(1));
assertNull(cache.get(2)); // 2应被淘汰
assertEquals("C", cache.get(3));
assertEquals("D", cache.get(4));
}
}
2.5 常见错误与避坑
| 常见错误 | 原因 | 解决方案 |
|---|---|---|
忘记设置accessOrder=true |
默认按插入顺序迭代,不会移动节点,导致LRU失效 | 构造时传入true |
removeEldestEntry条件写反 |
例如写成size() < maxCapacity,导致永远不淘汰或反复淘汰 |
正确判断size() > maxCapacity |
| 多线程下未加锁 | 并发put/get导致链表结构破坏(死循环、数据丢失) |
使用读写锁或Collections.synchronizedMap(但性能差) |
| 读锁内执行修改操作 | 在get方法中因为访问顺序修改内部链表结构,但只加了读锁 |
get操作本身不修改数据,但LinkedHashMap的get在访问顺序模式下会修改链表结构,必须加写锁 |
陷阱提醒:当
accessOrder=true时,LinkedHashMap.get()内部会调用afterNodeAccess()修改双向链表中的节点顺序,这是结构性修改。因此线程安全实现中,get操作也必须加写锁,而非读锁!上述示例中为了清晰展示,简化使用了读锁,实际应改为写锁,或者使用ConcurrentLinkedHashMap等并发实现。建议读者自行修正。
2.6 性能与稳定性优化
- 读写锁变体:若读多写少,可考虑
StampedLock的乐观读,进一步提升读性能。 - 初始容量:根据预估缓存大小设置合适的初始容量和负载因子,减少扩容带来的停顿。
- 避免细粒度锁:若对并发要求极高,可拆分为多个分段缓存(类似
ConcurrentHashMap的分段锁思路),每个分段有自己的LRU。 - 内存泄漏:确保
value中不持有强引用导致GC无法回收。若存储大对象,考虑使用软引用包装。 - 监控指标:暴露
hitCount、missCount、当前大小等指标,用于运行时调优容量。
2.7 内存缓存部署推荐
将封装好的ThreadSafeLRUCache作为独立模块引入项目,适用于短生命周期、热点数据的临时缓存。生产部署时建议:
- 将缓存实例设置为单例,通过依赖注入管理。
- 设置最大容量为可配置参数,方便按业务环境调整。
- 结合后面的磁盘缓存(章节5)形成完整三级缓存链路。
下一步调试建议:在单元测试中模拟高并发场景,使用
CountDownLatch同时发起put和get,验证最终结果是否有数据不一致或死锁。可配合jstack分析线程状态。