翻译自:https://github.com/google/guava/wiki/CachesExplained

Caches

Example

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
       .maximumSize(1000)
       .expireAfterWrite(10, TimeUnit.MINUTES)
       .removalListener(MY_LISTENER)
       .build(
           new CacheLoader<Key, Graph>() {
             public Graph load(Key key) throws AnyException {
               return createExpensiveGraph(key);
             }
           });

使用范围

缓存在多重场景下都非常有用。例如,当计算或者获取某个值的代价非常昂贵,并且你不止一次会获取它,那么这个时候,你应该考虑使用缓存。

CacheConcurrentMap 类似,但是不完全相同。最大的不同在于 ConcurrentMap 中的值需要手动移除。对于 Cache 来说,它可以自动驱除缓存的条目来减少对内存的占用。在某些场景下,LoadingCache 会非常有用,即使它不去删除条目,但是会自动加载缓存。

一般来说 Guava 缓存在以下场景中可以应用:

  • 通过内存来提高访问速度
  • 多次通过某个 key 来访问数据
  • 缓存中存储的数据不会比 RAM 还要多。(Guava 的缓存存在你本地的应用上。不会存在文件中,或者外部的服务器上。如果这个满足不了你的需求,请考虑 Memcached

如果上面这些都符合你的场景,那么 Guava 缓存非常适合你。

通过 CacheBuilder 生成器模式可以获取一个 Cahce,如上面的示例。但是定制自己的缓存是一个非常有趣的过程。(表示感觉不到)

注意:如果你不需要 Cache 提供的特性。那么 ConcurrentHashMap 有更高的内存效率。但是通过旧的 ConcurrentMap 难以甚至不可能复制 Cache 的特性。

总览

在使用缓存前,先问自己第一个问题:是否有默认的方法通过一个 key 去加载或者计算 value?如果有的话,你应该使用 CacheLoader,如果没有,或许你应该重写默认的方法,但是你依然想要原子的 “如果不存在则计算” 语义,那么你应该将 Callable 传递给 get 来调用。调用 Cache.put 可以直接将元素插入到缓存,但是自动加载缓存是首选,因为它更容易在所有内容上保持缓存一致性。

CacheLoader

LoadingCache 是一个附加了 CacheLoaderCache。创建一个 CacheLoader 最典型的方法就是实现方法 V load(K key) throws Exception。如下有一个创建 LoadingCache 的示例:

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
       .maximumSize(1000)
       .build(
           new CacheLoader<Key, Graph>() {
             public Graph load(Key key) throws AnyException {
               return createExpensiveGraph(key);
             }
           });

...
try {
  return graphs.get(key);
} catch (ExecutionException e) {
  throw new OtherException(e.getCause());
}

查询 LoadingCache 典型的方式为通过 get(K) 方法。这个方法将会返回一个已经存在的缓存值,或者通过 CacheLoader 自动的加载一个新的值到缓存中。CacheLoader 可能会抛出异常,LoadingCache.get(K) 会抛出 ExecutionException。(如果 CacheLoader 抛出一个 未检查异常get(K) 将会用一个 UncheckedExecutionException 包裹它。)你可以选择使用 getUnchecked(K),它将通过 UncheckedExecutionException 包裹所有的异常,但是这可能会导致一些奇怪的行为,因为 CacheLoader 会抛出可检查异常。

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
       .expireAfterAccess(10, TimeUnit.MINUTES)
       .build(
           new CacheLoader<Key, Graph>() {
             public Graph load(Key key) { // no checked exception
               return createExpensiveGraph(key);
             }
           });

...
return graphs.getUnchecked(key);

可以通过使用 getAll(Iterable<? extends K>) 来进行批量查询。默认情况下,getAll 会为每个 key 分别去调用 CacheLoader.load,如果该 key 对应的值在缓存中不存在。当批量查询比多次单词单独查询有效时,你可以选择重写 CacheLoader.loadAllgetAll(Iterable) 的性能将会相应的提高。

注意:你可以实现一个 CacheLoader.loadAll 来加载未被指定的 key 的 value。例如,如果你为组中一部分 key 计算 value,但是这些 value 是组中所有 key 的值,那么 loadAll 将会同时加载组中剩余的 key。

最后的这个注意事项翻译的贼别扭,看不懂的去看原文

Callable

所有的 Guava 缓存,不管是不是 loading cache,都支持 get(K, Callable<V) 方法。这个方法返回缓存中 key 对应的 value,或者通过指定的 Callable 方法重新计算 value,并插入到缓存中。在缓存完全加载之前,不会修改缓存的可观察状态。这个方法为传统的 “如果缓存了,则返回,否则就就创建,然后缓存并返回” 模式提供了一个简单的替代方法。

Cache<Key, Value> cache = CacheBuilder.newBuilder()
    .maximumSize(1000)
    .build(); // look Ma, no CacheLoader
...
try {
  // If the key wasn't in the "easy to compute" group, we need to
  // do things the hard way.
  cache.get(key, new Callable<Value>() {
    @Override
    public Value call() throws AnyException {
      return doThingsTheHardWay(key);
    }
  });
} catch (ExecutionException e) {
  throw new OtherException(e.getCause());
}

直接插入

通过 cache.put(key, value) 可以直接将数据插入到缓存中。该方法会重写指定的 key 在缓存中所对应的键值对。Cache.asMap() 暴露的视图,可以通过 ConcurrentMap 方法对缓存进行更改。注意,asMap 视图上的任何方法都不会导致缓存自动加载。而且,该视图上面的原子操作超出了缓存自动加载的范围。所以,不管是通过 CacheLoaderCallableCache.get(K, Callable<V) 应该总是优于 Cache.asMap().putIfAbsent

缓存回收

现实情况是,我们没有足够的内存来缓存所有的东西。你必须决定什么东西值得缓存?Guava 提供了三中类型的回收策略:基于大小,基于时间,基于引用。

(基于大小) Size-based Eviction

如果你不想让你的缓存超过固定大小,可以使用 CacheBuilder.maximumSize(long)。缓存将会尝试回收最近或者经常没有被用到的数据。警告:缓存可能会在超过固定大小之前进行回收工作 — 典型的情况是缓存的大小接近限定的大小。

如果缓存条目有不同的 “权重”。例如:如果你缓存有完全不同的内存占用。你应该指定权重函数 CacheBuilder.weigher(Weigher) 与最大缓存权重 CacheBuilder.maximumWeight(long)。除了与 maximumSize 的警告相同意外,还需要注意每个条目的权重在创建时计算,之后都是不变的。

(基于时间) Timed Eviction

CacheBuilder 提供了两个方法去通过时间回收:

  • expireAfterAccess(long, TimeUnit) 当一个条目最近被读取或者写入,在经过了指定时间之后将会过期。注意,决定回收哪个条目与基于大小的策略类似。
  • expireAfterWrite(long, TimeUnit) 当一个条目被创建或最近一次被替换,在经过了指定时间之后将会过期。如果缓存在经过一段时间之后变得陈旧,该策略是可取的。

在周期性的写以及偶尔性的读之间执行定时过期,如下所述。

基于时间回收的测试

基于时间回收的测试不会变得很痛苦,你不需要花两秒钟来测试一个两秒钟的过期时间。在你的 cache builder 中使用 Ticker 接口以及 CacheBuilder.ticker(Ticker) 方法,而不是等待系统的时间。

(基于引用) Reference-based Eviction

Guava 允许你设置缓存让垃圾回收器可以进行回收,通过使用弱引用的 key 或者 value,以及软引用的 value。

  • CacheBuilder.weakKeys() 通过弱引用来存储 key。如果条目没有其他对 key 的引用 (强引用或者软引用),允许垃圾回收器进行回收。因为垃圾回收器依赖于相等的标识,所以导致整个缓存都是用 (==) 来比较 key,而不是通过 equals()
  • CacheBuilder.weakValues() 通过弱引用来存储 value。如果在没有其它引用(强引用或者软引用)的情况下,允许垃圾回收器来进行回收。因为垃圾回收器依赖于相等的标识,所以导致整个缓存都是用 (==) 来比较 key,而不是通过 equals()
  • CacheBuilder.softValues() 通过软引用对 value 进行包装。垃圾回收器根据内存的需要,通过最近最少使用方式对软引用对象进行回收。由于使用软引用时的性能影响,所以我们建议指定缓存的最大大小。使用 softValues() 将会导致 value 间的比较使用 (==) 而不是 equals()

删除缓存

在任何时候,我们都可以显式的指定缓存条目无效,而不用等待条目自动过期。

可以使用如下方法:

移除监听器

你可以通过 CacheBuilder.removalListener(RemovalListener) 指定一个移除监听器,在缓存条目失效的时候做些其它的操作。RemovalListener 通过传递一个 RemovalNotification 来获取 RemovalCause,key 以及 value。

注意 RemovalListener 跑出来的任何异常可以被打印(使用 Logger)或者被吞掉。

CacheLoader<Key, DatabaseConnection> loader = new CacheLoader<Key, DatabaseConnection> () {
  public DatabaseConnection load(Key key) throws Exception {
    return openConnection(key);
  }
};
RemovalListener<Key, DatabaseConnection> removalListener = new RemovalListener<Key, DatabaseConnection>() {
  public void onRemoval(RemovalNotification<Key, DatabaseConnection> removal) {
    DatabaseConnection conn = removal.getValue();
    conn.close(); // tear down properly
  }
};

return CacheBuilder.newBuilder()
  .expireAfterWrite(2, TimeUnit.MINUTES)
  .removalListener(removalListener)
  .build(loader);

提醒:移除监听器默认是同步 (synchronously) 执行的。因为缓存的维护工作是在缓存的正常操作期间执行的。代价比较高的移除监听器操作会影响缓存的正常功能。如果你有一个代价比较高的移除监听器,使用 RemovalListeners.asynchronous(RemovalListener, Executor) 包装 RemovalListener,让它异步执行。

缓存清理发生的时机

CacheBuilder 构建的缓存不会自动清理过期的 values,也不会在 value 失效后就马上清理,或者其它类似的事情。相反的,它会在写操作期间,执行少量的维修工作,或者在写操作很少的情况下,偶尔读的时候执行维修工作。

这样做的原因如下:如果我们频繁的执行 Cache 的维修工作,我需要去创建一个线程,它的操作会与用户的操作竞争共享锁。另外,某些环境下严格限制了线程创建的数量,这将导致 CacheBuilder 在这种情况下不能使用。

相反,我们把选择权交到你的手里。如果你的缓存是高吞吐量的,那么你不用去担心执行清理缓存中过期条目之类的维护工作。如果你的缓存很少写,并且你不想清理来组织缓存的读。你可以通过定期的调用 Cache.cleanUp() 来创建你自己的维护线程。

如果你想为一个很少写的缓存定期的执行维护工作,可以使用 ScheduledExecutorService 来执行定期维护。

刷新 (Refresh)

刷新跟移除不一样。在 LoadingCache.refresh(K) 指定一个 key,为这个 key 加载一个新值,可能会是异步加载。当 key 被刷新时,会返回一个旧值。与移除相反的是,刷新会强制查询等待,直到新值被加载。

如果在刷新期间发生了异常,那么旧值会保留,异常会被打印并被吞掉。

使用刷新的时候,通过重写 CacheLoader.reload(K, V) 来为 CacheLoader 指定一个明智的行为,这将允许你在计算新值的时候使用旧值。

// 一些 key 是不需要被刷新的,我们希望刷新是异步执行的
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
       .maximumSize(1000)
       .refreshAfterWrite(1, TimeUnit.MINUTES)
       .build(
           new CacheLoader<Key, Graph>() {
             public Graph load(Key key) { // 非检查异常
               return getGraphFromDatabase(key);
             }

             public ListenableFuture<Graph> reload(final Key key, Graph prevGraph) {
               if (neverNeedsRefresh(key)) {
                 return Futures.immediateFuture(prevGraph);
               } else {
                 // asynchronous!
                 ListenableFutureTask<Graph> task = ListenableFutureTask.create(new Callable<Graph>() {
                   public Graph call() {
                     return getGraphFromDatabase(key);
                   }
                 });
                 executor.execute(task);
                 return task;
               }
             }
           });

自动定时刷新通过使用 CacheBuilder.refreshAfterWrite(long, TimeUnit) 添加到缓存中。与 expireAfterWrite 相反的是 refreshAfterWrite 会指定时间之后适当的对 key 进行刷新,但是刷新真正的初始化是在条目被查询的时候。(如果 CacheLoader.reload 的实现为异步,那么查询不会减慢刷新的速度)。所以,你可以在同一个缓存中同时指定 refreshAfterWriteexpireAfterWrite。因此,当一个条目可以被刷新的时候,它的过期计时器不会被盲目被重置。所以,一个条目在和可以被刷新的时候没有被查询,将会过期。

特点

统计

通过 CacheBuilder.recordStats(),你可以开启对 Guava cache 的统计。Cache.stats() 方法返回一个 CacheStats 对象,提供了如下的统计方式:

除了这些之外还有许多统计方式。这些统计可以用来缓存调优。我们建议在对性能有要求的应用上对这些统计保持关注。

asMap

你可以使用 asMap 视图将 Cache 当作 ConcurrentMap 来查看。但是 asMap 视图与 Cache 的相互作用需要一些解释:

  • cache.asMap() 包含当前缓存中已经加载的条目。所以,cache.asMap().keySet() 包含当前所加载的所有的 key。
  • asMap().get(key) 本质上跟 cache.getIfPresent(key) 相等,并且不会导致值重新被加载。这与 Map 是一致的。
  • 缓存的读写操作会重置访问时间 (包括 Cache.asMap().get(Object)Cache.asMap().put(K, V)),但是 containsKey(Object) 不会,同样的 Cache.asMap() 也不会。所以,通过 cache.asMap().entrySet() 来迭代,在查询的时候不会重置访问时间。

中断 (Interruption)

加载方法 (像 get) 永远不会抛出 InterruptedException。我们可以设计这些方法支持 InterruptedException,但是我们的支持还不完整。将成本强加给用户,但是只会给少部分用户带来好处。详情如下:

get 调用请求不会缓存值分成两大类:加载值以及等待另一个线程加载。不同点在于我们对中断的支持。最简单的方式是等待另一个线程去加载:这时我们可以进入一个可中断的等待。最困难的方式是我们自己加载值,这时我们依赖用户提供的 CacheLoader。如果支持中断,那么我们就支持,否则的话,就不支持。

所以为什么我们不提供一个支持中断的 CacheLoader?从某种意义上来说,我们做了 (见下文):如果 CacheLoader 抛出 InterruptedException,那么所有对 key 的 get 调用会迅速返回(就像其它的异常一样)。另外,get 会恢复加载线程中的中断位。令人惊讶的是,InterruptedException 被包裹在 ExecutionException 内。

原则上来说,我们不能为你包裹异常信息。但是,这将会强制所有使用 LoadingCache 的用户去处理 InterruptedException,即使大多数 CacheLoader 实现从来不会抛出它。但是考虑所有非加载的线程在等待时可能被中断,那么这可能是有意义的。但是大多数的缓存都是应用在单线程中。它们的使用者仍然需要捕获不可能发生的 InterruptedException。即使使用者在多线程中共享缓存数据,有时候使用者中断 get 调用,也是基于哪个线程先发出请求。

我们基于这个决定的指导方针是缓存的表现就像所有的值在调用的线程中加载的一样。这个原理将缓存引进到代码中变得简单,该代码在每次调用之前都会重新计算它的值。如果旧的代码不能被中断,那么新的代码也不会被中断。

“在某种意义上”,我说我们支持中断。从一个角度来说,我们不支持中断,这让 LoadingCache 成为了一种有漏洞的抽象。如果加载线程被中断了,我们像处理其它异常一样处理它。这在大多数的情况下是有用的,但是在多个线程调用 get 方法等待获取值时,这是不对的。虽然计算该值的操作被中断了,但是获取该值的操作却没有。然而,所有的这些调用者都会收到 InterruptedException (包裹在 ExecutionException 中),尽管并没有像 “取消” 一样,有这么多的 “失败”。正确的行为应该是剩余线程其中一个重新加载。所以,这有一个 bug。但是,修复是有风险的(所以一个 2014 的 bug,现在还没有修复 🙄)。所以我们并不打算修复这个问题,而是计划将精力放在 AsyncLoadingCache 上,它将返回一个 Future 对象,并且具有正确的中断行为。