缓存淘汰算法 LFU 最少使用频次 - zxlrise/cache GitHub Wiki
缓存淘汰算法 LFU 最少使用频次
LFU(Least Frequently Used)即最近最不常用,看名字就知道是个基于访问频次的一种算法。 LRU是基于时间的,会将时间上最不常访问的数据给淘汰,在算法表现上是放到列表的顶部; LFU为将频率上最不常访问的数据淘汰。 既然是基于频率的,就需要有存储每个数据访问的次数。 从存储空间上,较LRU会多出一些持有计数的空间。
如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小。
O(N) 的删除 为了能够淘汰最少使用的数据,个人第一直觉就是直接一个 HashMap<String, Interger>, String 对应 key 信息,Integer 对应次数。 每次访问到就去+1,设置和读取的时间复杂度都是 O(1);不过删除就比较麻烦了,需要全部遍历对比,时间复杂度为 O(N); O(logN)的删除 另外还有一种实现思路就是利用小顶堆+hashmap,小顶堆插入、删除操作都能达到O(logn)时间复杂度,因此效率相比第一种实现方法更加高效。比如 TreeMap。 O(1)的删除 我们要想实现 O(1) 的操作,肯定离不开 Hash 的操作,我们 O(N) 的删除中就实现了 O(1) 的 put/get。 但是删除性能比较差,因为需要寻找次数最少的比较耗时。
private Map<K, Node> map; // key和数据的映射
private Map<Integer, LinkedHashSet<Node>> freqMap; // 数据频率和对应数据组成的链表
class Node {
K key;
V value;
int frequency = 1;
}
我们使用双 Hash 基本上就可以解决这个问题了。 map 中存放 key 和节点之间的映射关系。put/get 肯定都是 O(1) 的。 key 映射的 node 中,有对应的频率 frequency 信息;相同的频率都会通过 freqMap 进行关联,可以快速通过频率获取对应的链表。 删除也变得非常简单了,基本可以确定需要删除的最低频次是1,如果不是最多从 1...n 开始循环,最小 freq 选择链表的第一个元素开始删除即可。 至于链表本身的优先级,那么可以根据 FIFO,或者其他你喜欢的方式。
public class CacheEvictLfu<K,V> extends AbstractCacheEvict<K,V> {
private static final Log log = LogFactory.getLog(CacheEvictLfu.class);
/**
* key 映射信息
* @since 0.0.14
*/
private final Map<K, FreqNode<K,V>> keyMap;
/**
* 频率 map
* @since 0.0.14
*/
private final Map<Integer, LinkedHashSet<FreqNode<K,V>>> freqMap;
/**
*
* 最小频率
* @since 0.0.14
*/
private int minFreq;
public CacheEvictLfu() {
this.keyMap = new HashMap<>();
this.freqMap = new HashMap<>();
this.minFreq = 1;
}
}
节点定义
- FreqNode.java
public class FreqNode<K,V> {
/**
* 键
* @since 0.0.14
*/
private K key;
/**
* 值
* @since 0.0.14
*/
private V value = null;
/**
* 频率
* @since 0.0.14
*/
private int frequency = 1;
public FreqNode(K key) {
this.key = key;
}
//fluent getter & setter
// toString() equals() hashCode()
}
/**
* 移除元素
*
* 1. 从 freqMap 中移除
* 2. 从 keyMap 中移除
* 3. 更新 minFreq 信息
*
* @param key 元素
* @since 0.0.14
*/
@Override
public void removeKey(final K key) {
FreqNode<K,V> freqNode = this.keyMap.remove(key);
//1. 根据 key 获取频率
int freq = freqNode.frequency();
LinkedHashSet<FreqNode<K,V>> set = this.freqMap.get(freq);
//2. 移除频率中对应的节点
set.remove(freqNode);
log.debug("freq={} 移除元素节点:{}", freq, freqNode);
//3. 更新 minFreq
if(CollectionUtil.isEmpty(set) && minFreq == freq) {
minFreq--;
log.debug("minFreq 降低为:{}", minFreq);
}
}
/**
* 更新元素,更新 minFreq 信息
* @param key 元素
* @since 0.0.14
*/
@Override
public void updateKey(final K key) {
FreqNode<K,V> freqNode = keyMap.get(key);
//1. 已经存在
if(ObjectUtil.isNotNull(freqNode)) {
//1.1 移除原始的节点信息
int frequency = freqNode.frequency();
LinkedHashSet<FreqNode<K,V>> oldSet = freqMap.get(frequency);
oldSet.remove(freqNode);
//1.2 更新最小数据频率
if (minFreq == frequency && oldSet.isEmpty()) {
minFreq++;
log.debug("minFreq 增加为:{}", minFreq);
}
//1.3 更新频率信息
frequency++;
freqNode.frequency(frequency);
//1.4 放入新的集合
this.addToFreqMap(frequency, freqNode);
} else {
//2. 不存在
//2.1 构建新的元素
FreqNode<K,V> newNode = new FreqNode<>(key);
//2.2 固定放入到频率为1的列表中
this.addToFreqMap(1, newNode);
//2.3 更新 minFreq 信息
this.minFreq = 1;
//2.4 添加到 keyMap
this.keyMap.put(key, newNode);
}
}
/**
* 加入到频率 MAP
* @param frequency 频率
* @param freqNode 节点
*/
private void addToFreqMap(final int frequency, FreqNode<K,V> freqNode) {
LinkedHashSet<FreqNode<K,V>> set = freqMap.get(frequency);
if (set == null) {
set = new LinkedHashSet<>();
}
set.add(freqNode);
freqMap.put(frequency, set);
log.debug("freq={} 添加元素节点:{}", frequency, freqNode);
}
@Override
protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) {
ICacheEntry<K, V> result = null;
final ICache<K,V> cache = context.cache();
// 超过限制,移除频次最低的元素
if(cache.size() >= context.size()) {
FreqNode<K,V> evictNode = this.getMinFreqNode();
K evictKey = evictNode.key();
V evictValue = cache.remove(evictKey);
log.debug("淘汰最小频率信息, key: {}, value: {}, freq: {}",
evictKey, evictValue, evictNode.frequency());
result = new CacheEntry<>(evictKey, evictValue);
}
return result;
}
/**
* 获取最小频率的节点
*
* @return 结果
* @since 0.0.14
*/
private FreqNode<K, V> getMinFreqNode() {
LinkedHashSet<FreqNode<K,V>> set = freqMap.get(minFreq);
if(CollectionUtil.isNotEmpty(set)) {
return set.iterator().next();
}
throw new CacheRuntimeException("未发现最小频率的 Key");
}
代码
ICache<String, String> cache = CacheBs.<String,String>newInstance()
.size(3)
.evict(CacheEvicts.<String, String>lfu())
.build();
cache.put("A", "hello");
cache.put("B", "world");
cache.put("C", "FIFO");
// 访问一次A
cache.get("A");
cache.put("D", "LRU");
Assert.assertEquals(3, cache.size());
System.out.println(cache.keySet());
日志
[DEBUG] [2020-10-03 21:23:43.722] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素节点:FreqNode{key=A, value=null, frequency=1}
[DEBUG] [2020-10-03 21:23:43.723] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素节点:FreqNode{key=B, value=null, frequency=1}
[DEBUG] [2020-10-03 21:23:43.725] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素节点:FreqNode{key=C, value=null, frequency=1}
[DEBUG] [2020-10-03 21:23:43.727] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=2 添加元素节点:FreqNode{key=A, value=null, frequency=2}
[DEBUG] [2020-10-03 21:23:43.728] [main] [c.g.h.c.c.s.e.CacheEvictLfu.doEvict] - 淘汰最小频率信息, key: B, value: world, freq: 1
[DEBUG] [2020-10-03 21:23:43.731] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict
[DEBUG] [2020-10-03 21:23:43.732] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素节点:FreqNode{key=D, value=null, frequency=1}
[D, A, C]
LFU是基于访问频次的模式,而LRU是基于访问时间的模式。
在数据访问符合正态分布时,相比于LRU算法,LFU算法的缓存命中率会高一些。
- LFU的复杂度要比LRU更高一些。
- 需要维护数据的访问频次,每次访问都需要更新。
- 早期的数据相比于后期的数据更容易被缓存下来,导致后期的数据很难被缓存。
- 新加入缓存的数据很容易被剔除,像是缓存的末端发生“抖动”。
不过实际实践中,LFU 的应用场景实际并没有那么广泛。 因为真实的数据都是有倾斜的,热点数据才是常态,所以 LRU 的性能一般情况下优于 LFU。