新一代本地缓存—Caffeine


谈及本地缓存时,大家对HashMap以及Ehcache、Guava Cache等技术都会耳熟能详,他们都有各自的优缺点,比如GuavaCache是基于LRU算法的、Ehcache可保存内容到磁盘里等等,这里就不展开详细阐述,今天蓝猫要介绍的是一款后起之秀的缓存—Caffeine,同时它的创始人也是我们JDK工具类中的ConcurrentLinkedHashMap以及Guava Cache的作者,由于Caffeine的性能更优秀,在Spring Boot 2.0后官方也开始使用Caffeine替代Guava Cache作为默认的缓存组件。


1、什么是Caffeine

Caffeine是一款基于JAVA8的高性能、提供最佳命中率的缓存库。与ConcurrentMap相似,但又不完全一样,最根本区别是ConcurrentMap会保留所有添加的元素,直到明确地将元素删除,另一方面缓存通常会被配置成自动删除元素,目的是为了限制内存占用量。在某些情况下,LoadingCache或AsyncLoadingCache虽然不会自动地移除元素,但它还是非常有用的,因为它会自动缓存加载。

Caffeine提供了灵活的构造器创建缓存,并提供以下特性:

  • 自动化加载元素到缓存,并可选择异步化模式

  • 当缓存容量达到最大值时基于容量大小移除元素,而元素又基于访问频率(LFU)及最近访问次数(LRU)来移除

  • 通过上次访问或上次写入的规则来确保元素基于时间过期

  • 当第一次访问缓存时,可异步性地刷新

  • 缓存的Keys自动地被弱引用包装

  • 缓存的Values自动地被虚引用或软引用自动包装

  • 当元素被移除时(或者其他被删除),可收到通知

  • 缓存访问统计信息的汇总

  • 缓存写入时,数据可传播到外部资源中,如存储资源或者二级缓存

为改善整合,在额外的模块中提供了JSR-107 JCache与Guava适配器,JSR-107标准化了基于JAVA6的API,以牺牲功能和性能为代价最小化具体的代码,而Guava Cache是其前身的库,适配器提供了简单的迁移方式。


2、访问

Caffeine提供了四种类型的创建策略:手动、同步加载、异步化(手动)及异步化加载

  • 手动
 1Cache<Key, Graph> cache = Caffeine.newBuilder()
 2    .expireAfterWrite(10, TimeUnit.MINUTES)
 3    .maximumSize(10_000)
 4    .build();
 5// Lookup an entry, or null if not found
 6Graph graph = cache.getIfPresent(key);
 7// Lookup and compute an entry if absent, or null if not computable
 8graph = cache.get(key, k -> createExpensiveGraph(key));
 9// Insert or update an entry
10cache.put(key, graph);
11// Remove an entry
12cache.invalidate(key);

Cache接口提供了显式地对元素的查找、更新及失效的控制,元素可通过cache.put(key, value)的方式直接添加到缓存中,这会直接覆盖对应Key中的旧值,更好的方式是使用cache.get(key, k -> value)方式自动的计算与添加值到缓存中,这可避免与其他写入的现成发生竞争。

如果元素不可计算,则cache.get方法可能返回null,或者如果计算失败时则抛出异常,当然也可通过Cache.asMap()视图暴露ConcurrentMap方法对缓存进行更改。

  • 同步加载
1LoadingCache<Key, Graph> cache = Caffeine.newBuilder()
2    .maximumSize(10_000)
3    .expireAfterWrite(10, TimeUnit.MINUTES)
4    .build(key -> createExpensiveGraph(key));
5// Lookup and compute an entry if absent, or null if not computable
6Graph graph = cache.get(key);
7// Lookup and compute entries that are absent
8Map<Key, Graph> graphs = cache.getAll(keys);

LoadingCache是使用关联的CacheLoader构建的缓存,可通过getAll方法进行批量的查找,默认情况下如果缓存中不存在对应的Key,getAll方法则将会对CacheLoader.load方法发出单独调用,如果批量查询的效率比单个查询高,则我们可以利用这点重新CacheLoader.loadAll方法。

  • 异步化(手动)
1AsyncCache<Key, Graph> cache = Caffeine.newBuilder()
2    .expireAfterWrite(10, TimeUnit.MINUTES)
3    .maximumSize(10_000)
4    .buildAsync();
5
6// Lookup and asynchronously compute an entry if absent
7CompletableFuture<Graph> graph = cache.get(key, k -> createExpensiveGraph(key));

