淘汰数据算法
LFU和LRU
淘汰策略是影响缓存命中率的因素之一,常见的淘汰数据算法有LFU(Least Frequency Used,最近不经常使用)和LRU(Least Recently Used,最近最少使用)。
- LFU优先淘汰掉最不经常使用的数据,需要维护一个表示使用频率的字段。缺点是(1)维护使用频率的字段会占据一定的空间;(2)无法合理的更新新出现的热点数据,旧的热点数据存储在缓存中不容易被淘汰
- LRU优先淘汰掉最久未访问到的数据,缺点是不能很好地应对偶然的突发流量。例如一个热点数据在前一段时间经常访问,但在此时有很多冷门数据进入缓存,则会将热点数据淘汰出缓存
W-TinyLFU
Caffine使用了W-TinyLFU 算法,解决了 LRU 和LFU上述的缺点。W-TinyLFU的基础是TinyLFU,TinyLFU主要做了两件事:
- 采用Count-Min Sketch算法降低频率信息带来的内存消耗
- 维护一个PK机制保障新上的热点数据能够进入缓存,当有新的记录插入时,可以让它跟老的记录进行PK,输者就会被淘汰,这样一些老的、不再需要的数据就会被淘汰
Count-Min Sketch
Count-Min Sketch是一个用来计数的算法,在数据大小比较多大时,可以通过牺牲准确性减少数据的存储空间。它主要包含三个参数:
- Hash哈希函数的数量:
- 计数表格列的数量:
- 内存中使用的空间:
Count-Min Sketch只需要个空间大小就可以统计每个数据出现的次数,相比每一个数据维护一个频率值,空间节省了很多。但是Count-Min Sketch计算出来的频率值是某个数据出现的最大次数,而不是准确次数。
初始化出现次数算法步骤:
- 先初始化二维数组,大小为,值全部初始化为0
- 第i个hash函数当作行值i,该函数计算好的hash值模m当作列值j
- 二维数组第i行第j列的值加1
计算最大出现次数算法步骤:
- 遍历所有的hash函数,第i个hash函数当作行值i,该函数计算好的hash值当作列值j
- 比较全部(i,j)位置上的值,取最小值,该值即为数据出现的最大次数
实例:
三个hash函数(1); (2); (3)。表格列的数量为5,即k=3,m=5,初始化二维表格如下:
插入字母B,则,分别对m=5取模,得到三组值(h1, 1)、(h2, 3)、(h3, 4),对应的二维数组均加1,则二维表格变为:
当计算字母B的出现的最大频率时,先计算三个hash值,即$h1(B) =66;h2(B)=68;h3(B)=264,分别对m=5取模,得到三组值(h1, 1)=1、(h2, 3)=1、(h3, 4)=1,取最小值为1,则B出现的最大次数为1。
Caffeine实现
计算频率
Caffine对Count-Min Sketch的实现在FrequencySketch类中,Caffine对该算法进行了优化,其不再使用二维数组,而只使用一个long类型的一维数组。且Caffine认为缓存的访问频率不需要特别大,只需要15就足够,一般认为达到15次的频率算是很高了,因此只需要4bit就可以表示,一个long有64bit,可以存储16个这样的统计数,这也使得存储效率提高了16倍。当频率超过15次之后,Caffine还有频率衰退减半的操作。
FrequencySketch中的属性以及初始化
int sampleSize;
int tableMask;
long[] table;
int size;
public void ensureCapacity(@NonNegative long maximumSize) {
// 判断输入参数是否合法
requireArgument(maximumSize >= 0);
int maximum = (int) Math.min(maximumSize, Integer.MAX_VALUE >>> 1);
if ((table != null) && (table.length >= maximum)) {
return;
}
// 初始化数组,数组长度为大于maximum的最小的2的整次幂
// 2的整次幂方便通过&操作来模拟取余操作
table = new long[(maximum == 0) ? 1 : ceilingNextPowerOfTwo(maximum)];
tableMask = Math.max(0, table.length - 1);
sampleSize = (maximumSize == 0) ? 10 : (10 * maximum);
if (sampleSize <= 0) {
sampleSize = Integer.MAX_VALUE;
}
size = 0;
}
static int ceilingNextPowerOfTwo(int x) {
// 左移得到大于x的最小的2的整次幂
return 1 << (Integer.SIZE - Integer.numberOfLeadingZeros(x - 1));
}
increment()方法
increment()方法用于增加数据的频率值,当频率不超过15时,则增加频率值;当存放的数据数量等于预设值的10倍(上诉参数中size == sampleSize)时,则对元素的频率进行向下采样,这样实现了一个频率减小的机制,允许过期的长期的数据退出数组
public void increment(@NonNull E e) {
// 检查table频率数组是否为null
if (isNotInitialized()) {
return;
}
// 使用spread()方法再次哈希e的hash值
int hash = spread(e.hashCode());
// (1)64位中每4位表示一个频率值,即一共16个频率值
// (2)16个频率值又连续4个一组表示一个数据的频率,即一共4组
// (3)对照上诉Count-Min Sketch过程,一个64位相当于是一个4*4的二维数组
// start就是用来定位是属于哪一组频率值的
// hash&3只能得到二进制00,01,10,11四种取值,再左移2位只能得到0,4,8,12四个值
int start = (hash & 3) << 2;
// 得到四个table[]数组的坐标
int index0 = indexOf(hash, 0);
int index1 = indexOf(hash, 1);
int index2 = indexOf(hash, 2);
int index3 = indexOf(hash, 3);
// 每一组频率值中有4个,判断在4个数据中同一组每一个是否能增加频率
// 一个记录的最大频率值为15
boolean added = incrementAt(index0, start);
added |= incrementAt(index1, start + 1);
added |= incrementAt(index2, start + 2);
added |= incrementAt(index3, start + 3);
// size变量就是所有记录的数量,即每个记录加频率1,这个size都会加1
// sampleSize为初始化数组值得10倍
// 当达到阈值时,所有的频率统计除以2
if (added && (++size == sampleSize)) {
reset();
}
}
// 对给定的哈希代码应用补充的哈希函数,以防止出现质量差的哈希函数。
int spread(int x) {
x = ((x >>> 16) ^ x) * 0x45d9f3b;
x = ((x >>> 16) ^ x) * 0x45d9f3b;
return (x >>> 16) ^ x;
}
static final long[] SEED = new long[] {
0xc3a5c85c97cb3127L, 0xb492b66fbe98f273L, 0x9ae16a3b2f90404fL, 0xcbf29ce484222325L};
int indexOf(int item, int i) {
// 种子乘以item
long hash = SEED[i] * item;
// hash右移32位,将高32位放置再低32位,并将高32位置0
// 即低32位+高32位结果放在低32位上,高32位不变
hash += hash >>> 32;
// 舍弃高32位,即只保留hash值的高32+低32的结果
// tableMask为2的整次幂-1,其二进制表示为前面全为0,后面全为1,例如000111
// 两者取余可以得到一个小于tableMask的整数,即在数组中的位置
return ((int) hash) & tableMask;
}
boolean incrementAt(int i, int j) {
// j表示属于哪一组频率值的,左移两位相当于乘以4,即得到在64位bit中的位置
// 例如j的取值位0,4,8,12对应的offset分别为0,16,32,48
int offset = j << 2;
// 0xfL表示15,即频率的阈值
// mask表示将15左移offset位,即将1111左移offset位
// mask中的1可以和offset,offset+1,offset+2,offset+3位重合
long mask = (0xfL << offset);
// table[i] & mask将table[i]的64位bit中的[offset,offset+4]这4位置1,其他位置0
// mask是[offset,offset+4]这4位为1,其他为0的数
// 即可判断table[i] & mask是否等于15
if ((table[i] & mask) != mask) {
// 如果未达到阈值,则+1
table[i] += (1L << offset);
return true;
}
return false;
}
上诉过程如图所示:
reset()方法
reset()方法用于将所有记录的频率值除以2,可以让缓存保持“新鲜”,剔除掉过往频率很高但之后不经常使用的缓存。
void reset() {
int count = 0;
// 遍历table中的每一个元素
for (int i = 0; i < table.length; i++) {
// 获取
count += Long.bitCount(table[i] & ONE_MASK);
table[i] = (table[i] >>> 1) & RESET_MASK;
}
size = (size >>> 1) - (count >>> 2);
}
frequency()方法
frequency()方法可以获取某个值的频率,该过程和上诉increment()类似
public int frequency(@NonNull E e) {
if (isNotInitialized()) {
return 0;
}
// 2次hash
int hash = spread(e.hashCode());
// 获取哪一组频率值
int start = (hash & 3) << 2;
int frequency = Integer.MAX_VALUE;
// 遍历该组频率值下的4个频率,并取最小值
for (int i = 0; i < 4; i++) {
int index = indexOf(hash, i);
int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
frequency = Math.min(frequency, count);
}
return frequency;
}
使用Window
Caffeine通过测试发现TinyLFU在面对突发性的稀疏流量(sparse bursts)时表现很差,因为新的记录(new items)还没来得及建立足够的频率就被剔除出去了,这就使得命中率下降。于是Caffine在TinyLFU的基础上增加了一个Window Cache。
W-TinyLFU主要包含两个缓存模块,主缓存是SLRU(Segmented LRU,即分段LRU),SLRU包括一个protected和一个probation的缓存区。另一个缓存区是Window Cache,当有新的记录插入时,会先存入WindowCache,这样就可以避免sparse bursts问题了。
当window区满了,就会根据LRU把candidate(即淘汰出来的元素)放到probation区,如果probation区也满了,就把candidate和probation将要淘汰的元素victim,两个进行“PK”,胜者留在probation,输者就要被淘汰了。
Window区和Segemented区的比例会根据统计数据去动态调整,如果应用程序的缓存随着时间变化比较快的话,增加window区的比例可以提高命中率;如果缓存都是比较固定不变的话,增加Main Cache区(protected区 +probation区)的比例会有较好的效果。
W-TinyLFU数据结构
基本的数据结构
// 最大的个数限制
long maximum;
// 当前的个数
long weightedSize;
// window区的最大限制
long windowMaximum;
// window区当前的个数
long windowWeightedSize;
// protected区的最大限制
long mainProtectedMaximum;
// protected区当前的个数
long mainProtectedWeightedSize;
// 下一次需要调整的大小(还需要进一步计算)
double stepSize;
// window区需要调整的大小
long adjustment;
// 命中计数
int hitsInSample;
// 不命中的计数
int missesInSample;
// 上一次的缓存命中率
double previousSampleHitRate;
// 频率计算和维护类
final FrequencySketch<K> sketch;
// window区队列
final AccessOrderDeque<Node<K, V>> accessOrderWindowDeque;
// probation区队列
final AccessOrderDeque<Node<K, V>> accessOrderProbationDeque;
// protected区队列
final AccessOrderDeque<Node<K, V>> accessOrderProtectedDeque;
各个分区的默认比例
// 主空间专用的最大加权容量的初始百分比。
static final double PERCENT_MAIN = 0.99d;
// protected区最大加权容量的初始百分比。
static final double PERCENT_MAIN_PROTECTED = 0.80d;
// 与上次命中率之差的阈值
static final double HILL_CLIMBER_RESTART_THRESHOLD = 0.05d;
// 步长(调整)的大小(跟最大值maximum的比例)
static final double HILL_CLIMBER_STEP_PERCENT = 0.0625d;
// 步长的衰减比例
static final double HILL_CLIMBER_STEP_DECAY_RATE = 0.98d;
evictEntries()方法
evictEntries()方法用于当缓存超过最大值时,淘汰数据
void evictEntries() {
if (!evicts()) {
return;
}
// 淘汰window区的记录,candidates表示淘汰的个数
int candidates = evictFromWindow();
// 淘汰Main区的记录
evictFromMain(candidates);
}
int evictFromWindow() {
int candidates = 0;
// window queue的头部节点
Node<K, V> node = accessOrderWindowDeque().peek();
// 判断当前window区数量是否大于window区设置的最大值
while (windowWeightedSize() > windowMaximum()) {
// The pending operations will adjust the size to reflect the correct weight
if (node == null) {
break;
}
// 下一个节点
Node<K, V> next = node.getNextInAccessOrder();
if (node.getWeight() != 0) {
// 把node定位在probation区
node.makeMainProbation();
// 从window区去掉
accessOrderWindowDeque().remove(node);
// 加入到probation queue,相当于把节点移动到probation区(晋升了)
accessOrderProbationDeque().add(node);
candidates++;
// 移除节点后,调整windowWeightedSize
setWindowWeightedSize(windowWeightedSize() - node.getPolicyWeight());
}
// 处理下一个节点
node = next;
}
return candidates;
}
void evictFromMain(int candidates) {
int victimQueue = PROBATION;
// victim是probation queue的头部
Node<K, V> victim = accessOrderProbationDeque().peekFirst();
// candidate是probation queue的尾部,是刚从window晋升来的
Node<K, V> candidate = accessOrderProbationDeque().peekLast();
// 判断当前的个数是否大于最大的个数
while (weightedSize() > maximum()) {
if (candidates == 0) {
candidate = null;
}
// 如果两个都为null,从protected和window中取数据
if ((candidate == null) && (victim == null)) {
if (victimQueue == PROBATION) {
victim = accessOrderProtectedDeque().peekFirst();
victimQueue = PROTECTED;
continue;
} else if (victimQueue == PROTECTED) {
victim = accessOrderWindowDeque().peekFirst();
victimQueue = WINDOW;
continue;
}
break;
}
// victim权重为0,则取victim的下一个节点
if ((victim != null) && (victim.getPolicyWeight() == 0)) {
victim = victim.getNextInAccessOrder();
continue;
} else if ((candidate != null) && (candidate.getPolicyWeight() == 0)) {
// candidate权重为0,取candidate上一个节点
candidate = candidate.getPreviousInAccessOrder();
candidates--;
continue;
}
// 如果只有一个数据存在,淘汰另一个数据
if (victim == null) {
@SuppressWarnings("NullAway")
Node<K, V> previous = candidate.getPreviousInAccessOrder();
Node<K, V> evict = candidate;
candidate = previous;
candidates--;
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
} else if (candidate == null) {
Node<K, V> evict = victim;
victim = victim.getNextInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
}
// 如果该数据已经被垃圾回收器回收,则立即驱逐
K victimKey = victim.getKey();
K candidateKey = candidate.getKey();
if (victimKey == null) {
@NonNull Node<K, V> evict = victim;
victim = victim.getNextInAccessOrder();
evictEntry(evict, RemovalCause.COLLECTED, 0L);
continue;
} else if (candidateKey == null) {
candidates--;
@NonNull Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.COLLECTED, 0L);
continue;
}
// 如果候选节点的权重大于最大权重,淘汰该数据
if (candidate.getPolicyWeight() > maximum()) {
candidates--;
Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
}
// 淘汰频率较低的数据
candidates--;
if (admit(candidateKey, victimKey)) {
Node<K, V> evict = victim;
victim = victim.getNextInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
candidate = candidate.getPreviousInAccessOrder();
} else {
Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
}
}
}
// 比较candidate和victim的频率值
boolean admit(K candidateKey, K victimKey) {
// victim的频率值
int victimFreq = frequencySketch().frequency(victimKey);
// candidate的频率值
int candidateFreq = frequencySketch().frequency(candidateKey);
if (candidateFreq > victimFreq) {
return true;
} else if (candidateFreq <= 5) {
return false;
}
int random = ThreadLocalRandom.current().nextInt();
return ((random & 127) == 0);
}
climb()方法
climb()方法主要用来调整window size的,使得Caffine可以使用当前的应用类型
// 调整驱逐策略,实现最佳的最近和频率淘汰配置
void climb() {
if (!evicts()) {
return;
}
// 调整window窗口大小
determineAdjustment();
demoteFromMainProtected();
long amount = adjustment();
if (amount == 0) {
return;
} else if (amount > 0) {
increaseWindow();
} else {
decreaseWindow();
}
}
// 调整window窗口大小
void determineAdjustment() {
// 如果frequencySketch还没初始化,则返回
if (frequencySketch().isNotInitialized()) {
setPreviousSampleHitRate(0.0);
setMissesInSample(0);
setHitsInSample(0);
return;
}
// 总请求量 = 命中次数 + 不命中次数
int requestCount = hitsInSample() + missesInSample();
// sampleSize = 10 * maximum,用sampleSize表示缓存是否使用频繁
if (requestCount < frequencySketch().sampleSize) {
return;
}
// 命中率的公式 = 命中 / 总请求
double hitRate = (double) hitsInSample() / requestCount;
// 本次命中率和上次命中率的差值
double hitRateChange = hitRate - previousSampleHitRate();
// 本次调整的大小,是由命中率的差值和上次的stepSize决定的
double amount = (hitRateChange >= 0) ? stepSize() : -stepSize();
// 下次的调整大小:
// (1)如果命中率的之差大于0.05,则重置为0.065 * maximum
// (2)否则按照0.98来进行衰减,即0.98 * amount
double nextStepSize = (Math.abs(hitRateChange) >= HILL_CLIMBER_RESTART_THRESHOLD)
? HILL_CLIMBER_STEP_PERCENT * maximum() * (amount >= 0 ? 1 : -1)
: HILL_CLIMBER_STEP_DECAY_RATE * amount;
setPreviousSampleHitRate(hitRate);
setAdjustment((long) amount);
setStepSize(nextStepSize);
setMissesInSample(0);
setHitsInSample(0);
}
static final int QUEUE_TRANSFER_THRESHOLD = 1_000;
// 主区超过设定的最大值,则将节点从protected转移到probation
void demoteFromMainProtected() {
// protected区的阈值和当前大小
long mainProtectedMaximum = mainProtectedMaximum();
long mainProtectedWeightedSize = mainProtectedWeightedSize();
if (mainProtectedWeightedSize <= mainProtectedMaximum) {
return;
}
for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
if (mainProtectedWeightedSize <= mainProtectedMaximum) {
break;
}
// protected区的节点
Node<K, V> demoted = accessOrderProtectedDeque().poll();
if (demoted == null) {
break;
}
// 和window节点加入到probation节点类似
// 先给节点打上probation的标签
demoted.makeMainProbation();
// 将该节点加到probation区域
accessOrderProbationDeque().add(demoted);
// 减去对应的权重
mainProtectedWeightedSize -= demoted.getPolicyWeight();
}
setMainProtectedWeightedSize(mainProtectedWeightedSize);
}
// 缩小mainCahce的Protected大小并增加windowCach的大小
void increaseWindow() {
if (mainProtectedMaximum() == 0) {
return;
}
// 获取调整值和protected区数据量两者的最小值
long quota = Math.min(adjustment(), mainProtectedMaximum());
// 减小protected区域的大小
setMainProtectedMaximum(mainProtectedMaximum() - quota);
// 增大window区域的大小
setWindowMaximum(windowMaximum() + quota);
// 主区超过设定的最大值,则将节点从protected转移到probation
demoteFromMainProtected();
for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
Node<K, V> candidate = accessOrderProbationDeque().peek();
boolean probation = true;
if ((candidate == null) || (quota < candidate.getPolicyWeight())) {
candidate = accessOrderProtectedDeque().peek();
probation = false;
}
if (candidate == null) {
break;
}
int weight = candidate.getPolicyWeight();
if (quota < weight) {
break;
}
quota -= weight;
if (probation) {
accessOrderProbationDeque().remove(candidate);
} else {
setMainProtectedWeightedSize(mainProtectedWeightedSize() - weight);
accessOrderProtectedDeque().remove(candidate);
}
setWindowWeightedSize(windowWeightedSize() + weight);
accessOrderWindowDeque().add(candidate);
candidate.makeWindow();
}
setMainProtectedMaximum(mainProtectedMaximum() + quota);
setWindowMaximum(windowMaximum() - quota);
setAdjustment(quota);
}
// 减小WindowCache的大小并增加mainCache的Protected大小
void decreaseWindow() {
if (windowMaximum() <= 1) {
return;
}
long quota = Math.min(-adjustment(), Math.max(0, windowMaximum() - 1));
setMainProtectedMaximum(mainProtectedMaximum() + quota);
setWindowMaximum(windowMaximum() - quota);
for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
Node<K, V> candidate = accessOrderWindowDeque().peek();
if (candidate == null) {
break;
}
int weight = candidate.getPolicyWeight();
if (quota < weight) {
break;
}
quota -= weight;
setMainProtectedWeightedSize(mainProtectedWeightedSize() + weight);
setWindowWeightedSize(windowWeightedSize() - weight);
accessOrderWindowDeque().remove(candidate);
accessOrderProbationDeque().add(candidate);
candidate.makeMainProbation();
}
setMainProtectedMaximum(mainProtectedMaximum() - quota);
setWindowMaximum(windowMaximum() + quota);
setAdjustment(-quota);
}