Google Guava 被誉为是JAVA类库中的瑞士军刀。能显著简化代码,让代码易写、易读、易于维护。同时可以大幅提高程序员的工作效率,让我们从大量重复的底层代码中脱身。
由于 Google Guava 类库包含大量非常有用的特性,无法在一篇文章中尽述。本篇仅简单介绍 Google Guava 中的缓存工具的使用。
依赖
使用 Maven 进行项目构建时,添加下面的依赖:
- <dependency>
- <groupId>com.google.guava</groupId>
- <artifactId>guava</artifactId>
- <version>29.0-jre</version>
- <!-- or, for Android: -->
- <version>29.0-android</version>
- </dependency>
使用 Gradle 进行项目构建时,添加下面的依赖:
- dependencies {
- // Pick one:
- // 1. Use Guava in your implementation only:
- implementation("com.google.guava:guava:29.0-jre")
- // 2. Use Guava types in your public API:
- api("com.google.guava:guava:29.0-jre")
- // 3. Android - Use Guava in your implementation only:
- implementation("com.google.guava:guava:29.0-android")
- // 4. Android - Use Guava types in your public API:
- api("com.google.guava:guava:29.0-android")
- }
示例
- LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
- .maximumSize(1000)
- .expireAfterWrite(10, TimeUnit.MINUTES)
- .removalListener(MY_LISTENER)
- .build(
- new CacheLoader<Key, Graph>() {
- @Override
- public Graph load(Key key) throws AnyException {
- return createExpensiveGraph(key);
- }
- });
适用性
缓存有非常广泛的应用场景。比如,你应该为那些计算或者查询代价高昂的数据使用缓存,或者你需要某个输入数据很多次的场景。
一个 `Cache` 类似于 `ConcurrentMap`,不过并不完全相同。基本的差异在于, `ConcurrentMap` 持久化所有添加进来的元素直到它们被显式删除。另一方面,通常将 `Cache` 配置为自动淘汰条目,以限制其内存占用量。在某些情况下, `LoadingCache` 会很有用,虽然它不淘汰条目,但是可以自动加载缓存。
通常,Guava 缓存工具可以适用于下列场景:
- 你希望使用一些内存空间来改善速度。
- 您希望多次查询某些键。
- 您的缓存将不需要存储超出 RAM 容量的数据。(Guava 缓存的作用范围局限于在应用程序的一次运行中。它们不将数据存储在文件中或外部服务器上。如果这不符合您的需求,请考虑使 Memcached)
如果这些都适用于您的应用场景,那么 Guava 缓存实用程序将很适合您!
如上面的示例代码所示,使用 `CacheBuilder` 生成器模式可以获取 `Cache`,但是自定义缓存是有趣的部分。
注意:如果不需要 `Cache` 的功能,则 `ConcurrentHashMap` 的内存使用效率更高——但是很难用任何旧的 `ConcurrentMap`来复制大多数 `Cache` 的功能。
填充
你需要问自己有关缓存的第一个问题是:是否有一些合理的默认函数来加载或计算与键关联的值?如果是这样,您应该使用 `CacheLoader`。如果不是这样,或者如果您需要覆盖默认值,但是仍然需要原子的 "get-if-absent-compute" 语义,则应该将 `Callable` 传递给 `get` 调用。可以使用 `Cache.put` 直接插入元素,但是首选自动加载缓存,因为这样可以更轻松地推断所有缓存内容的一致性。
使用 CacheLoader
`LoadingCache` 是一个通过附属的 `CacheLoader` 构建的 `Cache`。创建一个 `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` 可能会抛出 `Exception`,因此 `LoadingCache.get(K)` 会抛出 `ExecutionException`。(如果缓存加载器抛出 unchecked 异常,则`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` 将为缓存中不存在的每个键单独发出 `CacheLoader.load` 调用。如果批量检索比许多单个查询更有效,则可以覆盖 `CacheLoader.loadAll` 来利用这一点。 `getAll(Iterable)` 的性能将相应提高。
请注意,您可以编写一个 `CacheLoader.loadAll` 实现,该实现加载未明确要求的键的值。例如,如果计算某个组中任何键的值给您该组中所有键的值,则 `loadAll` 可能会同时加载其余组。
使用 Callable
所有 Guava 缓存(无论是否加载)均支持方法 `get(K, Callable)`。此方法返回与缓存中的键关联的值,或从指定的 `Callable` 中计算出该值并将其添加到缓存中。在加载完成之前,不会修改与此缓存关联的可观察状态。此方法为常规的“如果已缓存,则返回;否则创建,缓存并返回”模式提供了简单的替代方法。
- 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)` 。这将覆盖高速缓存中指定键的任何先前条目。也可以使用 `Cache.asMap()` 视图公开的任何 `ConcurrentMap` 方法对缓存进行更改。注意,`asMap` 视图上的任何方法都不会导致条目自动加载到缓存中。此外,该视图上的原子操作在自动缓存加载范围之外运行,因此在使用 `CacheLoader` 或 `Callable` 加载值的缓存中,始终应优先选择 `Cache.get(K, Callable<V>)` 而不是 `Cache.asMap().putIfAbsent` 。
驱逐
冷酷的现实是,我们几乎肯定没有足够的内存来缓存我们可以缓存的所有内容。您必须决定:什么时候不值得保留缓存条目?Guava 提供三种基本的驱逐类型:基于大小的驱逐,基于时间的驱逐和基于引用的驱逐。
基于大小的驱逐
如果你的缓存在达到某个大小之后就不应该继续增长,可以使用 `CacheBuilder.maximumSize(long)`。缓存将会尝试驱逐最近最少使用的缓存数据实体。
警告:缓存可能会在大小达到限制之前驱逐实体——通常是在缓存大小接近限制时。
另外,如果不同的缓存实体具有不同的“权重”——比如,如果你的缓存值具有不同的内存空间占用——你可以使用 `CacheBuilder.weigher(Weigher)` 指定权重函数,同时使用 `CacheBuilder.maximumWeight(long)` 指定最大缓存权重。除了需要与 `maximumSize` 相同的限制外,请注意,权重是在条目创建时计算的,此后是静态的。
- LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
- .maximumWeight(100000)
- .weigher(new Weigher<Key, Graph>() {
- public int weigh(Key k, Graph g) {
- return g.vertices().size();
- }
- })
- .build(
- new CacheLoader<Key, Graph>() {
- public Graph load(Key key) { // no checked exception
- return createExpensiveGraph(key);
- }
- });
基于时间的驱逐
- `CacheBuilder` 提供了两种基于时间的驱逐方法:
- `expireAfterAccess(long, TimeUnit)` 仅在自从上次通过读取或写入访问条目以来经过指定的持续时间后,条目才到期。请注意,驱逐条目的顺序将类似于基于大小的驱逐。
- `expireAfterWrite(long, TimeUnit)` 自创建条目以来经过指定的时间或该值的最新替换之后,使条目过期。如果经过一定时间后缓存的数据持续增长,则可能需要这样做。
定时到期是在写入过程中进行定期维护的,偶尔在读取过程中进行维护,如下所述。
基于引用的驱逐
Guava 允许你设置你的缓存以允许数据实体的垃圾收集,通过对键或者值使用的 weak references ,或者对值使用的 soft references 进行设置。
- `CacheBuilder.weakKeys()` 使用弱引用存储键。这允许实体在没有其他引用(强引用或者软引用)指向其键时被垃圾收集。由于垃圾收集基于 id 相等规则,这就导致整个缓存多需要使用 id (`==`)相等来比较键,而不是使用 `equals()`。
- `CacheBuilder.weakValues()` 使用弱引用存储值。这允许实体在没有其他引用(强引用或者软引用)指向其值时被垃圾收集。由于垃圾收集基于 id 相等规则,这就导致整个缓存多需要使用 id (`==`)相等来比较值,而不是使用 `equals()`。
- `CacheBuilder.softValues()` 将值包装进入软引用。软引用对象以全局最近最少使用规则进行垃圾收集,以响应内存需求。由于使用软引用可能会有些性能问题,我们通常推荐使用更加容易预测的 maximum cache size 替代。使用 `softValues()` 将导致值被通过 id (`==`) 相等比较,而不是使用 `equals()`。
显式删除
任何时刻,你都可以显式废除缓存实体,而不需要等待实体被驱逐。可以通过以下方法:
- 单个废除,使用 `Cache.invalidate(key)`
- 批量废除,使用 `Cache.invalidateAll(keys)`
- 全部废除,使用 `Cache.invalidateAll()`
清理何时发生?
用 `CacheBuilder` 构建的缓存不会“自动”或在值过期后立即执行清除和逐出值,或类似的任何操作。取而代之的是,它在写操作期间或偶尔进行的读操作(如果很少进行写操作)中执行少量维护。
这样做的原因如下:如果我们要连续执行 `Cache` 维护,则需要创建一个线程,并且该线程的操作将与用户操作竞争共享锁。另外,某些环境限制了线程的创建,这会使 `CacheBuilder` 在该环境中无法使用。
相反,我们会将选择权交给您。如果您的缓存是高吞吐量的,那么您不必担心执行缓存维护以清理过期的条目等。 如果您的缓存确实很少写入,并且您不想清理来阻止缓存读取,则您可能希望创建自己的维护线程,该线程定期调用 `Cache.cleanUp()`。
如果要为很少写入的缓存安排定期的缓存维护,只需使用 `ScheduledExecutorService` 调度维护操作。
刷新
刷新与驱逐并不完全相同。如 `LoadingCache.refresh(K)` 所述,刷新键可能会异步加载该键的新值。与驱逐相反,旧键(如果有的话)在刷新键时仍会返回,这迫使检索要等到重新加载该值。
如果刷新时引发异常,则将保留旧值,并记录并吞下该异常。
`CacheLoader` 可以通过覆盖 `CacheLoader.reload(K, V)` 指定某些将要在刷新时执行的明智行为,它允许您在计算新值时使用旧值。
- // Some keys don't need refreshing, and we want refreshes to be done asynchronously.
- LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
- .maximumSize(1000)
- .refreshAfterWrite(1, TimeUnit.MINUTES)
- .build(
- new CacheLoader<Key, Graph>() {
- public Graph load(Key key) { // no checked exception
- 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` 在指定的持续时间后将使键“具有资格”进行刷新,但实际上仅在查询条目时才会启动刷新。(如果将 `CacheLoader.reload` 实现为异步,则刷新不会降低查询的速度。)因此,例如,您可以在同一缓存上同时指定 `refreshAfterWrite` 和 `expireAfterWrite`,以便只要条目符合刷新资格,就不会盲目地重置条目的过期计时器,因此,如果在符合刷新资格后不查询条目,则允许它过期。