AsyncCache是缓存的变体,它通过Execetor执行器计算元素及返回CompletableFuture,这允许通过流行的响应式编程模型使用缓存。

synchronous()视图提供了一个阻塞的缓存,直至异步计算完成,同时也可通过AsyncCache.asMap()视图暴露ConcurrentMap方法对缓存进行更改,ForkJoinPool.commonPool()是默认执行器,通过Caffeine.executor(Executor)方法可重新覆盖重写。

  • 异步加载
 1AsyncLoadingCache<Key, Graph> cache = Caffeine.newBuilder()
 2    .maximumSize(10_000)
 3    .expireAfterWrite(10, TimeUnit.MINUTES)
 4    // Either: Build with a synchronous computation that is wrapped as asynchronous 
 5    .buildAsync(key -> createExpensiveGraph(key));
 6    // Or: Build with a asynchronous computation that returns a future
 7    .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));
 8
 9// Lookup and asynchronously compute an entry if absent
10CompletableFuture<Graph> graph = cache.get(key);
11// Lookup and asynchronously compute entries that are absent
12CompletableFuture<Map<Key, Graph>> graphs = cache.getAll(keys);

AsyncLoadingCache是个异步缓存,通过AsyncCacheLoader构建,当以同步的方式计算时,应该提供CacheLoader;当以异步的方式计算机返回CompletableFuture返回结果时,应该提供一个AsyncCacheLoader。


3、驱逐

Caffeine提供了三种移除元素的方式:基于容量驱逐、基于时间驱逐、基于引用驱逐。

  • 基于容量驱逐
 1// Evict based on the number of entries in the cache
 2LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 3    .maximumSize(10_000)
 4    .build(key -> createExpensiveGraph(key));
 5
 6// Evict based on the number of vertices in the cache
 7LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 8    .maximumWeight(10_000)
 9    .weigher((Key key, Graph graph) -> graph.vertices().size())
10    .build(key -> createExpensiveGraph(key));

如果你的缓存不会超过一定的大小,则应该使用Caffeine.maximumSize(long)设置最大容量,缓存将会驱逐那些最近或经常未使用的元素,或者如果不同的缓存元素需要有不同的权重—例如缓存中的值占用不同的内存,可以使用指定权重的函数Caffeine.weigher(Weigher)及Caffeine.maximumWeight(long)设置最大权重函数,另外需要注意的是权重是在元素创建及更新时被计算的,而在元素被驱逐时并不会使用权重。

  • 基于时间驱逐
 1// Evict based on a fixed expiration policy
 2LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 3    .expireAfterAccess(5, TimeUnit.MINUTES)
 4    .build(key -> createExpensiveGraph(key));
 5LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 6    .expireAfterWrite(10, TimeUnit.MINUTES)
 7    .build(key -> createExpensiveGraph(key));
 8
 9// Evict based on a varying expiration policy
10LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
11    .expireAfter(new Expiry<Key, Graph>() {
12      public long expireAfterCreate(Key key, Graph graph, long currentTime) {
13        // Use wall clock time, rather than nanotime, if from an external resource
14        long seconds = graph.creationDate().plusHours(5)
15            .minus(System.currentTimeMillis(), MILLIS)
16            .toEpochSecond();
17        return TimeUnit.SECONDS.toNanos(seconds);
18      }
19      public long expireAfterUpdate(Key key, Graph graph, 
20          long currentTime, long currentDuration) {
21        return currentDuration;
22      }
23      public long expireAfterRead(Key key, Graph graph,
24          long currentTime, long currentDuration) {
25        return currentDuration;
26      }
27    })
28    .build(key -> createExpensiveGraph(key));

Caffeine提供三种基于时间驱逐元素的方法:

  1. expireAfterAccess(long, TimeUnit):从元素在最后通过读或写访问后经过指定的连续时间使元素过期,当缓存中的数据绑定到会话且由于不活跃而过期的则该方法是可取的。

  2. expireAfterWrite(long, TimeUnit):从元素被创建或者值被更新后经过指定的连续时间使元素过期,当缓存中的数据在一定的时间后变得过期则该方法是可取的。

  3. expireAfter(Expiry):在可变的持续时间过后使元素过期,当元素的过期时间取决于外部资源的,则该方法是可取的。

