Guava 是 Google 提供的一套 Java 工具包,而 Guava Cache 是该工具包中提供的一套完善的 JVM 级别高并发缓存框架;本文主要介绍它的相关功能及基本使用,文中所使用到的软件版本:Java 1.8.0_341、Guava 32.1.3-jre。

1、简介

缓存在很多情况下非常有用。例如,当某个值的计算或检索代价很高,并且你需要在特定输入下多次使用该值时,就应该考虑使用缓存。

Guava Cache 与 ConcurrentMap 类似,但并不完全相同。最基本的区别在于,ConcurrentMap 会一直保存所有添加到其中的元素,直到显式地将它们删除。而 Guava Cache 通常会配置自动删除条目,以限制其内存占用。在某些情况下,即使不删除条目,LoadingCache 也很有用,因为它具有自动加载条目的功能。

通常情况下,Guava Cache 适用于以下情况:

  • 你愿意花费一些内存来提高速度。
  • 你预期某些键有时会被多次查询。
  • 你的缓存不需要存储超过内存容量的数据。(Guava Cache 是局限于应用程序运行期间的本地缓存。它们不会将数据存储在文件或外部服务器上。如果这不符合你的需求,可以考虑使用像Memcached 这样的工具。)

如果你的情况符合上述每一点,那么 Guava Cache 可能适合你。

注意:如果你不需要缓存的特性,ConcurrentHashMap 在内存效率方面更高——但是使用任何 ConcurrentMap 几乎不可能复制大多数 Guava Cache 的特性。

2、数据加载

使用 Guava Cache 时,首先要问自己一个问题:是否有合理的默认函数来加载或计算需缓存的数据?如果是这样,应该使用 CacheLoader。如果没有,或者需要覆盖默认函数,但仍然希望具有原子的“如果不存在则计算并获取”语义,你应该将一个 Callable 对象传递给 get 方法。可以直接使用 Cache.put 方法插入元素,但更推荐自动加载数据,因为这样可以更容易地推断所有缓存内容的一致性。

2.1、CacheLoader

LoadingCache 是一个带有 CacheLoader 的缓存。创建 CacheLoader 很容易,只需要实现方法 V load(K key) throws Exception 即可。

