从源码出发,分析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超过缓存最大容量时,将链表头部数据移除
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的源码分析总结就到这里了。
欢迎大家一起讨论技术,共同成长!
学习 | 工作 | 分享
以上是关于从源码出发,分析LRU缓存淘汰策略的实现!的主要内容,如果未能解决你的问题,请参考以下文章 前端进阶算法3:从浏览器缓存淘汰策略和Vue的keep-alive学习LRU算法