在写入过程中与偶尔的读过程中,过期时间是会被定期性的处理,定时及触发某个过期事件的时间复杂度是O(1) ,为了能准时过期,而不是依赖于其他的缓存活动去触发常规维护,应该使用Scheduler接口与Caffeine.scheduler(Scheduler)方法在缓存构建器中设置具体的调度线程,对于JAVA9的使用者而言或许会更加喜欢使用Scheduler.systemScheduler()的方式专门利用系统内的调度线程。

基于时间驱逐方式的测试不要求一直阻塞直至时间过去,应该使用Ticker接口及Caffeine.ticker(Ticker)方法在缓存构建器中设置一个具体的时间源,而不是应该等待系统的时钟,为此Guava的测试库中提供了方便的FakeTicker。

  • 基于引用的驱逐
 1// Evict when neither the key nor value are strongly reachable
 2LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 3    .weakKeys()
 4    .weakValues()
 5    .build(key -> createExpensiveGraph(key));
 6
 7// Evict when the garbage collector needs to free memory
 8LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 9    .softValues()
10    .build(key -> createExpensiveGraph(key));

Caffeine可通过指定Key键或Value值为虚引用、指定Value值为软引用的方式设置缓存,以达到允许垃圾回收缓存的元素,需要注意的是AsyncCache是不支持指定Value值为虚引用及软引用的。

Caffeine.weakKeys()方法中使用虚引用存储Key,当这些Keys没有强引用时,对应的这些元素被垃圾回收,但由于垃圾回收仅取决于身份的相等性,因此导致整个缓存需要使用==的方式比较Keys,而不是使用equals()方法。

Caffeine.weakValues()方法中使用虚引用存储Value值,当这些Value值没有强引用的时候,对应的这些元素将会被垃圾回收,但由于垃圾回收仅取决于身份的相等性,因此导致整个缓存需要使用==的方式比较Values,而不是使用equals()方法。

Caffeine.softValues()方法中使用软引用存储Value值,软引用的对象会以最近最少使用的方式全局性的被垃圾回收,已响应虚拟机内存的需求。由于使用软引用对性能的影响,因此我们一般推荐通过预测最大缓存的大小的方式来使用缓存。使用softValues()方法将会导致Value值使用==的方式比较Values,而不是使用equals()方法.


4、清除

  • 术语:

驱逐意味着元素是由于策略而被驱逐

无效意味着元素调用者手动删除

清除作为无效或驱逐的结果

  • 显式清除

任何时候,我们可以显式地使缓存元素失效而不是等待元素被驱逐

1// individual key
2cache.invalidate(key)
3// bulk keys
4cache.invalidateAll(keys)
5// all keys
6cache.invalidateAll()
  • 清除监听器
1Cache<Key, Graph> graphs = Caffeine.newBuilder()
2    .removalListener((Key key, Graph graph, RemovalCause cause) ->
3        System.out.printf("Key %s was removed (%s)%n", key, cause))
4    .build();

当某个元素被清除时,我们可以为缓存定义一个具体的清除监听器来执行一些操作,如Caffeine.removalListener(RemovalListener),RemovalListener可获取旧的Key键及Value值、清除原因。

移除监听器操作是被Executor异步执行的,默认的执行器是ForkJoinPool.commonPool()且可以重写Caffeine.executor(Executor),当操作必须与删除操作同步执行时,请改用CacheWriter。


5、刷新

1LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
2    .maximumSize(10_000)
3    .refreshAfterWrite(1, TimeUnit.MINUTES)
4    .build(key -> createExpensiveGraph(key));

与驱逐不太一样,LoadingCache.refresh(K)刷新Key会异步加载该Key对应的值,当该Key正在刷新时将会返回Key对应的旧值,与驱逐相反,驱逐强制查找要等到重新加载该值。

与expireAfterWrite方法不一样的是,refreshAfterWrite可以使key在具体的持续时间后符合条件刷新,但实际上只有在元素被查询的时候才会执行刷新,因此可以同时设置缓存的expireAfterWrite与refreshAfterWrite方法,保证过期计时器在元素符合条件刷新的时候不会盲目地重置,如果元素符合刷新的条件后没有被查询,那它将会被设置为过期。

