《Caffeine入门使用》 -> 《Caffeine基础源码解析》 -> 《Caffeine 驱逐算法》
概况
在《Caffeine入门使用》一文中讲解了Caffeine基本使用方法以及功能分类描述和使用示例,在会使用以及了解使用模式的背景下,在《Caffeine基础源码解析》文中对caffeine的主流程进行源码分析,从原理看下Caffeine的设计和实现,从会使用到深理解,本文是Caffeine学习的最后一部分,也是它最重要的一部分,驱逐算法的实现,即W-TinyLFU算法在Caffeine中的实现。
算法
对于缓存组件,驱逐算法基本是标配,它是直接影响缓存组件命中率的重要因素,无论是基于服务的Redis组件提供的多种驱逐策略,还是本地缓存的Guava Cache、Caffeine Cache等本身实现的驱逐算法,基本都在目前流行的页面置换算法范围中,包括FIFO、LRU、LFU等,下面简单介绍下几种算法工作逻辑。
FIFO
FIFO(First in First out)先进先出。可以理解为是一种类似队列的算法实现
- 算法:最先进来的数据,被认为在未来被访问的概率也是最低的,因此,当规定空间用尽且需要放入新数据的时候,会优先淘汰最早进来的数据;
- 优点:最简单、最公平的一种数据淘汰算法,逻辑简单清晰,易于实现;
- 缺点:这种算法逻辑设计所实现的缓存的命中率是比较低的,因为没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉;
下面简单演示了FIFO的工作过程,假设存放元素尺寸是3,且队列已满,放置元素顺序如下图所示,当来了一个新的数据“ldy”后,因为元素数量到达了阈值,则首先要进行太淘汰置换操作,然后加入新元素,操作如图展示:
LRU
LRU(The Least Recently Used)最近最久未使用算法。相比于FIFO算法智能些
- 算法:如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据
- 优点:LRU可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。
- 缺点:对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,也就是置换出去了热点数据,把这些偶发性数据留下了,从而导致LRU的数据命中率急剧下降;频率的存储,需要额外的空间。
下图展示了LRU简单的工作过程,访问时对数据的提前操作,以及数据满且添加新数据的时候淘汰的过程的展示如下:
此处介绍的LRU是有明显的缺点,如上所述,对于偶发性、周期性的数据没有良好的抵抗力,很容易就造成缓存的污染,影响命中率,因此衍生出了很多的LRU算法的变种,用以处理这种偶发冷数据突增的场景,比如:LRU-K、Two Queues等,目的就是当判别数据为偶发或周期的冷数据时,不会存入空间内,从而降低热数据的淘汰率。
下图展示了LRU-K的简单工作过程,简单理解,LRU中的K是指数据被访问K次,传统LRU与此对比则可以认为传统LRU是LRU-1。可以看到LRU-K有两个队列,新来的元素先进入到历史访问队列中,该队列用于记录元素的访问次数,采用的淘汰策略是LRU或者FIFO,当历史队列中的元素访问次数达到K的时候,才会进入缓存队列。
LFU
LFU(The Least Frequently Used)最近很少使用算法,与LRU的区别在于LRU是以时间衡量,LFU是以时间段内的次数
- 算法:如果一个数据在一定时间内被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰时间段内访问次数最低的数据
- 优点:LFU也可以有效的保护缓存,相对场景来讲,比LRU有更好的缓存命中率。因为是以次数为基准,所以更加准确,自然能有效的保证和提高命中率
- 缺点:因为LFU需要记录数据的访问频率,因此需要额外的空间;当访问模式改变的时候,算法命中率会急剧下降,这也是他最大弊端。
下面描述了LFU的简单工作过程,首先是访问元素增加元素的访问次数,从而提高元素在队列中的位置,降低淘汰优先级,后面是插入新元素的时候,因为队列已经满了,所以优先淘汰在一定时间间隔内访问频率最低的元素
W-TinyLFU
W-TinyLFU(Window Tiny Least Frequently Used)是对LFU的的优化和加强。
- 算法:当一个数据进来的时候,会进行筛选比较,进入Window窗口队列,以此应对流量突增,提高稀疏流量命中率,经过淘汰后进入过滤器,通过访问频率判决是否进入缓存。如果一个数据最近被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽的时候,会优先淘汰最近访问次数很低的数据;
- 优点:使用Count-Min Sketch算法存储访问频率,极大的节省空间;定期衰减操作,应对访问模式变化;并且使用window-lru机制能够尽可能避免缓存污染的发生,在过滤器内部会进行筛选处理,避免低频数据置换高频数据。
- 缺点:是由谷歌工程师发明的一种算法,目前已知应用于Caffeine Cache组件里,应用不是很多。
进化过程
针对LRU算法的两个主要问题(频率空间占用、稀疏突发流量表现不好)处理,W-tinyLFU算法出现。
- 首先使用Count-Min Sketch算法优化频率空间占用问题;
关于Count-Min Sketch算法,可以看作是布隆过滤器的同源的算法,假如我们用一个hashmap来存储每个元素的访问次数,那这个量级是比较大的,并且hash冲突的时候需要做一定处理,否则数据会产生很大的误差,Count-Min Sketch算法将一个hash操作,扩增为多个hash,这样原来hash冲突的概率就降低了几个等级,且当多个hash取得数据的时候,取最低值,也就是Count Min的含义所在。
常规Conut-Min Skech算法实现是以二维数组存储的,是 w 列和 d 行的二维数组。参数 w 和 d 在创建草图时是固定的,并确定时间和空间需求以及在查询频率或内部产品草图时的错误概率。与每个 d 行相关联的是一个单独的散列函数; 哈希函数必须是成对独立的。
当一个新的类型 i 事件到达时,我们更新如下:对于表中的每一行 j,应用相应的散列函数来获得列索引 k = hj(i)。然后将第 j 行第 k 列中的值加 1。
当获取key的统计频率时,也是分别计算hash函数得到对应行的index,比较d个索引下频率值,最小的值返回。
总体描述工作过程:
- 假设有四个hash函数,每当元素被访问时,将进行次数加1;
- 此时会按照约定好的N个hash函数进行hash计算找到对应的位置,相应的位置进行+1操作;
- 当获取元素的频率时,同样根据hash计算找到N个索引位置;
- 取得N个位置的频率信息,然后根据Count Min取得最低值作为本次元素的频率值返回,即Min(Count);
- 其次,通过增加窗口Window区域应对稀疏突发流量【没有该区域,新突发流量很难进入到缓存,因为频率很低】,积攒一定的频率后,在Window区域通过LRU淘汰晋升,Window晋升的元素和TinyLFU中的头部元素【频率最低】比较,优胜劣汰。
下面是Tiny-LFU官方的示意图:
W-TinyLFU算法分为两个区域,Window区域、SLRU-主Cache区域,新元素首先进入到Window队列中,通过LRU淘汰元素,此区域对于突发流量有缓冲作用,通过积攒频率可以淘汰到主区域中之前频率较高但是后期无访问的老数据,这点是保持“新鲜”的重点。
Caffeine W-TinyLFU 实现
在 《Caffeine基础源码解析》提到在进行读写之后会进行缓存的后置处理逻辑,再触发维护方法 maintenance() ,其中就包括缓存的驱逐处理。
缓存队列
在Caffeine具体实现中,同样分为窗口和主缓存区,其中缓存区分为冷热部分,主要使用三个队列来实现,且有默认尺寸,尺寸比重默认值,window占比默认比例0.99,热区占比默认比例0.8,作为调整的数据指标:
- window队列:accessOrderWindowDeque,1% 最大尺寸,提高稀疏突发冷数据的命中率;
- Main冷队列:accessOrderProbationDeque,剩余空间的20%,window队列的元素淘汰晋升后进入此,当此队列元素被访问,进入主缓存区域;
- main热队列:accessOrderProtectedDeque,剩余空间的80%,缓存主区域;
对应队列尺寸默认配比:
/** The initial percent of the maximum weighted capacity dedicated to the main space. */
static final double PERCENT_MAIN = 0.99d;
/** The percent of the maximum weighted capacity dedicated to the main's protected space. */
static final double PERCENT_MAIN_PROTECTED = 0.80d;
/**参考函数 元素**/
//最大的个数限制
long maximum;
//当前的个数
long weightedSize;
//window区的最大限制
long windowMaximum;
//window区当前的个数
long windowWeightedSize;
//protected区的最大限制
long mainProtectedMaximum;
//protected区当前的个数
long mainProtectedWeightedSize;
当执行缓存构造函数中,看到setMaximumSize()方法,会进行相应队列尺寸的初始化:
protected BoundedLocalCache(Caffeine<K, V> builder,
@Nullable CacheLoader<K, V> cacheLoader, boolean isAsync) {
this.isAsync = isAsync;
this.cacheLoader = cacheLoader;
executor = builder.getExecutor();
writer = builder.getCacheWriter();
evictionLock = new ReentrantLock();
weigher = builder.getWeigher(isAsync);
drainBuffersTask = new PerformCleanupTask(this);
nodeFactory = NodeFactory.newFactory(builder, isAsync);
data = new ConcurrentHashMap<>(builder.getInitialCapacity());
readBuffer = evicts() || collectKeys() || collectValues() || expiresAfterAccess()
? new BoundedBuffer<>()
: Buffer.disabled();
accessPolicy = (evicts() || expiresAfterAccess()) ? this::onAccess : e -> {};
if (evicts()) {
setMaximumSize(builder.getMaximum());
}
}
队列尺寸初始化,:
void setMaximumSize(long maximum) {
requireArgument(maximum >= 0);
if (maximum == maximum()) {
return;
}
// 获取最大尺寸
long max = Math.min(maximum, MAXIMUM_CAPACITY);
// 获取窗口尺寸
long window = max - (long) (PERCENT_MAIN * max);
// 获取保护区【主缓存尺寸】
long mainProtected = (long) (PERCENT_MAIN_PROTECTED * (max - window));
// 设置尺寸【子类实现方法】
setMaximum(max);
setWindowMaximum(window);
setMainProtectedMaximum(mainProtected);
setHitsInSample(0);
setMissesInSample(0);
setStepSize(-HILL_CLIMBER_STEP_PERCENT * max);
if ((frequencySketch() != null) && !isWeighted() && (weightedSize() >= (max >>> 1))) {
// Lazily initialize when close to the maximum size
frequencySketch().ensureCapacity(max);
}
}
元素驱逐
在maintaince()中,会进行一些读写缓存处理、一些gc回收的缓存、过期缓存的处理,最后有一个envictEntries()方法,驱逐元素,该方法为W-tinyLFU算法的主要执行过程:
void evictEntries() {
// 是否需要驱逐元素
if (!evicts()) {
return;
}
// 从window淘汰 & 晋升元素
int candidates = evictFromWindow();
// 从主缓存区域 淘汰元素
evictFromMain(candidates);
}
首先看下是否需要驱逐元素,如果是无界缓存,则不需要进行缓存的驱逐,驱逐元素是从Window区域开始的。
int evictFromWindow() {
// 本次驱逐的元素个数
int candidates = 0;
// 获取头元素【Window使用LRU算法】
Node<K, V> node = accessOrderWindowDeque().peek();
// 如果window尺寸超出约定,则需要进行元素淘汰
while (windowWeightedSize() > windowMaximum()) {
// The pending operations will adjust the size to reflect the correct weight
// 元素为null,队列为空,直接返回
if (node == null) {
break;
}
// 获取first指针,指向的元素
Node<K, V> next = node.getNextInAccessOrder();
// 元素所占【权重、尺寸默认1】
if (node.getWeight() != 0) {
// 设置该元素队列类别为MainProbation
node.makeMainProbation();
// window队列移除该元素
accessOrderWindowDeque().remove(node);
// 主缓存-冷队列添加该元素
accessOrderProbationDeque().add(node);
candidates++;
// 窗口尺寸 同步调整
setWindowWeightedSize(windowWeightedSize() - node.getPolicyWeight());
}
node = next;
}
// 返回驱逐元素的个数
return candidates;
}
该方法有效性在于window队列的尺寸超了,进行有效元素的驱逐处理,将其从window移除,放入到MainProbation队列的尾部,最后返回移除元素的个数,然后就到了主缓存区域的处理:
void evictFromMain(int candidates) {
int victimQueue = PROBATION;
// MainProbation 取出头元素,即要驱逐的元素
Node<K, V> victim = accessOrderProbationDeque().peekFirst();
// MainProbation 取出尾元素【可能就是在Window淘汰而晋升的元素】
Node<K, V> candidate = accessOrderProbationDeque().peekLast();
// 总尺寸检查,超出则进行元素处理
while (weightedSize() > maximum()) {
// Stop trying to evict candidates and always prefer the victim
if (candidates == 0) {
candidate = null;
}
// 二者都是null,则MainProbation为空,则通过window和MainProtected进行处理
if ((candidate == null) && (victim == null)) {
if (victimQueue == PROBATION) {
victim = accessOrderProtectedDeque().peekFirst();
victimQueue = PROTECTED;
continue;
} else if (victimQueue == PROTECTED) {
victim = accessOrderWindowDeque().peekFirst();
victimQueue = WINDOW;
continue;
}
// The pending operations will adjust the size to reflect the correct weight
break;
}
// 跳过重量为0
if ((victim != null) && (victim.getPolicyWeight() == 0)) {
victim = victim.getNextInAccessOrder();
continue;
} else if ((candidate != null) && (candidate.getPolicyWeight() == 0)) {
candidate = candidate.getPreviousInAccessOrder();
candidates--;
continue;
}
// 二者有一个为null,则直接驱逐,并进行下一元素处理
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;
}
// 如果元素被gc回收,则直接驱逐处理,并处理下一节点
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;
}
// 如果window淘汰的元素此时重量超出最大尺寸,直接驱逐
if (candidate.getPolicyWeight() > maximum()) {
candidates--;
Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
}
//根据节点的统计频率frequency来做比较,看看要处理掉victim还是candidate
//admit是具体的比较规则,看下面
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);
}
}
}
比较频率是重点,看下admin()方法:
boolean admit(K candidateKey, K victimKey) {
// 获取频率
int victimFreq = frequencySketch().frequency(victimKey);
// 获取频率
int candidateFreq = frequencySketch().frequency(candidateKey);
// 比较频率
if (candidateFreq > victimFreq) {
return true;
//如果相等,candidate小于5都当输了
} else if (candidateFreq <= 5) {
// The maximum frequency is 15 and halved to 7 after a reset to age the history. An attack
// exploits that a hot candidate is rejected in favor of a hot victim. The threshold of a warm
// candidate reduces the number of random acceptances to minimize the impact on the hit rate.
return false;
}
//如果相等且candidate大于5,则随机淘汰一个
int random = ThreadLocalRandom.current().nextInt();
return ((random & 127) == 0);
}
那么MainProtected尺寸过大后,什么时机下驱逐呢?其中之一在evictFromMain中提到,如果尺寸过大,但是MainProbation队列为空,则会通过MainProtected驱逐,另一个驱逐时机在清理任务的climb()方法的demoteFromMainProtected()中:
void demoteFromMainProtected() {
long mainProtectedMaximum = mainProtectedMaximum();
long mainProtectedWeightedSize = mainProtectedWeightedSize();
// 如果没有超出mainProtected队列最大尺寸,则返回,否则进行驱逐,直到满足
if (mainProtectedWeightedSize <= mainProtectedMaximum) {
return;
}
for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
if (mainProtectedWeightedSize <= mainProtectedMaximum) {
break;
}
// 弹出mainProtected队列首元素
Node<K, V> demoted = accessOrderProtectedDeque().poll();
if (demoted == null) {
break;
}
// 将该元素放入MainProbation队列
demoted.makeMainProbation();
accessOrderProbationDeque().add(demoted);
mainProtectedWeightedSize -= demoted.getPolicyWeight();
}
// 同步mainProtected队列尺寸
setMainProtectedWeightedSize(mainProtectedWeightedSize);
}
将mainProtected队列中的元素放入MainProbation队列中。
元素访问
当元素访问后,在队列中会发生哪些变化,上篇文章 《Caffeine基础源码解析》中提到读后处理onAccess(),其中看到根据当前元素在不同的队列有不同的处理逻辑:
void onAccess(Node<K, V> node) {
if (evicts()) {
K key = node.getKey();
if (key == null) {
return;
}
frequencySketch().increment(key);
if (node.inWindow()) {
reorder(accessOrderWindowDeque(), node);
} else if (node.inMainProbation()) {
reorderProbation(node);
} else {
reorder(accessOrderProtectedDeque(), node);
}
setHitsInSample(hitsInSample() + 1);
} else if (expiresAfterAccess()) {
reorder(accessOrderWindowDeque(), node);
}
if (expiresVariable()) {
timerWheel().reschedule(node);
}
}
static <K, V> void reorder(LinkedDeque<Node<K, V>> deque, Node<K, V> node) {
// An entry may be scheduled for reordering despite having been removed. This can occur when the
// entry was concurrently read while a writer was removing it. If the entry is no longer linked
// then it does not need to be processed.
if (deque.contains(node)) {
// 将元素移动到队列末尾
deque.moveToBack(node);
}
}
void reorderProbation(Node<K, V> node) {
if (!accessOrderProbationDeque().contains(node)) {
// Ignore stale accesses for an entry that is no longer present
return;
} else if (node.getPolicyWeight() > mainProtectedMaximum()) {
return;
}
// If the protected space exceeds its maximum, the LRU items are demoted to the probation space.
// This is deferred to the adaption phase at the end of the maintenance cycle.
setMainProtectedWeightedSize(mainProtectedWeightedSize() + node.getPolicyWeight());
accessOrderProbationDeque().remove(node);
accessOrderProtectedDeque().add(node);
node.makeMainProtected();
}
- 在window区域:进入reorder方法,移动该元素到队列末尾;
- 在MainProbation:进入reorderProbation方法,将该元素从MainProbation移除,放入MainProtected队列;
- 在MainProtected:进入reorder方法,移动该元素到队列末尾;
那么总体过程简单描述如下:
- 新数据首先进入到Window区域,如果window满,则会通过LRU进行淘汰晋升到probation区域;此队列元素被访问后移动到队列尾部;
- 元素的淘汰发生在probation区域,如果总尺寸超出,则会进行probation队列首尾元素频率PK,进行淘汰;当此区域元素访问后,该元素会晋升到proted区域;
- proted区域的元素是比较稳定的,如果尺寸超出,则淘汰的元素会进入到probation队列,在该队列中进行淘汰;此队列元素被访问后移动到队列尾部;
频率处理
上面是三个队列间的关系,其中元素PK淘汰时,是通过频率来比较的,Caffeine中关于Count-Min Schech算法的实现都在FrequencySketch类中,包括获取指定key的频率,增加指定key的频率,首先看下有哪些变量:
//Hash函数的种子
static final long[] SEED = { // A mixture of seeds from FNV-1a, CityHash, and Murmur3
0xc3a5c85c97cb3127L, 0xb492b66fbe98f273L, 0x9ae16a3b2f90404fL, 0xcbf29ce484222325L};
static final long RESET_MASK = 0x7777777777777777L;
static final long ONE_MASK = 0x1111111111111111L;
int sampleSize;
// 快速获取hash值对应table的index的掩码,table长度为2的n次方,此值为table的size-1,这样可以用&代替取模
int tableMask;
// 存储频率的long数组,初始化长度为指定缓存尺寸的最接近的大的2的n次方,比如设置最大尺寸为3,那么table设置为4,
long[] table;
int size;
Caffeine在频率存储上更加节省空间,传统使用二维数组,它内部使用一维的long数组存储频率,使用四个hash种子,分别确定在long数组的四个index位置,每个long数字,分为16等份,每份占6位,则频率最大值为15【意味着当元素频率超过15则需要统一削减处理】,取hash值低两位,在左移2位得到一个小于等于15的值start,这个值就是等分的下边,在四个index,分别+0、1、2、3作为等分的下边,进行+1频率操作。
看下初始化的过程,在调用BoundedLocalCache构造方法时,也会对频率器初始化:
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;
}
// 计算传入size的最接近的2的次幂作为容量【可以使用位运算替代mod运算】
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;
}
主要是对table数组的初始化,对容量的计算,看下频率增加的过程:
public void increment(@NonNull E e) {
if (isNotInitialized()) {
return;
}
// 获取元素的hash码,通过sperad函数处理,增加hash的散列
int hash = spread(e.hashCode());
// 根据拿到的hash值,计算在index下的起始位置【15内:long下64位,4位一份,分16份】
int start = (hash & 3) << 2;
// 分别计算4个hash索引
int index0 = indexOf(hash, 0);
int index1 = indexOf(hash, 1);
int index2 = indexOf(hash, 2);
int index3 = indexOf(hash, 3);
// 对应位置进行++处理
boolean added = incrementAt(index0, start);
added |= incrementAt(index1, start + 1);
added |= incrementAt(index2, start + 2);
added |= incrementAt(index3, start + 3);
if (added && (++size == sampleSize)) {
reset();
}
}
// 对应索引下具体位置,增加频率
boolean incrementAt(int i, int j) {
// j是计算的long16份中的index, x 4,就是64位的起始位置
int offset = j << 2;
// 计算0xfL【15】相对于offset左移
long mask = (0xfL << offset);
//如果满足,则证明全为1,则频率满值,则无法在进行+1处理
if ((table[i] & mask) != mask) {
table[i] += (1L << offset);
return true;
}
return false;
}
// 根据四个种子,计算索引
int indexOf(int item, int i) {
long hash = SEED[i] * item;
hash += hash >>> 32;
return ((int) hash) & tableMask;
}
int spread(int x) {
x = ((x >>> 16) ^ x) * 0x45d9f3b;
x = ((x >>> 16) ^ x) * 0x45d9f3b;
return (x >>> 16) ^ x;
}
会发现很多mod逻辑用这种&代替mod,提高运算速度,是我们值得参考的点,知道了频率增加的逻辑,那获取频率的逻辑也很清晰了:
public int frequency(@NonNull E e) {
if (isNotInitialized()) {
return 0;
}
// 计算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;
}
找到对应的四个位置的频率,比较最小值返回,作为该key的频率。
策略自适应
我们看到在Caffeine对于三个队列,会有一个默认的配比,队列配比一定程度上影响了缓存的命中率,Caffeine深知这一点,所以会自适应的调整队列的配比。
之前代码示例中,时常看到类似计数器的代码,比如每次缓存的查询会进行命中和未命中的次数统计:
if (node == null) {
if (recordStats) {
statsCounter().recordMisses(1);
}
return null;
}
statsCounter().recordHits(result.size());
这个命中数的统计,就是队列配比调整的数据基础,实际上我们可以思考下:如果命中率低,是不是缓存中大部分数据不在缓存区,是不是数据变化较快,可以适当增加window配比呢?是不是可以通过和上次统计的命中率的比较从而对比进行配比的调整呢?,这段自适应调整策略在maintenance()中climb()方法中。
对于Caffeine的W-TinyLFU算法的实现,有很多值得学习的点,无论是对于任务异步的处理,还是针对一些计算提效的小点,最好是通过源码的阅读理解含义,从而学以致用。
参考文档
1:博客self blog - Ftsom
2:文章《缓存算法-FIFO/LRU/LFU/W-TinyLFU》
3:源码 github.com/ben-manes