LoadingCache loadingCache = CacheBuilder.newBuilder()        .maximumSize(1000)        .build(new CacheLoader() {            @Override            public String load(Long key) throws Exception {                //TODO: 根据业务加载数据                return RandomStringUtils.randomAlphanumeric(10);            }        });try {    log.info(loadingCache.get(1L));} catch (ExecutionException e) {    e.printStackTrace();}

LoadingCache 使用 get(K) 方法来获取数据。该方法要么返回已缓存的值,要么使用 CacheLoader 来原子地加载一个新值到缓存中。由于 CacheLoader 可能会抛出异常,LoadingCache.get(K) 方法会抛出 ExecutionException 异常。(如果 CacheLoader抛出未经检查异常,get(K) 方法将抛出包装异常 UncheckedExecutionException)。也可以选择使用 getUnchecked(K) 方法,它将所有异常都包装在UncheckedExecutionException 中,但如果底层的 CacheLoader 抛出已检查异常,这可能导致意外行为。

LoadingCache loadingCache = CacheBuilder.newBuilder()        .maximumSize(1000)        .build(new CacheLoader() {            @Override            public String load(Long key) {//抛出未检查异常                //TODO: 根据业务加载数据                return RandomStringUtils.randomAlphanumeric(10);            }        });log.info(loadingCache.getUnchecked(1L));

可以使用 getAll(Iterable)方法执行批量查询。默认情况下,getAll 会为缓存中不存在的每个键单独调用 CacheLoader.load 方法。当批量检索比多个单独查找更高效时,可以重写CacheLoader.loadAll 以利用此功能。getAll(Iterable)的性能将相应提高。

2.2、Callable

所有 Guava Cache,无论是 LoadingCache 还是非 LoadingCache,都支持 get(K, Callable) 方法。该方法返回与缓存中键相关联的值,或者从指定的 Callable 计算它并将其添加到缓存中。在加载完成之前,与此缓存关联的任何可观察状态都不会被修改。该方法为传统的“如果有缓存,则返回;否则创建、缓存并返回”模式提供了一个简单的替代方案。

Cache cache = CacheBuilder.newBuilder()        .maximumSize(1000)        .build();try {    String s = cache.get(1L, new Callable() {        @Override        public String call() throws Exception {            //TODO: 根据业务加载数据            return RandomStringUtils.randomAlphanumeric(10);        }    });    log.info(s);} catch (ExecutionException e) {    e.printStackTrace();}

2.3、直接插入

可以使用 cache.put(key, value) 方法直接将值插入到缓存中。这会覆盖缓存中指定键的的任何先前条目。还可以使用 Cache.asMap() 视图公开的任何 ConcurrentMap 方法更改缓存。请注意,asMap 视图上的任何方法都不会自动将条目加载到缓存中。此外,视图上的原子操作在缓存自动加载的范围之外运行,因此在使用 CacheLoader 或 Callable 加载值的缓存中,始终应优先选择 Cache.get(K, Callable) 而不是 Cache.asMap().putIfAbsent()。

3、数据淘汰

现实情况是,我们几乎肯定没有足够的内存来缓存所有可能的内容。你必须决定:什么时候不值得保留缓存条目?Guava提供了三种数据淘汰方式:基于大小的淘汰、基于时间的淘汰和基于引用的淘汰。

3.1、基于容量的淘汰

如果你的缓存不应该超过一定大小,只需使用 CacheBuilder.maximumSize(long) 。缓存将尝试淘汰最近未被使用或使用频率很低的条目。警告:在达到限制之前,缓存可能会淘汰条目,通常是在缓存大小接近限制时。

或者,如果不同的缓存条目具有不同的“权重”——例如,如果你的缓存值具有截然不同的内存占用,你可以使用 CacheBuilder.weigher(Weigher) 来指定一个权重函数,并使用CacheBuilder.maximumWeight(long) 来设置最大的缓存权重。除了与 maximumSize 相同的注意事项外,请注意权重是在条目创建时计算的,并且在此后是静态的。

Cache cache = CacheBuilder.newBuilder()        .maximumWeight(100000)        .weigher(new Weigher() {            @Override            public int weigh(Long key, String value) {                return value.getBytes().length;            }        }).build();

3.2、基于时间的淘汰

CacheBuilder 提供了两种基于时间淘汰数据的方法:

expireAfterAccess(long, TimeUnit):在最后一次读取或写入条目后,仅在指定的持续时间过去后才淘汰条目。需要注意的是,条目的淘汰顺序类似于基于大小的淘汰策略。
expireAfterWrite(long, TimeUnit):在条目创建或最近一次替换值之后,仅在指定的持续时间过去后才淘汰条目。如果缓存数据在一段时间后变得过时,这种方式可能是可取的。

3.3、基于引用的淘汰

Guava 允许通过对键或值使用弱引用和对值使用软引用来设置缓存,从而利用垃圾回收来淘汰数据。

  • CacheBuilder.weakKeys() 使用弱引用来存储键。这意味着当键没有其他(强或软)引用时,条目可以被垃圾回收。由于垃圾回收仅依赖于内存地址相等性,这导致整个缓存使用(==)来比较键,而不是equals()方法。
  • CacheBuilder.weakValues() 使用弱引用来存储值。这意味着当值没有其他(强或软)引用时,条目可以被垃圾回收。由于垃圾回收仅依赖于内存地址相等性,这导致整个缓存使用(==)来比较值,而不是equals()方法。
  • CacheBuilder.softValues() 使用软引用来存储值。以软引用方式引用的对象会根据内存需求以全局最近最少使用的方式进行垃圾回收。由于使用软引用可能会影响性能,我们通常建议使用更可预测的 maximum cache size 替代。使用 softValues() 将导致值使用(==)来比较,而不是 equals() 方法。

3.4、显式删除

在任何时候,你可以显式地使缓存条目失效,而不是等待条目被淘汰。可以通过以下方式实现:

单个失效:使用 Cache.invalidate(key)
批量失效:使用 Cache.invalidateAll(keys)
全部失效:使用 Cache.invalidateAll()

3.5、删除监听器

你可以为缓存指定一个删除监听器(RemovalListener),以在条目被移除时执行某些操作,通过 CacheBuilder.removalListener(RemovalListener)方法指定删除监听器。RemovalListener 会接收到一个RemovalNotification 对象,其中包含了 RemovalCause、键和值的信息。

需要注意的是,任何由 RemovalListener 抛出的异常都会被记录(使用 Logger 时)并被忽略。

Cache cache = CacheBuilder.newBuilder()        .maximumSize(1000)        .removalListener(new RemovalListener() {            @Override            public void onRemoval(RemovalNotification notification) {                log.info(notification.toString());            }        })        .build();

警告:默认情况下,移除监听器操作是同步执行的。由于缓存维护通常在正常缓存操作期间执行,因此移除监听器可能会降低缓存的速度!如果需要移除监听器,请使用RemovalListeners.asynchronous(RemovalListener, Executor) 方法来装饰 RemovalListener,这样可以以异步的方式运行。

3.6、数据清理时机

使用 CacheBuilder 构建的缓存不会“自动”执行清理和逐出值,也不会在值过期后立即执行清理和逐出值,也不会执行任何类似操作。相反,它会在写入操作期间执行少量维护,或者在偶尔的读取操作期间(如果写入很少)执行少量维护。

原因是:如果我们想要连续执行缓存维护,我们需要创建一个线程,它的操作将与用户操作竞争共享锁。此外,某些环境限制了线程的创建,这将使 CacheBuilder 在该环境中无法使用。

相反,我们将选择权交到你手中。如果你的缓存是高吞吐量的,那么你无需担心执行缓存维护来清除过期条目等问题。如果你的缓存只偶尔进行写操作,并且不想让清理阻塞缓存读取,你可以创建自己的维护线程,定期调用 Cache.cleanUp() 方法。

如果要为很少进行写入操作的缓存安排定期缓存维护,请使用 ScheduledExecutorService。

3.7、刷新

刷新(Refreshing)与淘汰(Eviction)并不完全相同。根据 LoadingCache.refresh(K) 的定义,刷新一个键会加载该键的新值,这可能是异步的。在键正在刷新的过程中,旧值(如果存在)仍然会被返回,这与淘汰操作不同,淘汰操作会导致获取操作等待直到新值加载完成。

如果在刷新过程中发生异常,旧值将被保留,异常将被记录并忽略。

可以根据业务需要,重写 CacheLoader 的CacheLoader.reload(K, V) 方法来重新定义刷新操作;这允许你在计算新值时使用旧值。

LoadingCache loadingCache = CacheBuilder.newBuilder()        .maximumSize(1000)        .refreshAfterWrite(1, TimeUnit.MINUTES)        .build(new CacheLoader() {            @Override            public String load(Integer key) throws Exception {                //TODO: 根据业务加载数据                return RandomStringUtils.randomAlphanumeric(10);            }            @Override            public ListenableFuture reload(Integer key, String oldValue) throws Exception {                if (neverNeedsRefresh(key)) {//不需要刷新                    return Futures.immediateFuture(oldValue);                } else {                    //异步刷新                    ListenableFutureTask task = ListenableFutureTask.create(new Callable() {                        public String call() {                            return RandomStringUtils.randomAlphanumeric(10);                        }                    });                    executorService.execute(task);                    return task;                }            }        });

可以使用 CacheBuilder.refreshAfterWrite(long, TimeUnit) 为缓存添加自动定时刷新。与 expireAfterWrite 不同,refreshAfterWrite 会使一个键在指定的时间后变为可刷新状态,但只有在查询该条目时才会实际启动刷新(如果 CacheLoader.reload 被实现为异步,则查询不会因刷新而变慢)。因此,可以在同一个缓存上同时指定 refreshAfterWrite 和 expireAfterWrite,以便在条目变为可刷新状态时不会盲目地重置过期计时器,如果一个条目在变为可刷新状态后没有被查询,它就允许过期。

4、特点4.1、统计信息

使用 CacheBuilder.recordStats() 可以为 Guava Cache 打开统计信息收集功能。Cache.stats() 方法返回一个 CacheStats 对象,该对象提供了诸如以下统计信息:

  • hitRate():返回命中次数与请求次数之比。
  • averageLoadPenalty():平均加载新值所花费的时间(以纳秒为单位)。
  • evictionCount():缓存淘汰的数量。

还有许多其他的统计信息。这些统计信息在缓存调优中非常重要,我们建议在性能关键的应用程序中密切关注这些统计信息。

4.2、asMap

可以使用 Cache 的 asMap 视图将任何缓存视为 ConcurrentMap,但是 asMap 视图与缓存的交互需要一些说明。

  • cache.asMap() 包含当前加载在缓存中的所有条目。例如,cache.asMap().keySet() 包含当前加载的所有键。
  • asMap().get(key) 基本等同于 cache.getIfPresent(key),并且不会导致值被加载。这与 Map 的约定一致。
  • 访问时间会被读取和写入操作重置(包括 Cache.asMap().get(Object) 和 Cache.asMap().put(K, V)),但不会被 containsKey(Object) 或其他操作所重置。因此,遍历 cache.asMap().entrySet() 不会重置条目的访问时间。

5、简单使用5.1、引入依赖

<dependency>    <groupId>com.google.guava</groupId>    <artifactId>guava</artifactId>    <version>32.1.3-jre</version></dependency>

5.2、简单使用

public static void main(String[] args) {    LoadingCache loadingCache = CacheBuilder.newBuilder()            .initialCapacity(1000)            .maximumSize(10000)            .expireAfterWrite(10, TimeUnit.MINUTES)            .refreshAfterWrite(3, TimeUnit.MINUTES)            .recordStats()            .build(new CacheLoader() {                @Override                public String load(Long key) throws Exception {//抛出已检查异常                    //TODO: 根据业务加载数据                    return RandomStringUtils.randomAlphanumeric(10);                }            });    try {        log.info(loadingCache.get(1L));    } catch (ExecutionException e) {        e.printStackTrace();    }}

参考:https://github.com/google/guava/wiki/CachesExplained。