论文原址:
LRU
LRU(The Least Recently Used)最近最久未使用算法: 如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据 。
优点:实现简单,可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。
缺点:对于热点数据的命中率可能不如LFU。对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,也就是置换出去了热点数据,把这些偶发性数据留下了,从而导致LRU的数据命中率急剧下降。
缺点例子:
访问顺序:a、b、c、a、a、a、a、b、c、d
lru 例子.png
实现
缓存就是Map + 淘汰策略。Map的作用是提供快速访问,淘汰策略是缓存算法的灵魂,决定了命中率的高低。选择链表来记录数据被访问的先后顺序。
链表结构体:
public static class Node<K, V> {
private K key;
private V value;
private Node<K, V> pre, next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
@Override
public String toString() {
return value.toString();
}
}
具体实现:
public class LruSegment<K, V> {
private int capacity;
private Map<K, Node<K, V>> keyArrays;
private Node<K,V> head, tail;
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock writeLock = lock.writeLock();
private Lock readLock = lock.readLock();
public LruSegment(int capacity) {
this.capacity = capacity;
keyArrays = new HashMap<>();
}
public V put(K key, V value) {
Node<K,V> remove = null;
writeLock.lock();
try {
if (keyArrays.containsKey(key)) {
Node<K, V> kvNode = keyArrays.get(key);
kvNode.value = value;
remove(kvNode);
add(kvNode);
} else {
if (keyArrays.size() >= capacity) {
remove = head;
keyArrays.remove(head.key);
remove(head);
}
Node<K, V> kvNode = new Node<>(key, value);
add(kvNode);
keyArrays.put(key, kvNode);
}
}finally {
writeLock.unlock();
}
return remove == null ? null : remove.value;
}
public V get(K key) {
writeLock.lock();
try {
Node<K, V> node = keyArrays.get(key);
if (node == null) return null;
remove(node);
add(node);
return node.value;
} finally {
writeLock.unlock();
}
}
public int size() {
readLock.lock();
try {
return keyArrays.size();
} finally {
readLock.unlock();
}
}
public Set<V> getAllV() {
readLock.lock();
try {
HashSet<V> vs = new HashSet<>();
Node<K, V> node = tail;
while (node != null) {
vs.add(node.value);
node = node.pre;
}
return vs;
} finally {
readLock.unlock();
}
}
private void add(Node<K, V> node) {
if (node == null) {
return;
}
if (head == null) {
head = tail = node;
} else {
tail.next = node;
node.pre = tail;
node.next = null;
tail = node;
}
}
private void remove(Node<K, V> node) {
if (node == null) {
return;
}
if (node.pre == null) {
head = node.next;
} else {
node.pre.next = node.next;
}
if (node.next != null) {
node.next.pre = node.pre;
} else {
tail = node.pre;
}
}
}
提高性能,进行分段:
public class LruCache<K, V> {
private LruSegment<K, V>[] cacheSegments;
public RARLruCache(final int capacity) {
int cores = Runtime.getRuntime().availableProcessors();
int concurrency = cores < 2 ? 2 : cores;
cacheSegments = new LruSegment[concurrency];
int segmentCapacity = capacity / concurrency;
if (capacity % concurrency == 1) segmentCapacity++;
for (int index = 0; index < cacheSegments.length; index++) {
cacheSegments[index] = new LruSegment<>(segmentCapacity);
}
}
public LruCache(final int concurrency, final int maxCapacity) {
cacheSegments = new LruSegment[concurrency];
int segmentCapacity = maxCapacity / concurrency;
if (maxCapacity % concurrency == 1) segmentCapacity++;
for (int index = 0; index < cacheSegments.length; index++) {
cacheSegments[index] = new RARLruSegment<>(segmentCapacity);
}
}
private int segmentIndex(K key) {
int hashCode = Math.abs(key.hashCode() * 31);
return hashCode % cacheSegments.length;
}
private LruSegment<K, V> cache(K key) {
return cacheSegments[segmentIndex(key)];
}
public V get(K key) {
return cache(key).get(key);
}
public V put(K key, V value) {
return cache(key).put(key, value);
}
public Set<V> getAllV() {
final HashSet<V> vs = new HashSet<>();
for (int i = 0; i < cacheSegments.length; i++) {
vs.addAll(cacheSegments[i].getAllV());
}
return vs;
}
public int size() {
int size = 0;
for (LruSegment<K, V> cache : cacheSegments) {
size += cache.size();
}
return size;
}
}
LFU
LRU中,上述过程存在不合理明明a是被频繁访问的数据,最终却被淘汰掉了。所以如果要改进这个算法,我们希望的是能够记录每个元素的访问频率信息,访问频率最低的那个才是最应该被淘汰的那个。
LFU(The Least Frequently Used)最近很少使用算法,与LRU的区别在于LRU是以时间衡量,LFU是以时间段内的次数。如果一个数据在一定时间内被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰时间段内访问次数最低的数据。
优点:对于热点数据命中率更高。 因为是以次数为基准,所以更加准确,自然能有效的保证和提高命中率。
缺点:
- 难以应对突发的稀疏流量、可能存在旧数据长期不被淘汰,会影响某些场景下的命中率(如外卖)
- 需要维护大而复杂的元数据 (频次统计数据等)
工作流程:
首先是访问元素增加元素的访问次数,从而提高元素在队列中的位置,降低淘汰优先级,后面是插入新元素的时候,因为队列已经满了,所以优先淘汰在一定时间间隔内访问频率最低的元素。
TinyLFU
20.webp
TinyLfu主要优化方向:
- 维护一个新鲜度机制(freshness mechanism),来保持历史最近访问且可以移除旧事件(keep the history recent and remove the history old events)
- 减少内存的消耗
Count-Min Sketch 算法
是布隆过滤器的同源的算法。
cmSketch计数法: 创建一个长度为 x 的数组,用来计数,初始化每个元素的计数值为0 (a[x]={0})。给要计数的值计算一个Hash,然后在数组中给这个Hash值对应的位置累加1,但只要是用Hash计算就有存在冲突的可能。
解决冲突: 使用多个数组和多个哈希函数,来计算一个元素对应的数组的位置索引。那么,要查询某个元素的频率时,返回这个元素在不同数组中的计数值中的最小值。这是一个不那么精确地统计方法,但是可以大致的反应访问分布的规律。
Freshness Mechanism
保鲜机制:TinyLFU 还采用了一种基于滑动窗口的时间衰减设计机制,借助于一种简易的 reset 操作,每次添加一条记录到 Sketch 的时候,都会给一个计数器上加 1,当计数器达到 一个预设的采样尺寸(W) 的时候,把所有记录的 Sketch 数值都除以 2,该 reset 操作可以起到衰减的作用。
采样数W越大,截断错误的带来的影响越小
Space Reduction
-
减小了sketch中计数值的尺寸
- 回到Count-Min Sketch 算法中,LFU需要统计每个条数据的访问频率,需要一个int或者long类型来存储次数,但一条缓存数据的访问次数大概率不需要int类型这么大的表示范围来统计。可以假设一个缓存被访问15次已经算是很高的频率,那么只用4个Bit就可以保存这个数据。(2^4=16)。
- 从采样大小W出发,计数值需要占用log(W) bit。
-
减少了sketch分的计数器的数量,引入 Doorkeeper 机制
- 具体由一个常规的Bloom Filter作为拦截
- 如果一个元素,在Doorkeeper中,则直接插入TinyLFU的主结构,否则先插入Doorkeeper;对于数据查询,会将Doorkeeper中的那一个计数值也加到计数值上去
- reset操作会清空Doorkeeper
- 这样对于每个W周期内,都会过滤仅有一次的访问的数据
尽管Doorkeeper需要一些额外的空间,但是相对于在主要结构中节约的空间来说,显得微不足道。
实现
自然TinyLFU核心使用的是cm Sketch算法。每4Bit当做一个计数器Counter,一共需要n/2长度数组来计数。
当数组的一个计数器Counter达到最高的时候会进行&操作,如此时假设一个Counter是4Bit大小,则与0111进行&操作。
//cmSketch封装
const cmDepth = 4
type cmSketch struct {
rows [cmDepth]cmRow
seed [cmDepth]uint64
mask uint64
}
//numCounter - 1 = next2Power() = 0111111(n个1)
//0000,0000|0000,0000|0000,0000
//0000,0000|0000,0000|0000,0000
//0000,0000|0000,0000|0000,0000
//0000,0000|0000,0000|0000,0000
func newCmSketch(numCounters int64) *cmSketch {
if numCounters == 0 {
panic("cmSketch: bad numCounters")
}
numCounters = next2Power(numCounters)
sketch := &cmSketch{mask: uint64(numCounters - 1)}
// Initialize rows of counters and seeds.
source := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < cmDepth; i++ {
sketch.seed[i] = source.Uint64()
sketch.rows[i] = newCmRow(numCounters)
}
return sketch
}
func (s *cmSketch) Increment(hashed uint64) {
for i := range s.rows {
s.rows[i].increment((hashed ^ s.seed[i]) & s.mask)
}
}
// 找到最小的计数值
func (s *cmSketch) Estimate(hashed uint64) int64 {
min := byte(255)
for i := range s.rows {
val := s.rows[i].get((hashed ^ s.seed[i]) & s.mask)
if val < min {
min = val
}
}
return int64(min)
}
// 让所有计数器都减半,保鲜机制
func (s *cmSketch) Reset() {
for _, r := range s.rows {
r.reset()
}
}
// 清空所有计数器
func (s *cmSketch) Clear() {
for _, r := range s.rows {
r.clear()
}
}
附:BloomFilter实现
//根据BloomFilter来思考一下我们需要什么
//一个bit图,n个Hash函数
//一个BitMap的实现
type cmRow []byte //byte = uint8 = 0000,0000 = COUNTER 4BIT = 2 counter
//64 counter
//1 uint8 = 2counter
//32 uint8 = 64 counter
func newCmRow(numCounters int64) cmRow {
return make(cmRow, numCounters/2)
}
func (r cmRow) get(n uint64) byte {
return byte(r[n/2]>>((n&1)*4)) & 0x0f
}
0000,0000|0000,0000| 0000,0000 make([]byte, 3) = 6 counter
func (r cmRow) increment(n uint64) {
//定位到第i个Counter
i := n / 2 //r[i]
//右移距离,偶数为0,奇数为4
s := (n & 1) * 4
//取前4Bit还是后4Bit
v := (r[i] >> s) & 0x0f //0000, 1111
//没有超出最大计数时,计数+1
if v < 15 {
r[i] += 1 << s
}
}
//cmRow 100,
//保鲜
func (r cmRow) reset() {
// 计数减半
for i := range r {
r[i] = (r[i] >> 1) & 0x77 //0111,0111
}
}
func (r cmRow) clear() {
// 清空计数
for i := range r {
r[i] = 0
}
}
//快速计算最接近x的二次幂的算法
//比如x=5,返回8
//x = 110,返回128
//2^n
//1000000 (n个0)
//01111111(n个1) + 1
// x = 1001010 = 1111111 + 1 =10000000
func next2Power(x int64) int64 {
x--
x |= x >> 1
x |= x >> 2
x |= x >> 4
x |= x >> 8
x |= x >> 16
x |= x >> 32
x++
return x
}
总结
优点
- 内存占用减少
- 缓存保鲜机制:解决LFU旧数据问题
缺点
- 只会估算偏大,永远不会偏小:冲突使不同的数据通过函数映射到同一个地址,使该数组的计数偏大
- 对于低频的元素,估算值相对的错误可能会很大:当低频的元素与频率高的元素的哈希值相同时(冲突) ,查找低频元素的频率时输出的是高频元素的频率,对低频元素的估算值影响较大
- 根据实测TinyLFU应对突发的稀疏流量时表现不佳。大概思考一下也可以得知,这些稀疏流量的访问频次不足以让他们在LFU缓存中占据位置,很快就又被淘汰
W-TinyLFU
W-TinyLFU(Window Tiny Least Frequently Used)是对LFU的的优化和加强。主要用来解决稀疏的突发访问元素, 新的突发流量可能会因为无法构建足够的频率数据来保证自己驻留在缓存中。
Window-TinyLFU策略里包含LRU(LRU对于稀疏流量效果很好)和LFU两部分。
19.png
前端的小LRU叫做Window LRU,它的容量只占据1%的总空间。目的就是用来存放短期的突发访问数据。数据首先会进入到 Window LRU, 从 Window LRU 中淘汰后,会进入到过滤器中过滤。
Filter则是Count-Min Sketch 算法,对应上面的TinyLFU。当由W-LRU驱逐过来的数据比Filter要驱逐的数据高频时,这个数据才会被Filter接纳,主要是为了使到达Filter的数据积累一定的访问频率,再进入到后面的缓存段中。
存放主要元素的Segmented LRU(SLRU)是一种LRU的改进,主要把在一个时间窗口内命中至少2次的记录和命中1次的单独存放,这样就可以把短期内较频繁的缓存元素区分开来。
SLRU包含2个固定尺寸的LRU,一个叫Probation段A1,一个叫Protected段A2。新记录总是插入到A1中,当A1的记录被再次访问,就把它移到A2,当A2满了需要驱逐记录时,会把驱逐记录插入到A1中。W-TinyLFU中,SLRU有80%空间被分配给A2段。
所有请求的 key 都会被允许进入窗口缓存,而窗口缓存的记录则有机会被允许进入主缓存。
Caffeine 应用
在Caffeine的实现中,会先创建一个Long类型的数组,数组的大小为 2的幂次大小。Caffeine将64位的Long类型划分为4段,每段16位,用于存储4种hash算法对应的数据访问频率计数。
具体实现: HillClimberWindowTinyLfuPolicy.java
巨人的肩膀
硬核课堂
冬奥会结束,祖国伟大!!!