2. 内存缓存实现:LRU缓存数据结构设计、LinkedHashMap继承与封装、线程安全控制

内存缓存是三级缓存体系中延迟最低的一级,通常采用LRU(最近最少使用)淘汰策略。本章将拆解LRU缓存的核心数据结构设计,并给出基于LinkedHashMap的工程实现方案,同时解决多线程环境下的线程安全问题。

2.1 LRU缓存原理与数据结构

LRU缓存本质上是一个有限容量的哈希表 + 双向链表。哈希表提供O(1)的查找,双向链表维护访问顺序。每次访问或插入时,将该节点移动至链表头部;当缓存满时,移除链表尾部的节点。

LRU缓存数据结构与线程安全架构 LRUCache capacity / lock / map LinkedHashMap accessOrder / removeEldestEntry Node key / value / prev / next ReentrantReadWriteLock readLock / writeLock get(K key) put(K key, V value) 核心设计要点 ① LinkedHashMap继承HashMap,支持访问排序(accessOrder) ② 重写removeEldestEntry实现容量限制自动淘汰 ③ 读写锁分离:读操作共享锁,写操作独占锁

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)提高读并发:

  1. 读操作(get)加读锁,允许多线程同时读取。
  2. 写操作(put、remove、clear)加写锁,保证互斥。
  3. 遍历/更新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分析线程状态。