CacheLoader可以重写CacheLoader.reload方法来指定刷新时的特定性为,这时若正在计算新的值,则查询将会返回旧的值,刷新操作是被Executor异步地执行,默认执行器是ForkJoinPool.commonPool()且我们可以通过Caffeine.executor(Executor)重写,如果在刷新值时抛出异常,则旧的值会继续保留在缓存中且异常将会被Logger记录下来。


6、写入器

 1LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
 2  .writer(new CacheWriter<Key, Graph>() {
 3    @Override public void write(Key key, Graph graph) {
 4      // write to storage or secondary cache
 5    }
 6    @Override public void delete(Key key, Graph graph, RemovalCause cause) {
 7      // delete from storage or secondary cache
 8    }
 9  })
10  .build(key -> createExpensiveGraph(key));

缓存写入器允许缓存对底层的资源充当为门面,当与CacheLoader联合使用时,所有的读与写操作都可通过缓存传播,写入器在缓存基础上拓展了原子性操作,包括与外部资源的同步方面,这意味着缓存将会阻塞元素后面变化的运算且读操作将会返回旧的值,直至写入操作完成,如果写入器失败则映射将保持不变,并且异常将会传播给调用方。

当元素被创建、发生变化或者被删除的时候,CacehWriter将会收到通知,但是如果元素是通过LoadingCache.get()方法加载的、LoadingCache.refresh()方法重新加载或者通过Map.computeIfPresent()方法计算的映射是不会被传达通知的,需要注意的是CacheWriter不能与虚引用的Key或AysncLoadingCache一起使用的。

  • 可能的用例

CacheWriter是复杂工作流程的一个拓展点,这些流程需要外部资源观察给定Key的变化顺序,Caffeine支持以下的用法,但不是本地内置的:

  • 写入模式

CacheWriter可用于实现直接写或者回写缓存。

在直写式缓存中操作是同步执行的,并且当写入器成功完成时才会更新缓存,这避免在资源与缓存被原子性更新时出现条件竞争。

在回写缓存中对外部资源的操作在完成缓存更新后是异步执行,这样可以提高吞吐量但会存在数据不一致的风险,例如在写失败时会保留无效的状态,这种方法也许对写入操作延迟到一定时间、限制写入速率、批量写入会很有用。

回写缓存的拓展实现了以下某些特性:

  1. 批量、合并操作
  2. 延迟操作至某个时间点
  3. 超过阈值大小,则在定期刷新前执行批处理
  4. 如果操作尚未刷新,则从后写缓冲中加载
  5. 重试、限速率、并发取决于外部资源的特点
  • 分层

CacheWriter可用于集成多个缓存层。

分层缓存从系统支持的外部缓存中加载及写入,它允许有一块快速小缓存,用于回退到慢速大缓存中,典型的是堆外、基于文件和远程缓存。

Victim缓存是个分层缓存变体,它被驱逐的元素将被写入到二级缓存中,delete(K, V, RemovalCause)方法允许检查为什么元素被删除且做出相应的操作。

  • 同步监听器

CacheWriter可用于发布到同步的监听器中。

一个同步的监听器按照给定的Key发生的操作接收事件通知,监听器会阻塞缓存操作或对异步执行的事件进行排队,这种类型的监听器常用于复制或构造分布式缓存。


7、统计

1Cache<Key, Graph> graphs = Caffeine.newBuilder()
2    .maximumSize(10_000)
3    .recordStats()
4    .build();

通过使用Caffeine.recordStats(),我们可以统计信息收集,Cache.stats()方法将返回一个CacheStats,用于提供以下的统计数据:

  • hitRate():返回请求命中率
  • evictionCount():缓存被驱逐的数量
  • aveageLoadPenalty():加载值的平均耗时

这些统计数据对于缓存调整非常重要,建议在对性能重要的程序中关注这些统计信息。缓存数据可通过基于推或拉的方式集成到报表系统中,基于拉的方法会定期的调用Cache.stats()方法并记录最新的快照版本,基于推的方法会提供一个自定义的StatsCounter,用于在缓存操作期间指标能直接被更新,如果系统中使用了Prometheus可尝试用simpleclient-caffeine接入。


8、清理

默认情况下,Caffeine在某个值过期后不会自动地或立刻地执行清理、驱逐元素,相反地如果写操作很少,则会在写入操作后或偶尔读操作后执行少量的维护操作,即使是高吞吐量的缓存,那么也不必担心类似执行清理过期元素的维护操作,如果缓存是很少读、写操作的,可以利用外部的线程在适当时调用Cache.cleanUp()方法。

1LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
2    .scheduler(Scheduler.systemScheduler())
3    .expireAfterWrite(10, TimeUnit.MINUTES)
4    .build(key -> createExpensiveGraph(key));

Scheduler定时器也许可提供过期元素的清理能力,在过期事件期间的调度目的是利用批处理及在短暂时间内减少执行操作,调度器尽最大努力但不会保证什么时候删除过期的元素,JAVA9用户可能更喜欢用Scheduler.systemScheduler()利用专用的、系统范围内的调度线程。

1Cache<Key, Graph> graphs = Caffeine.newBuilder().weakValues().build();
2Cleaner cleaner = Cleaner.create();
3cleaner.register(graph, graphs::cleanUp);
4graphs.put(key, graph);

清理器Cleaner可能会在JAVA9以后使用,使能够迅速移除基于引用(使用虚引用的Key、虚引用的Value值或软应用Value值)的元素,只需要简单地在Cleaner注册Key或者Value值,只要在可运行的逻辑中调用Cache.cleanUp()就会触发维护工作。


9、策略

缓存支持的策略在构造缓存时是已经固定的,在运行的时候可以检查和调整此配置,这些策略是可以通过Optional获取的以表示缓存是否支持该特性。

  • 基于容量
1cache.policy().eviction().ifPresent(eviction -> {
2  eviction.setMaximum(2 * eviction.getMaximum());
3});

若缓存受到最大权重的限制,则可通过weightedSize()方法获得当前的权重,这与用于获得存在多少元素的Cache.estimatedSize()方法不同,最大容量或权重可通过getMaximum()方法获得,并且可通过setMaximum(long)方法调整设置,若缓存超过最大阈值的话则会被驱逐元素,如果需要尽可能地保留元素子集或使其被被驱逐,则hottest(int)以及 coldest(int) 方法可以获取元素的有序快照。

  • 基于时间
1cache.policy().expireAfterAccess().ifPresent(expiration -> ...);
2cache.policy().expireAfterWrite().ifPresent(expiration -> ...);
3cache.policy().expireVariably().ifPresent(expiration -> ...);
4cache.policy().refreshAfterWrite().ifPresent(expiration -> ...);

ageOf(key, TimeUnit)方法提供了从expireAfterAccess、expireAfterWrite或者refreshAfterWrite策略中获取一个元素空闲了多久的功能,最长持续时间可以从getExpiresAfter(TimeUnit)方法获取及使用setExpiresAfter(long,TimeUnit)调整此时间。

如果需要尽可能地需要保留元素子集或使其过期,则通过youngest(int)与oldest(int)方法可以获取元素的有序快照。


10、如何测试

 1FakeTicker ticker = new FakeTicker(); // Guava's testlib
 2Cache<Key, Graph> cache = Caffeine.newBuilder()
 3    .expireAfterWrite(10, TimeUnit.MINUTES)
 4    .executor(Runnable::run)
 5    .ticker(ticker::read)
 6    .maximumSize(10)
 7    .build();
 8
 9cache.put(key, graph);
10ticker.advance(30, TimeUnit.MINUTES)
11assertThat(cache.getIfPresent(key), is(nullValue()));

基于时间驱逐方式的测试不要求一直阻塞直至时间过去,应该使用Ticker接口及Caffeine.ticker(Ticker)方法在缓存构建器中设置一个具体的时间源,而不是应该等待系统的时钟,为此Guava的测试库中提供了方便的FakeTicker。由于在维护周期内需要移除已过期的元素,因此当测试取决于元素驱逐时,使用Cache.cleanUp()方法立即出发某个元素。

Caffeine将定期维护、移除通知和异步计算都委托给Executor执行器,这在公平对待调用者与使用默认的ForkJoinPool.commonPool()的前提下提供了更可预测的响应时间,使用Caffeine.executor(Executor)方法在缓存构造器中直接指定Executor执行器,而不需等待异步任务的完成。


11、最后总结

Caffeine是一款基于JAVA8开发、高性能、提供最佳命中率的缓存库,Caffeine的创始人也是ConcurrentLinkedHashMap与Guava Cache的作者,由于在代码风格上还是功能易用性都与Guava Cache非常的类似,因此可无缝地兼容Guava Cache,至于为什么Caffeine的性能比Guava Cache的更上一层楼,蓝猫会在后续的文章讲解原因。