Java实现LRU缓存案例

wen java案例 6

Java实现LRU缓存案例:从面试题到生产级高并发架构的终极指南


目录导读

  1. LRU缓存核心原理与数据结构选型
    • 为什么HashMap+双向链表是黄金组合?
    • 手写LRU:LinkedHashMap的“温柔陷阱”
  2. Java生产级LRU实现案例(双重锁+泛型+TTL)

    代码级拆解:get/put/淘汰/过期四大核心方法

    Java实现LRU缓存案例

  3. 高并发场景下的性能优化与坑点规避
    • Synchronized vs ConcurrentHashMap+CAS
    • 缓存穿透、雪崩、击穿的“三道防线”
  4. LRU进化版:LRU-K与TinyLFU(面试加分项)
  5. 常见面试问答(Q&A)速查表

LRU缓存核心原理与数据结构选型

LRU(Least Recently Used)算法的本质是“最近最少使用”,当缓存满时,优先淘汰最长时间未被访问的数据,实现该算法需解决两个核心问题:O(1)时间复杂度的数据访问O(1)时间复杂度的有序淘汰

为什么HashMap+双向链表是黄金组合?

  • HashMap(哈希表)负责提供O(1)的键值查询,通过hash定位节点地址。
  • 双向链表维护访问顺序:每次getput,将该节点移动到链表头部;当容量满时,直接删除链表尾部节点。
  • 之所以用双向链表而非单链表,是因为删除节点时需要获取其前驱节点,双向链表可直接通过node.prev获取,避免O(n)遍历。

手写LRU:LinkedHashMap的“温柔陷阱”
Java集合框架的LinkedHashMap内置了accessOrder参数,可在构造时设为true,这样每次访问节点会自动移动到链表尾部,重写removeEldestEntry(Map.Entry)方法,当size() > capacity时返回true即可实现简易LRU。但此方法线程不安全,且无法控制过期时间(TTL),仅适用于单线程或学习演示。


Java生产级LRU实现案例(双重锁+泛型+TTL)

以下为可直接落地的生产级代码,包含泛型支持过期时间(TTL)线程安全(双重检查锁)

public class LRUCache<K, V> {
    // 双向链表节点
    private static class Node<K, V> {
        K key;
        V value;
        long expireAt; // 过期时间戳(ms),0表示永不过期
        Node<K, V> prev, next;
        Node(K key, V value, long expireAt) {
            this.key = key;
            this.value = value;
            this.expireAt = expireAt;
        }
    }
    private final int capacity;
    private final Map<K, Node<K, V>> map = new HashMap<>();
    private final Node<K, V> head = new Node<>(null, null, 0); // 虚拟头
    private final Node<K, V> tail = new Node<>(null, null, 0); // 虚拟尾
    private final ReentrantLock lock = new ReentrantLock();
    public LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }
    public V get(K key) {
        lock.lock();
        try {
            Node<K, V> node = map.get(key);
            if (node == null) return null;
            if (isExpired(node)) { // 过期则删除
                removeNode(node);
                map.remove(key);
                return null;
            }
            moveToHead(node); // 更新访问顺序
            return node.value;
        } finally {
            lock.unlock();
        }
    }
    public void put(K key, V value, long ttlMillis) {
        lock.lock();
        try {
            Node<K, V> node = map.get(key);
            long expireAt = ttlMillis > 0 ? System.currentTimeMillis() + ttlMillis : 0;
            if (node != null) {
                node.value = value;
                node.expireAt = expireAt;
                moveToHead(node);
            } else {
                node = new Node<>(key, value, expireAt);
                map.put(key, node);
                addToHead(node);
                if (map.size() > capacity) {
                    // 淘汰尾部:需跳过已过期的节点
                    Node<K, V> tailNode = tail.prev;
                    while (tailNode != head && isExpired(tailNode)) {
                        removeNode(tailNode);
                        map.remove(tailNode.key);
                        tailNode = tail.prev;
                    }
                    if (map.size() > capacity) {
                        Node<K, V> eldest = tail.prev;
                        removeNode(eldest);
                        map.remove(eldest.key);
                    }
                }
            }
        } finally {
            lock.unlock();
        }
    }
    // 省略 addToHead, removeNode, moveToHead, isExpired 等私有方法
}

代码核心设计解析

  • 双重检查锁lock保证并发安全,但读写均加锁,写多读少场景性能尚可。
  • TTL处理get时主动检查过期;put时若缓存已满,为避免“脏数据”堆积,先清理尾部连续过期节点,再淘汰真正的LRU节点。
  • 虚拟头尾节点:避免空指针判断,简化链表边界操作。

高并发场景下的性能优化与坑点规避

Synchronized vs ConcurrentHashMap+CAS

  • 上述ReentrantLock是全局锁,高并发(>10万QPS)下竞争激烈,优化方案:改用ConcurrentHashMap存储节点,但并发淘汰逻辑复杂(需原子操作链表)。
  • 实践方案:采用 ConcurrentHashMap + 分段锁(Striped Lock),为每个哈希桶分配独立锁,降低竞争。
  • 最佳实践:若追求极致性能,可使用 CaffeineGuava Cache,其基于ConcurrentHashMap + 环形缓冲区实现,但面试时需展示底层原理。

缓存三大坑(穿透、雪崩、击穿)的“三道防线”

  • 缓存穿透(查询不存在的数据):采用布隆过滤器在缓存前拦截;或缓存空值(TTL设置极短)。
  • 缓存雪崩(大量key同时失效):设置随机TTL(如基础时间+随机数);或采用多级缓存(本地Caffeine + Redis)。
  • 缓存击穿(热点key过期瞬间高并发):互斥锁(只允许一个线程重建缓存);或逻辑过期(永不过期,异步更新)。

LRU进化版:LRU-K与TinyLFU(面试加分项)

LRU-K:核心思想是“访问两次才进入缓存”,记录每个key的访问历史(队列),第一次访问仅放入历史队列,第二次访问才移入缓存队列,有效防止一次性数据污染缓存。TinyLFU(Caffeine内置):使用Count-Min Sketch频率估算器,结合LFU(最不经常使用)与LRU,适应“稀疏突发”流量。


常见面试问答(Q&A)速查表

Q1:为什么用双向链表不用单向?
A:单向链表删除节点需遍历找前驱,复杂度O(n);双向链表直接node.prev,O(1)。
Q2:HashMap的扩容会影响LRU性能吗?
A:会,扩容需重新哈希,会短暂阻塞,生产环境可预分配容量(new HashMap<>(capacity)),避免扩容。
Q3:如何实现线程安全的LRU且保证高性能?
A:分段锁或使用ConcurrentLinkedHashMap;或直接使用Caffeine(内部采用W-TinyLFU算法)。
Q4:TTL过期但从未访问的节点如何清理?
A:惰性删除(访问时检查) + 定时清理(后台线程扫描链表尾部),实践中优先惰性删除,避免定时任务开销。



从面试手写LinkedHashMap到生产级高并发框架,LRU缓存的实现折射出Java并发编程、数据结构与系统架构设计的核心思想,掌握本文代码与原理,你不仅能轻松应对算法题,更能深入理解Caffeine、Redis等主流组件的底层逻辑。优化永无止境,但基础决定上限

抱歉,评论功能暂时关闭!