文章目录
1.背景
我们在各类缓存等应用场景中都涉及到LRU算法。接下来我仔细分析一下LRU算法的设计思路及代码实现。以及介绍一些扩展知识点。
2.LRU详解
2.1 什么是LRU?
LRU是什么?按照英文的直接原义就是Least Recently Used,最近最久未使用法(去掉最久未使用的数据),它是按照一个非常著名的计算机操作系统基础理论得来的:最近使用的页面数据会在未来一段时期内仍然被使用,已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。基于这个思想,会存在一种缓存淘汰机制,每次从内存中找到最久未使用的数据然后置换出来,从而存入新的数据!它的主要衡量指标是使用的时间(顺序),附加指标是使用的次数。在计算机中大量使用了这个机制,它的合理性在于优先筛选热点数据,所谓热点数据,就是最近最多使用的数据!因此,利用LRU我们可以解决很多实际开发中的问题,并且很符合业务场景。
2.2 LRU算法设计
2.2.1、数据结构。
由LRU概念原理分析,我们需要的这个数据结构必须满足条件:查找快,插入快,删除快,有顺序之分。那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表。LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。这个数据结构长这样:
双向链表有一个特点就是它的链表是双路的,我们定义好头节点和尾节点,然后利用先进先出(FIFO),最近被放入的数据会最早被获取。其中主要涉及到新增、访问、修改、删除操作。
2.2.2、新增&修改
新增和修改的逻辑是一样的:
- 先查询链表中此key是否存在。
- 如果存在,则为修改,用新值覆盖旧值。将整个节点移动至队尾。
- 如果不存在,则是新增。先判断是否超过链表容量,如果超过则将队首(最久未使用)节点删除后进行下一步。如果未超过容量,直接进行下一步。
- 将新节点,放在链表队尾,其他的元素顺序往队首移动;
具体实现时,判断链表容量时,还有扩容一说。具体原理参考HashMap存储扩容原理。
2.2.3、访问
- 先查询链表中此key是否存在。若不存在则返回null。
- 若存在,则返回对应的value。
- 若节点在队尾则不用管。若在队中或队首,则将对应节点移动至队尾。
- 返回为null还有一种情况,即对应key的节点存在,但其值为null。
2.2.4、删除
- 判断节点存在。
- 移除节点,其他节点顺序移动位置。
3.LRU实现
3.1 Java自己封装实现
- 定义基本的链表操作节点
public class Node {
//键
Object key;
//值
Object value;
//上一个节点
Node pre;
//下一个节点
Node next;
public Node(Object key, Object value) {
this.key = key;
this.value = value;
}
}
- 链表基本定义
我们定义一个LRU类,然后定义它的大小、容量、队尾节点、队首节点等部分,然后一个基本的构造方法
public class LRU<K, V> {
private int currentSize;//当前的大小
private int capcity;//总容量
private HashMap<K, Node> caches;//所有的node节点
private Node last;//队尾节点
private Node first;//队首节点
public LRU(int size) {
currentSize = 0;
this.capcity = size;
caches = new HashMap<K, Node>(size);
}
- 添加(修改)元素
添加(修改)元素的时候首先判断是不是新的元素,如果是新元素,判断当前的大小是不是大于总容量了,防止超过总链表大小,如果大于的话直接抛弃队首第一个节点,然后再以传入的key\value值创建新的节点。对于已经存在的元素,直接覆盖旧值,再将该元素移动到队尾部,然后保存在map中。
/**
* 添加(修改)元素
* @param key
* @param value
*/
public void put(K key, V value) {
Node node = caches.get(key);
//如果新元素
if (node == null) {
//如果超过元素容纳量
if (caches.size() >= capcity) {
//移除队首第一个节点
caches.remove(first.key);
removeLast();
}
//创建新节点
node = new Node(key,value);
}
//已经存在的元素覆盖旧值
node.value = value;
//把元素移动到队尾部
moveToLast(node);
caches.put(key, node);
}
如下所示,访问key=3这个节点的时候,需要把3移动到队尾,这样能保证整个链表的队尾(最近)节点一定是特点数据(最近使用的数据!)
4. 访问元素
通过key值来访问元素,主要的做法就是先判断如果是不存在的,直接返回null。如果存在,把数据移动到队尾部成为最后的节点,然后再返回旧值。
/**
* 通过key获取元素
* @param key
* @return
*/
public Object get(K key) {
Node node = caches.get(key);
if (node == null) {
return null;
}
//把访问的节点移动到尾部
moveToLast(node);
return node.value;
}
- 节点删除操作
在根据key删除节点的操作中,我们需要做的是把节点的前一个节点的指针指向当前节点下一个位置,再把当前节点的下一个的节点的上一个指向当前节点的前一个,这么说有点绕,我们来画图来看:
/**
* 根据key移除节点
* @param key
* @return
*/
public Object remove(K key) {
Node node = caches.get(key);
if (node != null) {
if (node.pre != null) {
node.pre.next = node.next;
}
if (node.next != null) {
node.next.pre = node.pre;
}
if (node == last) {
last = node.next;
}
if (node == first) {
first = node.pre;
}
}
return caches.remove(key);
}
假设现在要删除3这个元素,我们第一步要做的就是把3的pre节点4(这里说的都是key值)的下一个指针指向3的下一个节点2,再把3的下一个节点2的上一个指针指向3的上一个节点4,这样3就消失了,从4和2之间断开了,4和2再也不需要3来进行连接,从而实现删除的效果。
- 移动元素到队尾节点
首先把当前节点移除,类似于删除的效果(但是没有移除该元素),然后再将队尾节点设为当前节点的下一个,再把当前节点设为队尾节点的前一个节点。当前节点设为队尾节点。再把当前节点的前一个节点设为null,这样就是间接替换了队尾节点为当前节点。
/**
* 把当前节点移动到队尾部
* @param node
*/
private void moveToLast(Node node) {
if (last == node) {
return;
}
if (node.next != null) {
node.next.pre = node.pre;
}
if (node.pre != null) {
node.pre.next = node.next;
}
if (node == fisrt) {
fisrt= fisrt.pre;
}
if (last == null || first == null) {
last = first = node;
return;
}
node.next = last;
last.pre = node;
last= node;
last.pre = null;
}
3.2 基于LinkedHashMap实现
对于上述的实现思路,java.util.LinkedHashMap已经实现了其中的99%,因此直接基于LinkedHashMap实现LRUCache非常简单。
LinkedHashMap为LRUCache铺垫了什么
-
LinkedHashMap本质上还是复用HashMap的绝大部分功能,包括底层的Node<K, V>[],因此能支持原本HashMap的功能。
-
构造方法提供了accessOrder选项,传入true后,get时会将访问的节点移至队尾(为最近使用节点)。
-
覆盖了父类HashMap的newNode方法和newTreeNode方法,这两个方法在HashMap中只是创建Node用的,而在LinkedHashMap中不但创建Node,还将Node放在链表末尾。
-
父类HashMap提供了3个void的Hook方法,方法没做任何事:
afterNodeRemoval 父类在remove一个集合中存在的元素后调用
afterNodeInsertion 父类在put、compute、merge后调用
afterNodeAccess 父类在replace、compute、merge等替换值后会调用LinkedHashMap实现了父类HashMap的这3个Hook方法,并新增一个方法:
afterNodeRemoval 实现链表的删除操作
afterNodeInsertion 并没有实现链表的插入操作
afterNodeAccess 如前所述,设置accessOrder为true后会将被操作的节点放在链表末尾,保证链表顺序按访问顺序逆序排列
removeEldestEntry 新添 加了一个Hook方法boolean removeEldestEntry,当这个Hook方法返回true时,删除链表队首的节点,但这个方法在LinkedHashMap中的实现永远返回false,要使用需要复写。
到这为止,实现一个LRUCache就很简单了:实现这个removeEldestEntryHook方法,给LinkedHashMap设置一个阈值,那么超过这个阈值时就会进行LRU淘汰。
网上随处可见的Java代码实现
// 继承LinkedHashMap
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int MAX_CACHE_SIZE;
public LRUCache(int cacheSize) {
// 使用构造方法 public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
// initialCapacity 初始化容量大小、loadFactor 负载因子
// accessOrder要设置为true,按访问排序
super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
MAX_CACHE_SIZE = cacheSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
// 超过阈值时返回true,进行LRU淘汰
return size() > MAX_CACHE_SIZE;
}
}
看似几行代码解决的事儿,其实只是冰山一角而已。以前百度三面的时候,遇到面时官问我为什么要基于LinkedHashMap实现,就是以上这个知识点。
4.彩蛋
4.1 FIFO算法及实现
先进先出,如果缓存容量满,则优先移出最早加入缓存的数据;其内部可以使用队列实现。
- 特点
- Object get(key):获取保存的数据,如果数据不存在或者已经过期,则返回null。
- void put(key,value,expireTime):加入缓存,无论此key是否已存在,均作为新key处理(移除旧key);如果空间不足,则移除已过期的key,如果没有,则移除最早加入缓存的key。过期时间未指定,则表示永不自动过期。
- 此题需要注意,我们允许key是有过期时间的,这一点与普通的FIFO有所区别,所以在设计此题时需要注意。(也是面试考察点,此题偏设计而非算法)
普通的FIFO或许大家都能很简单的写出,此处增加了过期时间的特性,所以在设计时需要多考虑。如下示例,为FIFO的简易设计,尚未考虑并发环境场景。
- 设计思路
- 用普通的hashMap保存缓存数据。
- 我们需要额外的map用来保存key的过期特性,例子中使用了TreeMap,将“剩余存活时间”作为key,利用treemap的排序特性。
public class FIFOCache {
//按照访问时间排序,保存所有key-value
private final Map<String,Value> CACHE = new LinkedHashMap<>();
//过期数据,只保存有过期时间的key
//暂不考虑并发,我们认为同一个时间内没有重复的key,如果改造的话,可以将value换成set
private final TreeMap<Long, String> EXPIRED = new TreeMap<>();
private final int capacity;
public FIFOCache(int capacity) {
this.capacity = capacity;
}
public Object get(String key) {
//
Value value = CACHE.get(key);
if (value == null) {
return null;
}
//如果不包含过期时间
long expired = value.expired;
long now = System.nanoTime();
//已过期
if (expired > 0 && expired <= now) {
CACHE.remove(key);
EXPIRED.remove(expired);
return null;
}
return value.value;
}
public void put(String key,Object value) {
put(key,value,-1);
}
public void put(String key,Object value,int seconds) {
//如果容量不足,移除过期数据
if (capacity < CACHE.size()) {
long now = System.nanoTime();
//有过期的,全部移除
Iterator<Long> iterator = EXPIRED.keySet().iterator();
while (iterator.hasNext()) {
long _key = iterator.next();
//如果已过期,或者容量仍然溢出,则删除
if (_key > now) {
break;
}
//一次移除所有过期key
String _value = EXPIRED.get(_key);
CACHE.remove(_value);
iterator.remove();
}
}
//如果仍然容量不足,则移除最早访问的数据
if (capacity < CACHE.size()) {
Iterator<String> iterator = CACHE.keySet().iterator();
while (iterator.hasNext() && capacity < CACHE.size()) {
String _key = iterator.next();
Value _value = CACHE.get(_key);
long expired = _value.expired;
if (expired > 0) {
EXPIRED.remove(expired);
}
iterator.remove();
}
}
//如果此key已存在,移除旧数据
Value current = CACHE.remove(key);
if (current != null && current.expired > 0) {
EXPIRED.remove(current.expired);
}
//如果指定了过期时间
if(seconds > 0) {
long expireTime = expiredTime(seconds);
EXPIRED.put(expireTime,key);
CACHE.put(key,new Value(expireTime,value));
} else {
CACHE.put(key,new Value(-1,value));
}
}
private long expiredTime(int expired) {
return System.nanoTime() + TimeUnit.SECONDS.toNanos(expired);
}
public void remove(String key) {
Value value = CACHE.remove(key);
if(value == null) {
return;
}
long expired = value.expired;
if (expired > 0) {
EXPIRED.remove(expired);
}
}
class Value {
long expired; //过期时间,纳秒
Object value;
Value(long expired,Object value) {
this.expired = expired;
this.value = value;
}
}
}
4.2 LFU算法及实现
最近最不常用,当缓存容量满时,移除访问次数最少的元素,如果访问次数相同的元素有多个,则移除最久访问的那个。设计要求参见leetcode 460( LFU Cache)
public class LFUCache {
//主要容器,用于保存k-v
private Map<String, Object> keyToValue = new HashMap<>();
//记录每个k被访问的次数
private Map<String, Integer> keyToCount = new HashMap<>();
//访问相同次数的key列表,按照访问次数排序,value为相同访问次数到key列表。
private TreeMap<Integer, LinkedHashSet<String>> countToLRUKeys = new TreeMap<>();
private int capacity;
public LFUCache(int capacity) {
this.capacity = capacity;
//初始化,默认访问1次,主要是解决下文
}
public Object get(String key) {
if (!keyToValue.containsKey(key)) {
return null;
}
touch(key);
return keyToValue.get(key);
}
/**
* 如果一个key被访问,应该将其访问次数调整。
* @param key
*/
private void touch(String key) {
int count = keyToCount.get(key);
keyToCount.put(key, count + 1);//访问次数增加
//从原有访问次数统计列表中移除
countToLRUKeys.get(count).remove(key);
//如果符合最少调用次数到key统计列表为空,则移除此调用次数到统计
if (countToLRUKeys.get(count).size() == 0) {
countToLRUKeys.remove(count);
}
//然后将此key的统计信息加入到管理列表中
LinkedHashSet<String> countKeys = countToLRUKeys.get(count + 1);
if (countKeys == null) {
countKeys = new LinkedHashSet<>();
countToLRUKeys.put(count + 1,countKeys);
}
countKeys.add(key);
}
public void put(String key, Object value) {
if (capacity <= 0) {
return;
}
if (keyToValue.containsKey(key)) {
keyToValue.put(key, value);
touch(key);
return;
}
//容量超额之后,移除访问次数最少的元素
if (keyToValue.size() >= capacity) {
Map.Entry<Integer,LinkedHashSet<String>> entry = countToLRUKeys.firstEntry();
Iterator<String> it = entry.getValue().iterator();
String evictKey = it.next();
it.remove();
if (!it.hasNext()) {
countToLRUKeys.remove(entry.getKey());
}
keyToCount.remove(evictKey);
keyToValue.remove(evictKey);
}
keyToValue.put(key, value);
keyToCount.put(key, 1);
LinkedHashSet<String> keys = countToLRUKeys.get(1);
if (keys == null) {
keys = new LinkedHashSet<>();
countToLRUKeys.put(1,keys);
}
keys.add(key);
}
}
4.3 HashMap数据结构及源码解析
HashMap数据结构及主要方法解析,我推荐下面这位兄弟的几篇博客,他已经解释的很详细了,所以我就不再转述。
- HashMap-----数据结构、常量、成员变量、构造方法
- HashMap-----tableSizeFor()
- HashMap-----resize()
- HashMap-----put()
- HashMap-----get(key)、containsKey(key)
关于HashMap相关知识点我记录几点我需要注意的:
-
白话数据结构及查找。
HashMap是一个数组结构,数组结构中每一个元素(即Bucket)是链表结构。每一个Bucket有着不同的key产出的相同的hashcode,不同的key-value存储在这个链表结构中。当查询时,先拿着key产出hashcode,找到对应的bucket,然后去比较key,找到对应的value。
-
链表扩容。
-
新增元素时,先判断链表是不是空,如果是空,则初始化。所以很多时候怀疑是懒加载实现。
-
链表扩容里面有几个参数:
DEFAULT_INITIAL_CAPACITY = 1 << 4(桶个数16)默认容量
DEFAULT_LOAD_FACTOR = 0.75f(负载因子0.75)
TREEIFY_THRESHOLD = 8(树化阈值8)由链表转换成红黑树
MIN_TREEIFY_CAPACITY = 64(树化要求的最少哈希表元素数量)最小红黑树的容量
UNTREEIFY_THRESHOLD = 6(解除树化的阈值,在resize阶段)由红黑树转换成链表
MAXIMUM_CAPACITY = 1 << 30 (最大的容量值230)1). 当判断Bucket个数(容量)时,先判断了当前容量是否需要扩容,是否能扩容。
2). 如果需要且能扩容,则扩容1倍。比如默认16个Bucket,当第13>16*0.75=12个节点需要加入的时候,先完成扩容变为可以装32个Bucket后,才将第13个节点加至队尾。
3). 当每个Bucket中的Map中的元素达到树化阈值=8时,此时这个Bucket的结构从链表结构变为树结构存储。当然此时它的容量最小都是64,即每个Bucket至少能存64个元素。当每个Bucket中Map中的元素元素减少,由大于8个减少至6个时,又由树结构转换成链表结构存储。
4). 当达到最大容量,不能再扩容时,就依据LRU算法,去掉最近最少使用的节点,为新节点腾坑。
- 容量 ,必须是2的指数幂。
tableSizeFor(initialCapacity): 将传入的initialCapacity做计算,返回一个大于等于initialCapacity的最小2的幂次方。(就是不管你传入的初始化Hash桶的长度参数为多少,最后通过这个方法会将Hash表的初始化长度改为2的幂次方 )例如:你传入的initialCapacity=6,计算出来的结果就为8。传入10,结果就是16.
至于原因为何我没看懂。具体可以去根据这篇文章分析:HashMap-----tableSizeFor()这里可以解释一下为什么要求table的长度为2的幂
n为2的幂,那么化成二进制就是100…00,减一之后成为0111…11
对于小于n-1的hash值,索引位置就是hash,大于n-1的就是取模,这样在获取table索引可以提高&运算的速度且最后一位为1,这样保证散列的均匀性。
本文有参考:
https://www.cnblogs.com/wyq178/p/9976815.html
https://www.cnblogs.com/kyoner/p/11179766.html
https://www.jianshu.com/p/a8a012195385
https://juejin.im/post/5d244abfe51d454fa33b1953
https://blog.csdn.net/meng_lemon/article/details/88857670
对以上文章的作者表示感谢。