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

Posted 有理想的菜鸡

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从源码出发,分析LRU缓存淘汰策略的实现!相关的知识,希望对你有一定的参考价值。

关注不迷路


01

前情提要


在之前的一文中,我们曾经提到过,利用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的内容值得单独写一篇文章来讲述,在此就不展开。


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



从上图我们可以看到,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,并通过beforeafter实现了双向链表的功能。


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


public class LinkedHashMap<K,Vextends HashMap<K,Vimplements 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方法的源码中,我们看到,它调用了父类HashMapgetNode的方法来判断key是否存在,然后在accessOrdertrue时,执行afterNodeAccess方法。


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


LinkedHashMapput方法直接继承自父类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的源码分析总结就到这里了。


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

学习 | 工作 | 分享

以上是关于从源码出发,分析LRU缓存淘汰策略的实现!的主要内容,如果未能解决你的问题,请参考以下文章

前端进阶算法3:从浏览器缓存淘汰策略和Vue的keep-alive学习LRU算法

双链表实现LRU缓存淘汰策略

Redis中的LRU淘汰策略分析

从浏览器缓存淘汰策略和Vue的keep-alive学习LRU算法

数据结构:缓存淘汰策略

06 | 链表(上):如何实现LRU缓存淘汰算法?