从源码出发,分析LRU缓存淘汰策略的实现!

良心公众号

关注不迷路

01

前情提要

在之前的如何优雅地实现LRU缓存淘汰算法?一文中,我们曾经提到过,利用java.util包中的LinkedHashMap可以很容易地实现LRU(最近最少使用)的缓存淘汰策略。这主要得益于LinkedHashMap底层维护的双向链表以及继承自HashMap的数据结构。

在大厂面试过程中,经常会遇到手写一个LRU缓存淘汰策略的题目。这时候,如果应聘者使用LinkedHashMap加以实现之后,面试官很有可能进一步发问!为什么可以实现?这一前一后两个问题,不仅考察了应聘者的代码能力,还考察了对基础和原理的把握,算得上是一道典型面试题目了。

之前已经讲过LRU的实现思路了,本文将重点从源码层面出发,分析一下为什么LinkedHashMap可以实现LRU。为了方便分析,我们将LRU缓存淘汰策略的实现代码作如下展示:

import java.util.LinkedHashMap;
import java.util.Map;


/**
 * 本程序及其注释在JDK11验证通过
 * @param <K>
 * @param <V>
 */
public class LRUCache<K, V> {


    // 默认负载因子
    private static final float DEFAULT_LOAD_FACTOR = 0.75f;


    // 缓存最大容量
    private final int MAX_CACHE_SIZE;


    // 自定义负载因子
    private final float LOAD_FACTOR;


    // 缓存主体LinkedHashMap
    private final LinkedHashMap<K, V> cacheMap;


    public LRUCache(int maxCacheSize, float loadFactor) {
        MAX_CACHE_SIZE = maxCacheSize;
        // 可根据具体情况自定义负载因子
        LOAD_FACTOR = loadFactor;
        // 根据缓存最大容量计算Map的初始化容量,避免扩容影响性能
        int capacity = (int)Math.ceil(MAX_CACHE_SIZE / LOAD_FACTOR) + 1;
        // accessOrder设置为true,表示在插入或者访问的时候,都会更新缓存,将该数据插入链表尾部或者移动至链表尾部
        cacheMap = new LinkedHashMap<>(capacity, LOAD_FACTOR, true) {
            private static final long serialVersionUID = 1001L;
            // 重写removeEldestEntry方法,当cacheMap的size超过缓存最大容量时,将链表头部数据移除
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > MAX_CACHE_SIZE;
            }
        };
    }


    public LRUCache(int maxCacheSize) {
        // 使用默认负载因子
        this(maxCacheSize, DEFAULT_LOAD_FACTOR);
    }


    public void put(K key, V value) {
        cacheMap.put(key, value);
    }


    public V get(K key) {
        return cacheMap.get(key);
    }
}

02

非线程安全

LRUCache工具类在单线程情况下可以很好地工作(在面试过程中,往往也可以很好地工作!),但不幸的是,本地缓存的实际使用场景,往往伴随着多线程的环境,原因是因为,无论是缓存,还是多线程,都是为了提升性能,因此,二者大概率是会同时存在于系统中的,这时候类似于上述LRUCache的实现便不再适用。

由于LinkedHashMap是非线程安全的,因此,为了实现线程安全的LRU缓存淘汰策略,一种思路是对LinkedHashMap的公共方法制定并发访问策略(加锁)。比较明显的是,对LinkedHashMap的写操作是非线程安全的。但事实上,在按结点访问顺序排序的策略下,对LinkedHashMap的读操作也是非线程安全的。因此,在制定有效的并发访问策略之前,首先需要了解其内部的实现。

而工作中更常用的本地缓存实现,则是Google开源的Guava Cache,它并非采用上述方式进行实现,而是采用了效率更高的类似于ConcurrentHashMap分段锁的实现。Guava Cache的内容值得单独写一篇文章来讲述,在此就不展开。

在分析源码之前,我们首先看一下LinkedHashMap在Map大家族中的位置:

从上图我们可以看到,LinkedHashMap继承了HashMap,并实现了Map接口。

03

源码解读

接下来我们从LinkedHashMap的源码(JDK11版本)入手,对其内部实现进行解读。

首先需要了解的是,LinkedHashMap内部一个关键的数据结构:

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

这个名为Entry的静态内部类继承自HashMap的静态内部类Node,并通过before和after实现了双向链表的功能。

基于Entry的数据结构,LinkedHashMap通过head维护双向链表的头结点,通过tail维护双向链表的尾结点,并利用布尔值accessOrder实现对结点排序策略的控制,具体代码如下:

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V> {
    /**
     * 双向链表的头结点(LRU中最先被淘汰的结点)
     */
    transient LinkedHashMap.Entry<K,V> head;


    /**
     * 双向链表的尾结点(LRU中最后被淘汰的结点)
     */
    transient LinkedHashMap.Entry<K,V> tail;


    /**
     * 结点的排序策略:基于访问顺序(true),基于插入顺序(false)
     */
    final boolean accessOrder;
}

至此,我们对LinkedHashMap的属性已经有了比较清晰的了解。接下来,让我们重点关注一下,在LRUCache类中使用到的LinkedHashMap的方法。

public V get(Object key) {
    Node<K,V> e;
    // 判断key是否存在
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // 若结点的排序策略为基于访问顺序排序,则执行afterNodeAccess方法
    if (accessOrder)
        // 将本次访问的结点,移动到链表的尾结点
        afterNodeAccess(e);
    return e.value;
}

在get方法的源码中,我们看到,它调用了父类HashMap中getNode的方法来判断key是否存在,然后在accessOrder为true时,执行afterNodeAccess方法。

值得注意的是,由于afterNodeAccess方法的功能是将本次访问的结点,移动到链表的尾结点,因此,当accessOrder为true时,get方法实际上存在写操作!这直接影响了并发访问控制策略的制定。

LinkedHashMap的put方法直接继承自父类HashMap,但值得注意的是,LinkedHashMap重写了afterNodeInsertion方法,这使得在put操作在满足removeEldestEntry方法(需要被重写,否则默认返回false)所指定的条件的时候,就会触发removeNode方法,最终删除被淘汰的结点。

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

至此,我们对实现LRU缓存过程中所使用到的LinkedHashMap的特性,均已从源码层面进行了解读。

本文关于LinkedHashMap的源码分析总结就到这里了。

欢迎大家一起讨论技术,共同成长!

学习 | 工作 | 分享

????长按关注“有理想的菜鸡

只有你想不到,没有你学不到

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值