从Caffeine使用到原理初探
简介
Caffeine是一个基于Java8开发的提供了近乎最佳命中率的高性能的缓存库。
缓存和ConcurrentMap有点相似,但还是有所区别。最根本的区别是ConcurrentMap将会持有所有加入到缓存当中的元素,直到它们被从缓存当中手动移除。但是,Caffeine的缓存 Cache
通常会被配置成自动驱逐缓存中元素,以限制其内存占用。在某些场景下,LoadingCache
和 AsyncLoadingCache
因为其自动加载缓存的能力将会变得非常实用。
Caffeine提供了灵活的构造器去创建一个拥有下列特性的缓存:
- 自动加载元素到缓存当中,异步加载的方式也可供选择
- 当达到最大容量的时候可以使用基于就近度和频率的算法进行基于容量的驱逐
- 将根据缓存中的元素上一次访问或者被修改的时间进行基于过期时间的驱逐
- 当向缓存中一个已经过时的元素进行访问的时候将会进行异步刷新
- key将自动被弱引用所封装
- value将自动被弱引用或者软引用所封装
- 驱逐(或移除)缓存中的元素时将会进行通知
- 写入传播到一个外部数据源当中
- 持续计算缓存的访问统计指标
使用方法
添加
Caffeine提供了四种缓存添加策略:手动加载,自动加载,手动异步加载和自动异步加载。
手动加载
Cache<Key, Graph> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
// 查找一个缓存元素, 没有查找到的时候返回null
Graph graph = cache.getIfPresent(key);
// 查找缓存,如果缓存不存在则生成缓存元素, 如果无法生成则返回null
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一个缓存元素
cache.put(key, graph);
// 移除一个缓存元素
cache.invalidate(key);
Cache 接口提供了显式搜索查找、更新和移除缓存元素的能力。
推荐使用 cache.get(key, k -> value) 操作来在缓存中不存在该key对应的缓存元素的时候进行计算生成并直接写入至缓存内,而当该key对应的缓存元素存在的时候将会直接返回存在的缓存值。一次 cache.put(key, value) 操作将会直接写入或者更新缓存里的缓存元素,在缓存中已经存在的该key对应缓存值都会直接被覆盖。值得注意的是,当缓存的元素无法生成或者在生成的过程中抛出异常而导致生成元素失败,cache.get 也许会返回 null 。
当然,也可以使用Cache.asMap()所暴露出来的ConcurrentMap的方法对缓存进行操作。
自动加载
LoadingCache<Key, Graph> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
// 查找缓存,如果缓存不存在则生成缓存元素, 如果无法生成则返回null
Graph graph = cache.get(key);
// 批量查找缓存,如果缓存不存在则生成缓存元素
Map<Key, Graph> graphs = cache.getAll(keys);
一个LoadingCache是一个Cache 附加上 CacheLoader能力之后的缓存实现。
通过 getAll可以达到批量查找缓存的目的。 默认情况下,在getAll 方法中,将会对每个不存在对应缓存的key调用一次 CacheLoader.load 来生成缓存元素。 在批量检索比单个查找更有效率的场景下,你可以覆盖并开发CacheLoader.loadAll 方法来使你的缓存更有效率。
值得注意的是,你可以通过实现一个 CacheLoader.loadAll并在其中为没有在参数中请求的key也生成对应的缓存元素。打个比方,如果对应某个key生成的缓存元素与包含这个key的一组集合剩余的key所对应的元素一致,那么在loadAll中也可以同时加载剩下的key对应的元素到缓存当中。
手动异步加载
AsyncCache<Key, Graph> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.buildAsync();
// 查找一个缓存元素, 没有查找到的时候返回null
CompletableFuture<Graph> graph = cache.getIfPresent(key);
// 查找缓存元素,如果不存在,则异步生成
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一个缓存元素
cache.put(key, graph);
// 移除一个缓存元素
cache.synchronous().invalidate(key);
一个AsyncCache 是 Cache 的一个变体,AsyncCache提供了在 Executor上生成缓存元素并返回 CompletableFuture的能力。这给出了在当前流行的响应式编程模型中利用缓存的能力。
synchronous()方法给 Cache提供了阻塞直到异步缓存生成完毕的能力。
当然,也可以使用 AsyncCache.asMap()所暴露出来的ConcurrentMap的方法对缓存进行操作。
默认的线程池实现是 ForkJoinPool.commonPool() ,当然你也可以通过覆盖并实现 Caffeine.executor(Executor)方法来自定义你的线程池选择。
自动异步加载
AsyncLoadingCache<Key, Graph> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
// 你可以选择: 去异步的封装一段同步操作来生成缓存元素
.buildAsync(key -> createExpensiveGraph(key));
// 你也可以选择: 构建一个异步缓存元素操作并返回一个future
.buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));
// 查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<Graph> graph = cache.get(key);
// 批量查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<Map<Key, Graph>> graphs = cache.getAll(keys);
一个 AsyncLoadingCache是一个 AsyncCache 加上 AsyncCacheLoader能力的实现。
在需要同步的方式去生成缓存元素的时候,CacheLoader是合适的选择。而在异步生成缓存的场景下, AsyncCacheLoader则是更合适的选择并且它会返回一个 CompletableFuture。
通过 getAll可以达到批量查找缓存的目的。 默认情况下,在getAll 方法中,将会对每个不存在对应缓存的key调用一次 AsyncCacheLoader.asyncLoad 来生成缓存元素。 在批量检索比单个查找更有效率的场景下,你可以覆盖并开发AsyncCacheLoader.asyncLoadAll 方法来使你的缓存更有效率。
值得注意的是,你可以通过实现一个 AsyncCacheLoader.asyncLoadAll并在其中为没有在参数中请求的key也生成对应的缓存元素。打个比方,如果对应某个key生成的缓存元素与包含这个key的一组集合剩余的key所对应的元素一致,那么在asyncLoadAll中也可以同时加载剩下的key对应的元素到缓存当中。
驱逐
Caffeine 提供了三种驱逐策略,分别是基于容量,基于时间和基于引用三种类型。
基于容量
// 基于缓存内的元素个数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> createExpensiveGraph(key));
// 基于缓存内元素权重进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumWeight(10_000)
.weigher((Key key, Graph graph) -> graph.vertices().size())
.build(key -> createExpensiveGraph(key));
如果你的缓存容量不希望超过某个特定的大小,那么记得使用Caffeine.maximumSize(long)。缓存将会尝试通过基于就近度和频率的算法来驱逐掉不会再被使用到的元素。
另一种情况,你的缓存可能中的元素可能存在不同的“权重”--打个比方,你的缓存中的元素可能有不同的内存占用--你也许需要借助Caffeine.weigher(Weigher) 方法来界定每个元素的权重并通过 Caffeine.maximumWeight(long)方法来界定缓存中元素的总权重来实现上述的场景。除了“最大容量”所需要的注意事项,在基于权重驱逐的策略下,一个缓存元素的权重计算是在其创建和更新时,此后其权重值都是静态存在的,在两个元素之间进行权重的比较的时候,并不会根据进行相对权重的比较。
基于时间
// 基于固定的过期时间驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
// 基于不同的过期驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfter(Expiry.creating((Key key, Graph graph) ->
Duration.between(Instant.now(), graph.creationDate().plusHours(5))))
.build(key -> createExpensiveGraph(key));
Caffeine提供了三种方法进行基于时间的驱逐:
- expireAfterAccess(long, TimeUnit): 一个元素在上一次读写操作后一段时间之后,在指定的时间后没有被再次访问将会被认定为过期项。在当被缓存的元素时被绑定在一个session上时,当session因为不活跃而使元素过期的情况下,这是理想的选择。
- expireAfterWrite(long, TimeUnit): 一个元素将会在其创建或者最近一次被更新之后的一段时间后被认定为过期项。在对被缓存的元素的时效性存在要求的场景下,这是理想的选择。
- expireAfter(Expiry): 一个元素将会在指定的时间后被认定为过期项。当被缓存的元素过期时间收到外部资源影响的时候,这是理想的选择。
在写操作,和偶尔的读操作中将会进行周期性的过期事件的执行。过期事件的调度和触发将会在O(1)的时间复杂度内完成。
为了使过期更有效率,可以通过在你的Cache构造器中通过Scheduler接口和Caffeine.scheduler(Scheduler) 方法去指定一个调度线程代替在缓存活动中去对过期事件进行调度。使用Java 9以上版本的用户可以选择Scheduler.systemScheduler()利用系统范围内的调度线程。
当测试基于时间的驱逐策略的时候,不需要坐在板凳上等待现实时钟的转动。使用Ticker接口和 Caffeine.ticker(Ticker)方法在你的Cache构造器中去指定一个时间源可以避免苦苦等待时钟转动的麻烦。Guava的测试库也提供了FakeTicker去达到同样的目的。
基于引用
// 当key和缓存元素都不再存在其他强引用的时候驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(key -> createExpensiveGraph(key));
// 当进行GC的时候进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.softValues()
.build(key -> createExpensiveGraph(key));
Caffeine 允许你配置你的缓存去让GC去帮助清理缓存当中的元素,其中key支持弱引用,而value则支持弱引用和软引用。记住 AsyncCache不支持软引用和弱引用。
-
Caffeine.weakKeys() 在保存key的时候将会进行弱引用。这允许在GC的过程中,当key没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行key之间的比较。
-
Caffeine.weakValues()在保存value的时候将会使用弱引用。这允许在GC的过程中,当value没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。
-
Caffeine.softValues()在保存value的时候将会使用软引用。为了相应内存的需要,在GC过程中被软引用的对象将会被通过LRU算法回收。由于使用软引用可能会影响整体性能,我们还是建议通过使用基于缓存容量的驱逐策略代替软引用的使用。同样的,使用 softValues() 将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。
移除
术语
- 驱逐 缓存元素因为策略被移除
- 失效 缓存元素被手动移除
- 移除 由于驱逐或者失效而最终导致的结果
显式移除
在任何时候,你都可以手动去让某个缓存元素失效而不是只能等待其因为策略而被驱逐。
// 失效key
cache.invalidate(key)
// 批量失效key
cache.invalidateAll(keys)
// 失效所有的key
cache.invalidateAll()
移除监听器
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.evictionListener((Key key, Graph graph, RemovalCause cause) ->
System.out.printf("Key %s was evicted (%s)%n", key, cause))
.removalListener((Key key, Graph graph, RemovalCause cause) ->
System.out.printf("Key %s was removed (%s)%n", key, cause))
.build();
你可以为你的缓存通过Caffeine.removalListener(RemovalListener)方法定义一个移除监听器在一个元素被移除的时候进行相应的操作。这些操作是使用 Executor 异步执行的,其中默认的 Executor 实现是 ForkJoinPool.commonPool() 并且可以通过覆盖Caffeine.executor(Executor)方法自定义线程池的实现。
当移除之后的自定义操作必须要同步执行的时候,你需要使用 Caffeine.evictionListener(RemovalListener) 。这个监听器将在 RemovalCause.wasEvicted() 为 true 的时候被触发。为了移除操作能够明确生效, Cache.asMap() 提供了方法来执行原子操作。
记住任何在 RemovalListener中被抛出的异常将会被打印日志 (通过Logger)并被吞食。
刷新
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.refreshAfterWrite(Duration.ofMinutes(1))
.build(key -> createExpensiveGraph(key));
刷新和驱逐并不相同。可以通过LoadingCache.refresh(K)方法,异步为key对应的缓存元素刷新一个新的值。与驱逐不同的是,在刷新的时候如果查询缓存元素,其旧值将仍被返回,直到该元素的刷新完毕后结束后才会返回刷新后的新值。
与 expireAfterWrite相反,refreshAfterWrite 将会使在写操作之后的一段时间后允许key对应的缓存元素进行刷新,但是只有在这个key被真正查询到的时候才会正式进行刷新操作。所以打个比方,你可以在同一个缓存中同时用到 refreshAfterWrite和expireAfterWrite ,这样缓存元素的在被允许刷新的时候不会直接刷新使得过期时间被盲目重置。当一个元素在其被允许刷新但是没有被主动查询的时候,这个元素也会被视为过期。
一个CacheLoader可以通过覆盖重写 CacheLoader.reload(K, V) 方法使得在刷新中可以将旧值也参与到更新的过程中去,这也使得刷新操作显得更加智能。
LoadingCache.refresh(K)可用于显式刷新条目,并在请求飞行时重复请求。返回的未来值可用于实现后备缓存,在后备缓存中,条目会被重新加载以从源获取最新值,但如果失败,则会查找并返回缓存值。
更新操作将会异步执行在一个Executor上。默认的线程池实现是ForkJoinPool.commonPool()当然也可以通过覆盖Caffeine.executor(Executor)方法自定义线程池的实现。
在刷新的过程中,如果抛出任何异常,都会使旧值被保留,并且异常将会被打印日志 (通过 System.Logger )并被吞食。
统计
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats()
.build();
通过使用Caffeine.recordStats()方法可以打开数据收集功能。Cache.stats()方法将会返回一个CacheStats对象,其将会含有一些统计指标,比如:
hitRate(): 查询缓存的命中率
evictionCount(): 被驱逐的缓存数量
averageLoadPenalty(): 新值被载入的平均耗时
这些统计指标在缓存的调优中十分重要,我们强烈的建议你在性能指标严格的程序中去留意这些统计指标。
这些缓存统计指标可以被基于push/pull模式的报告系统进行集成。基于pull模式的系统可以通过调用Cache.stats() 方法获取当前缓存最新的统计快照。一个基于push的系统可以通过自定义一个StatsCounter对象达到在缓存操作发生时自动推送更新指标的目的。
如果使用 Dropwizard Metrics 的话建议查看 metrics-caffeine 。
如果使用Prometheus的话可以尝试 prometheus-java。
如果实在难以选择的话可以尝试通过Micrometer来进行整合。
扩展
这里只简单介绍一下,关于Guava的扩展
// Guava的LoadingCache接口
LoadingCache<Key, Graph> graphs = CaffeinatedGuava.build(
Caffeine.newBuilder().maximumSize(10_000),
new CacheLoader<Key, Graph>() { // Guava'的CacheLoader
@Override public Graph load(Key key) throws Exception {
return createExpensiveGraph(key);
}
});
API兼容性
Caffeine 提供了适配器使缓存暴露Guava接口。这些适配器提供与Guava 相同的API规范。这些模仿Guava的操作已经经过Guava的测试组组件验证。
当迁移至Caffeine的接口的时候,记得注意虽然两个缓存之间可能存在相同名字的方法但是操作完全不同。请更仔细彻底的通过JavaDoc比较两者的用法。
最大容量 (或总权重)
Guava将通过LRU算法在到达最大容量之前就开始进行驱逐。Caffeine将通过Window TinyLFU算法在超过阈值之后进行清除。
立即过期
Guava 通过将最大容量设置为0的方式达到使缓存中的元素立即过期(expireAfterAccess(0, timeUnit) 和 expireAfterWrite(0, timeUnit))的效果。这在移除通知中的移除原因显示是由于容量而不是过期。Caffeine 则在移除原因中明确了是由于过期的原因。
替换通知
Guava中的元素被以任何原因替换的时候移除监听器都将收到通知。Caffeine 将不会通知相同引用性下的新旧值替换。
失效正在生成计算的元素
当元素正在生成计算中的时候,Guava将会忽略将其失效的请求。在Caffeine中的做法则是阻塞发起失效请求的线程直到生成计算结束,再将其移除。但是, 当通过invalidateAll()批量失效一批正在计算的元素的时候,可能由于底层hash表的抑制,导致这批元素的生成计算被直接跳过。当使用异步缓存的时候,失效操作将不会阻塞,而是直接移除缓存中的future,并由执行生成计算的线程发出移除通知。
异步维护
Guava 将在读写操作中分摊缓存中的维护操作。Caffeine 将通过配置的executor(默认: ForkJoinPool.commonPool()) 周期性地执行维护操作。也可以在调用线程中通过cleanUp()方法进行执行。
异步通知
Guava从队列中处理驱逐通知,任何一个调用线程都可以从这个队列中获取驱逐通知。Caffeine则交给配置的executor 去执行(默认: ForkJoinPool.commonPool())。
异步刷新
Guava 在请求刷新的线程中执行一个元素的重新生成计算。Caffeine则交给配置的executor 去执行(默认: ForkJoinPool.commonPool())。
生成计算结果为null
Guava生成计算元素的如果是null将会抛出异常,同时如果是在刷新的过程中出现这种情况,将会在缓存中保留这个元素。Caffeine 会直接返回null,如果实在刷新的过程中生成了null将会从缓存中移除这个元素。在使用了Guava适配器的情况下,Caffeine如果使用了Guava 的 CacheLoader接口的话将会选择和Guava一样的处理措施。
Map的生成计算方法
Guava在21.0版本之前所继承ConcurrentMap的默认方法(compute,computeIfAbsent,computeIfPresent和 merge)都是非原子性的。Caffeine实现了这些Java8新增功能的原子操作。
缓存统计
Guava中CacheStats.loadExceptionCount()和CacheStats.loadExceptionRate()方法在Caffeine中被重命名为CacheStats.loadFailureCount()和CacheStats.loadFailureRate()。因为在Caffeine生成计算结果null的时候将会被视为加载失败而不是当作异常处理。
Android 和 GWT 的兼容性
由于Caffeine对Java8的要求,将不兼容不支持Java8的平台。
性能
我们可以直接看看官方的性能测试图
100%读
100%写
原理初探
淘汰策略
淘汰策略是(Caffeine)的杀手锏,FIFO、LRU和LFU可能是大家耳熟能详的三大算法,其实,在这三大经典算法的基础之上,还衍生出了许多有趣的淘汰策略。
FIFO
FIFO(first in first out),先进先出是一种类似队列的算法,实现。在 FIFO 中,如果一个数据最先进入缓存中,则应该最早被淘汰。这是一种最简单的淘汰算法。它认为,最先进来的数据在未来被访问的概率也是最低的。但是缺点也非常明显,因为这种算法太“公平”了,它像排队一样,没有任何额外逻辑,能够尽可能的保证常用数据不被淘汰。
LRU
LRU(the last recently used)最近最久未使用算法, 他认为,如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的。当规定空间用尽且需要放入新数据时,它会优先淘汰最久未被访问的数据。
图片来源:链接
- 优势
- LRU 实现较为简单,在一般情况下,能够表现出很好的命中率,是一个"性价比"很高的算法。相较于FIFO,LRU 可以有效对访问比较频繁的数据进行保护,也就是针对热点数据的命中率有明显的提高效果。
- 劣势
- 如果一个不是经常使用的数据,偶尔或周期性的被使用,那么该数据会被加到 LRU 链表的头部, 而这种不经常使用的数据放在链表头部,不仅占用了空间,还需要一直等到 lru 淘汰完,才会被剔除出链表。如果这种数据一次性过多,那么整个链表数据都是这种无用数据,从而导致缓存命中率下降。因此,LRU 对偶发性、周期性的数据没有良好的抵抗力。
LFU
LFU(the last frequently used) 最近最少使用算法,他认为,如果一个数据在最近一段时间内使用次数很少,使用频率最低,那么在将来一段时间内,被使用的可能性也很小。它与 LRU 的区别在于,LRU 是以时间先后来衡量,LFU 是以时间段内的使用次数来衡量。
图片来源:链接
- 优势
- LFU 在一定程度上解决了 LRU 中偶发性周期性的数据造成污染的问题。因为 LFU 是以访问次数为基准,能够在一定程度上识别这些偶发性、周期性的冷数据。
- 劣势
- 首先,LFU 需要记录数据的访问频率,因此需要额外的空间。其次,需要给每个记录维护频率信息,每次访问都需要更新,这在高并引发下是个巨大的开销。
- 更大的问题在于,由于LFU 按照访问次数或者访问频率取胜,这个次数有一个累积的长周期,那么以前经常访问的数据,访问次数会很大,导致新来的数据哪怕是突发热点,也无法替代老的冷数据位置,这些老数据会一直占着位置,很难被替代。因此,LFU 应对稀疏性的突发流量不占优势
LRU和LFU的改进型
- WLFU
- WLFU 是他们的改进型,他同时考虑到时间和频率两个因素。
- LRU-K
- LRU-K结合了LRU和LFU的理念。在这个策略里面,每个元素的最后 K次 访问都被记录。通过使用这些数据,此算法从统计上预估到来的元素的瞬时频率,以便将最频繁的页面保存在内存中。
- ARC
- 自适应替换算法,这是一个比较有趣的算法,算法的大致原理如下:
- ARC 维护了 LRU 和 LFU两个列表,第一个记录的是仅被访问过一次元素,而第2个则是最近至少被访问过两次的元素。此外,ARC 为 LRU 和 LFU 分别设置了一个对应的 ghost 列表,最近被淘汰的元素会保存在 ghost 中。如果再次访问,同时在 ghost 中找这到该元素,那么ARC 将会对 LRU 或 LFU 的列表长度进行动态调整,使其特性更趋近于 LRU 或 LFU。
图片来源:链接
- LIRS
-
LIRS算法全称 low inter-rference Recency set, LIRS算法使用两个参数来衡量一个cache 块,分别是IRR(Inter-Reference Recency)和R(Recency),IRR为一个页面最近两次的访问间隔,当第一次被访问时IRR的值为无穷大(inf)。R为页面最近一次访问到当前时间内有多少页面曾经被访问过(LRU数值)。下面两张图为计算IRR和R值的方式和例子。
-
图片来源:链接
- SLRU
-
核心思路是保护那些出现至少两次的元素。
-
存划分为两部分,保护段和试用段。两个段内均独立使用 LRU 算法。
-
缓存中的元素优先进入试用段,若再次 hit 则加入保护段。保护段逐出元素时将元素加入试用段并更新该元素访问时间。如果试用队列满则正常按序将元素逐出。
-
- 2Q
- 有两个缓存队列,一个是FIFO队列,一个是LRU队列。当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。
TinyLFU
TinyLFU顾名思义,是在LFU上进行的改进。我们在前面的章节提到过LFU的两个显著问题:
- 维护元素访问频率的代价太大
- 一些老旧的高频元素会占着坑,并且这些元素难以被淘汰
TinyLFU就是为了解决这两个问题。
但是本质上,其实我们可以从图中看出,TinyLFU所处的位置,严格来说并不属于淘汰策略,而是准入策略。cache缓冲区可以采用其他的淘汰策略
W-TinyLFU
异步读写
总结
引用
本文大部分引用(不如说是纯复制抄袭)的来源:https://github.com/ben-manes/caffeine/wiki/Benchmarks-zh-CN