Caffeine Count-Min Sketch TinyLFU实现:FrequencySketch
FrequencySketch
本质上是一个概率性数据结构,具体来说是 Count-Min Sketch 的一种变体实现。它的主要目标是:
- 高效地统计:能够快速地记录一个元素的访问次数(频率)。
- 节省空间:相比于为每个元素都维护一个精确的计数器(例如使用
HashMap<E, Integer>
),它用极小的空间来估算频率,这对于一个高性能的本地缓存至关重要。 - 实现“遗忘”机制:缓存不仅关心一个元素历史总访问频率,更关心它在最近一段时间内的访问频率。因此,
FrequencySketch
实现了一种“老化”(Aging)或“衰减”(Decay)机制,定期将所有元素的频率减半,从而让旧的热点数据有机会“冷却”下来。
每个元素的频率最大被限制为 15(用 4 个 bit 位存储),并且会周期性地将所有计数器减半。
这个类的注释已经清晰地说明了它的用途:
/** Copyright 2015 Ben Manes. All Rights Reserved.** Licensed under the Apache License, Version 2.0 (the "License");* you may not use this file except in compliance with the License.* You may obtain a copy of the License at** http://www.apache.org/licenses/LICENSE-2.0** Unless required by applicable law or agreed to in writing, software* distributed under the License is distributed on an "AS IS" BASIS,// ... existing code ...*/
package com.github.benmanes.caffeine.cache;import static com.github.benmanes.caffeine.cache.Caffeine.requireArgument;import com.google.errorprone.annotations.Var;/*** A probabilistic multiset for estimating the popularity of an element within a time window. The* maximum frequency of an element is limited to 15 (4-bits) and an aging process periodically* halves the popularity of all elements.** @author ben.manes@gmail.com (Ben Manes)*/
final class FrequencySketch<E> {/** This class maintains a 4-bit CountMinSketch [1] with periodic aging to provide the popularity* history for the TinyLfu admission policy [2]. The time and space efficiency of the sketch* allows it to cheaply estimate the frequency of an entry in a stream of cache access events.
// ... existing code ...
核心数据结构与内存布局
FrequencySketch
的核心是 long[] table
数组。它巧妙地利用了 long
类型的 64 个 bit 位来存储多个计数器。
- 4-bit 计数器:每个元素的频率计数器只占用 4 个 bit,所以最大能表示的数字是
2^4 - 1 = 15
。 - 每个 long 元素(64位)被用来存储 16 个 4位的计数器。
硬件缓存行优化 (Cache Line Alignment)
一个非常精妙的设计是它考虑了 CPU 缓存的性能。现代 CPU 的 L1 缓存行(Cache Line)大小通常是 64 字节。一次内存读取会加载整个缓存行。如果数据能在一个缓存行内,就能极大地提高访问速度。
FrequencySketch
将一个元素的 4 个哈希探针(下面会讲到)所需的所有计数器限制在一个 64 字节的内存块内。
- 一个
long
是 8 字节。 - 一个 64 字节的块可以存放
64 / 8 = 8
个long
元素。 block = (blockHash & blockMask) << 3
这行代码就是用来定位这个 64 字节块的起始位置(<< 3
相当于* 8
)。- 之后,元素的 4 个计数器被分散到这个块内的 4 个不同的 16 字节段中,以减少冲突。
这种设计牺牲了理论上哈希的完全均匀性,但换来了巨大的硬件性能提升,因为它将多次不连续的内存访问变成了可预测的、具有空间局部性的单次内存访问。
ensureCapacity(long maximumSize)
- 初始化
这个方法用于初始化或扩容 table
数组。
// ... existing code ...public void ensureCapacity(long maximumSize) {requireArgument(maximumSize >= 0);int maximum = (int) Math.min(maximumSize, Integer.MAX_VALUE >>> 1);if ((table != null) && (table.length >= maximum)) {return;}table = new long[Math.max(Caffeine.ceilingPowerOfTwo(maximum), 8)];sampleSize = (maximumSize == 0) ? 10 : (10 * maximum);blockMask = (table.length >>> 3) - 1;if (sampleSize <= 0) {sampleSize = Integer.MAX_VALUE;}size = 0;}
// ... existing code ...
table
的大小被设置为大于等于maximumSize
的最小的 2 的幂次方,这使得后续可以通过位运算(&
)快速计算索引,而不是取模(%
)。sampleSize
被设置为10 * maximumSize
。它是一个阈值,当increment
操作的次数达到这个值时,就会触发reset
操作。blockMask
是用于定位 64 字节块的掩码。
increment(E e)
- 增加频率
这是最核心的操作之一。当一个元素被访问时,调用此方法来增加它的频率计数。
// ... existing code ...public void increment(E e) {if (isNotInitialized()) {return;}int blockHash = spread(e.hashCode());int counterHash = rehash(blockHash);int block = (blockHash & blockMask) << 3;// Loop unrolling improves throughput by 10m ops/sint h0 = counterHash;int h1 = counterHash >>> 8;int h2 = counterHash >>> 16;int h3 = counterHash >>> 24;int index0 = (h0 >>> 1) & 15;int index1 = (h1 >>> 1) & 15;int index2 = (h2 >>> 1) & 15;int index3 = (h3 >>> 1) & 15;int slot0 = block + (h0 & 1);int slot1 = block + (h1 & 1) + 2;int slot2 = block + (h2 & 1) + 4;int slot3 = block + (h3 & 1) + 6;boolean added =incrementAt(slot0, index0)| incrementAt(slot1, index1)| incrementAt(slot2, index2)| incrementAt(slot3, index3);if (added && (++size == sampleSize)) {reset();}}
// ... existing code ...
- 哈希计算:通过
spread
和rehash
两轮哈希来增强随机性,避免不良的hashCode()
实现导致频繁的哈希冲突。 - 定位:计算出
block
(64字节块的起始索引)和 4 个哈希值h0
到h3
。 - 循环展开:代码注释中提到,为了性能,这里手动展开了循环。
- 增加计数:它会找到 4 个不同的计数器位置(
slot
和index
共同决定),并调用incrementAt
尝试将它们的值加 1(如果没达到最大值 15)。注意这里用的是|
而不是||(防止短路)
,确保 4 个incrementAt
都会被执行。 - 触发 Reset:如果任意一个计数器成功增加了(
added
为true
),size
就会加 1。当size
达到sampleSize
时,调用reset()
方法。
补充说明:
- slot:是 table 数组的索引,它告诉我们要操作哪一个 long 元素。
- index:是 long 内部的计数器索引(范围从 0 到 15),它告诉我们要操作这个 long 里的哪一个 4位计数器。
为了让 CPU 能最高效地访问数据,Caffeine 将一个元素所需的 4 个计数器都限制在一个 64 字节的内存块内。
- 一个 64 字节的块正好可以容纳 8 个
long
元素。 - 代码中的
int block = (blockHash & blockMask) << 3;
就是用来定位这个 64 字节块的起始slot
索引(<< 3
等价于* 8
)。
为了让 4 个计数器在 64 字节的块内 不冲突,设计者将这个 64 字节(8个 long
)的块又进一步划分为 4 个 16 字节(2个 long
)的段。
核心设计思想:一个元素的 4 个计数器,保证每一个都落在不同的段里,因此不会重叠。
我们来看一下这个 8-long
块的结构:
- 段 0:
table[block]
和table[block + 1]
- 段 1:
table[block + 2]
和table[block + 3]
- 段 2:
table[block + 4]
和table[block + 5]
- 段 3:
table[block + 6]
和table[block + 7]
现在我们再来看 slot
的计算就一目了然了:
// ... existing code ...int slot0 = block + (h0 & 1);int slot1 = block + (h1 & 1) + 2;int slot2 = block + (h2 & 1) + 4;int slot3 = block + (h3 & 1) + 6;
// ... existing code ...
slot0
:block + (h0 & 1)
。(h0 & 1)
的结果是 0 或 1,所以slot0
会落在table[block]
或table[block+1]
,即第 0 段。slot1
:block + (h1 & 1) + 2
。+ 2
的作用是跳到第 1 段的起始位置,然后再从中随机选择一个long
。所以slot1
会落在table[block+2]
或table[block+3]
,即第 1 段。slot2
:+ 4
是为了跳到第 2 段。slot3
:+ 6
是为了跳到第 3 段。
所以,slot
每次加 2,是为了确保下一个计数器能够被定位到下一个独立的 16 字节段内,从而实现均匀分布。
slot 的计算使用了每个子哈希的最低位 (h & 1)。
index 的计算则使用了每个子哈希的第 2 到第 5 位 ((h >>> 1) & 15)。这样做可以保证用于计算 slot 和 index 的比特位是不同的,增加了随机性。
frequency(E e)
- 查询频率
此方法用于估算一个元素的当前频率。
// ... existing code ...public int frequency(E e) {if (isNotInitialized()) {return 0;}@Var int frequency = Integer.MAX_VALUE;int blockHash = spread(e.hashCode());int counterHash = rehash(blockHash);int block = (blockHash & blockMask) << 3;for (int i = 0; i < 4; i++) {int h = counterHash >>> (i << 3);int index = (h >>> 1) & 15;int offset = h & 1;int slot = block + offset + (i << 1);int count = (int) ((table[slot] >>> (index << 2)) & 0xfL);frequency = Math.min(frequency, count);}return frequency;}
// ... existing code ...
它执行与 increment
类似的操作来定位 4 个计数器,但它不修改值,而是读取这 4 个计数器的值,并返回其中最小的一个。这是 Count-Min Sketch 算法的核心思想,因为哈希冲突只会导致计数值被高估,所以取最小值可以得到一个更接近真实值的上界。
increment 和 frequency 这两个方法在逻辑上非常相似,但一个选择展开循环,另一个则没有,这背后是基于对性能、调用频率和代码可读性之间深思熟虑的权衡。
特性 | increment() | frequency() |
---|---|---|
调用频率 | 极高 (每次访问) | 较低 (仅在决策时) |
性能关键性 | 极高 (Hot Path) | 较高,但非瓶颈 |
优化方式 | 手动循环展开 | 依赖 JIT 自动优化 |
原因 | 性能收益巨大 (10m ops/s),值得牺牲可读性 | JIT 足够智能,保持可读性更重要 |
reset()
- 衰减
这是实现“遗忘”机制的关键。
// ... existing code ...static final long RESET_MASK = 0x7777777777777777L;static final long ONE_MASK = 0x1111111111111111L;/** Reduces every counter by half of its original value. */void reset() {@Var int count = 0;for (int i = 0; i < table.length; i++) {count += Long.bitCount(table[i] & ONE_MASK);table[i] = (table[i] >>> 1) & RESET_MASK;}size = (size - (count >>> 2)) >>> 1;}
}
- 遍历所有计数器:它会遍历整个
table
数组。 - 减半:对于
table
中的每个long
,执行(table[i] >>> 1) & RESET_MASK
。>>> 1
:无符号右移一位。对于每个 4-bit 的计数器,这相当于除以 2 并向下取整。& RESET_MASK
:RESET_MASK
是0x7777777777777777L
。在二进制中,每个7
是0111
。这个操作的目的是将每个 4-bit 计数器的最高位清零,防止右移时高位的 1 "溢出" 到下一个计数器的低位,从而保证每个计数器独立地减半。
- 更新 size:
size
也需要相应地减半。这里做了一个近似的调整,count
记录了所有计数器中值为奇数的个数,通过一系列位运算来估算衰减后的新size
。
这里的除法是整数除法。
- 如果一个计数器的值是偶数(如 10),除以 2 后是 5。
- 如果一个计数器的值是奇数(如 11),除以 2 后也是 5(
floor(11/2)
)。
这意味着,每一个值为奇数的计数器,在减半后都会“损失” 0.5。
因此,衰减后所有计数器的总和,并不完全等于之前总和的一半。新的总和 S_new
实际上是: S_new = (S_old - number_of_odd_counters) / 2
在 reset
方法中,count
变量就是用来统计值为奇数的计数器总数的。
现在我们来看 size
。size
在每次 increment
成功时加 1。而 increment
操作会尝试给 4 个不同的计数器加 1。所以,我们可以认为 size
的增长与所有计数器总和 S
的增长是成正比的。具体来说,可以近似认为 S ≈ 4 * size
。
既然 size
是 S
的代理,那么当 S
衰减时,size
也应该以同样的逻辑衰减,以保持关系。
所以,size
的新值 size_new
应该遵循:
size_new ≈ S_new/4 = (S_old/4 - count/4)/2 ≈ (size_old - count/4)/2
这种做法确保了 size
在 reset
之后,依然能够准确地作为整个 FrequencySketch
中总频率的一个估算值,而不是简单地将采样窗口减半。这在 TinyLFU 算法中对于维持稳定的采样和老化周期至关重要。
spread和rehash
这两个函数都是哈希混合函数(Mixing Hash Functions)。它们的存在是为了解决一个常见问题:Java 对象的 hashCode()
方法可能质量不高。
一个“质量不高”的哈希函数可能会导致不同的输入产生相同或相似(例如,只有低位不同)的哈希码,这会造成大量的哈希冲突。对于 FrequencySketch
这种依赖哈希来均匀分布计数器的数据结构来说,大量的冲突会严重降低其准确性。
因此,spread
和 rehash
的作用就是对原始的 hashCode()
进行二次加工,通过一系列位运算和乘法,将原始哈希码的比特位充分地、雪崩式地混合打乱,旨在用最小的计算开销换取最好的哈希质量,从而生成一个分布更均匀、随机性更好的新哈希码。
// ... existing code .../** Applies a supplemental hash function to defend against a poor quality hash. */static int spread(@Var int x) {x ^= x >>> 17;x *= 0xed5ad4bb;x ^= x >>> 11;x *= 0xac4c1b51;x ^= x >>> 15;return x;}
// ... existing code ...
这个函数的设计并非凭空而来。实际上,在 FrequencySketch
类的顶部注释中,作者给出了一个关键的参考链接:
// ... existing code ...* [3] Hash Function Prospector: Three round functions* https://github.com/skeeto/hash-prospector#three-round-functions
// ... existing code ...
这个链接指向一个名为 "Hash Function Prospector" 的项目,该项目致力于通过暴力搜索和统计测试来寻找性能优异的非加密哈希函数。
spread
函数的实现正是基于该项目发现的优秀哈希算法。我们来分解它的每一步:
x ^= x >>> 17
: 这一步是 XOR-Shift 操作。它将x
的高 17 位与低 17 位进行异或操作。这样做的目的是让原始哈希码的高位信息能够影响到低位,反之亦然。如果原始哈希码只在几个高位有变化,这个操作可以确保这些变化能“扩散”到整个 32 位整数中。x *= 0xed5ad4bb
: 乘以一个大的、奇特的常数(通常是质数或接近质数)。这个操作在密码学和哈希算法中非常常见。乘法可以将比特位进行复杂的置换和混合,极大地增强雪崩效应(输入的一位微小变化会导致输出的大量比特位发生变化)。- 重复混合: 后续的
x ^= x >>> 11; x *= 0xac4c1b51; x ^= x >>> 15;
是对上述过程的重复和加强,使用不同的位移量和乘法常数,进行多轮混合,以达到更好的随机分布效果。
为什么是这些特定的数字(17
, 0xed5ad4bb
, 11
, 0xac4c1b51
, 15
)?
它们不是通过数学推导得出的,而是通过经验——即“勘探”(Prospecting)——找到的。研究人员编写程序,生成海量的位移量和常数组合,然后用严格的统计学测试(如 SMHasher)来评估每种组合生成的哈希函数的质量(比如冲突率、均匀性、雪崩效应等),最后筛选出表现最好的那一批组合。这些数字 就是经过千锤百炼后被证明拥有优秀统计特性的“魔法数字”。
// ... existing code .../** Applies another round of hashing for additional randomization. */static int rehash(@Var int x) {x *= 0x31848bab;x ^= x >>> 14;return x;}
// ... existing code ...
rehash
函数的原理与 spread
类似,但更简单,只有一轮乘法和一轮 XOR-Shift。在 FrequencySketch
中,它被用在 spread
之后,对已经混合过的 blockHash
进行再次哈希,生成 counterHash
。
// ... existing code ...int blockHash = spread(e.hashCode());int counterHash = rehash(blockHash);
// ... existing code ...
这样做的目的是为不同的用途提供不同的哈希值,避免依赖单一哈希值。blockHash
用于确定元素应该落在哪个 64 字节的内存块,而 counterHash
则用于确定在这个块内的具体 4 个计数器位置。通过两轮独立的哈希,可以更好地降低冲突,即使两个元素的 blockHash
碰巧相同,它们的 counterHash
也大概率不同,从而使用的计数器也不同。
总结
FrequencySketch
是一个集理论与工程实践于一体的典范。它:
- 基于成熟的 Count-Min Sketch 概率算法。
- 通过位运算和数组实现了极高的空间效率,用 4-bit 存储一个计数器。
- 通过周期性衰减(
reset
)机制,实现了对访问频率的有时效性的统计,更关注近期热点。 - 通过缓存行对齐的内存布局优化,充分利用了现代 CPU 的硬件特性,将理论算法落地为高性能的工程实现。
在 Caffeine 中,它为 TinyLFU 淘汰策略提供了数据支持,帮助 Caffeine 判断一个新来的元素是否有足够的“热度”值得被加入缓存,或者一个缓存中的元素是否因为长期未被访问而应该被淘汰。