Google Guava CacheExplained Guava缓存使用说明 中文版
翻译自 Guava wiki CacheExplained,中文翻译原文
Guava 源码中文注释地址点这里
Caches
示例
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 Cache
适用于:
- 你愿意花一些内存来提高速度。
- 某些keys会被多次查询
- 您的缓存不需要存储比 RAM 所能容纳的更多的数据。
(Guava 缓存对于您的应用程序的单次运行是本地的。 它们不会将数据存储在文件中或外部服务器上。如果这不符合您的需求,请考虑使用类似 Memcached 的工具。)
如果这些都适用于您的用例,那么 Guava Cache
可能适合您!
如上面的示例代码所示,使用 CacheBuilder
构建器模式可以获取 Cache
,但自定义你的缓存更有意思。
Note: 如果您不需要 Cache
的功能,ConcurrentHashMap
更节省内存--但是用任何旧的ConcurrentMap
复制大多数 Cache
功能是极其困难或不可能的。
Population
关于缓存,要问自己的第一个问题是:是否有一些合理的默认函数来加载或计算与键关联的值? 如果是这样,您应该使用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<V>)
。
此方法返回与缓存中的键关联的值,或者从指定的 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()
。
请注意,Cache.get(K, Callable)
也可以将值插入到底层缓存中。
缓存淘汰
冷酷的现实是,我们几乎肯定没有足够的内存来缓存我们可以缓存的所有东西。
您必须决定:什么时候不值得保留缓存条目? 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)
自条目创建或最近的值替换后指定的持续时间过去后使条目过期。如果缓存数据在一段时间后变得陈旧,这可能是可取的。
定时过期在写入期间和偶尔在读取期间通过定期维护执行,如下所述。
测试定时驱逐
测试定时驱逐并不一定很痛苦……实际上也不必花两秒钟来测试两秒钟的到期时间。
使用 Ticker 接口和 CacheBuilder.ticker(Ticker)
方法在缓存构建器中指定时间源,而不必等待系统时钟。
基于引用
Guava 允许您设置缓存以允许对条目进行垃圾收集,方法是对键或值使用 [弱引用],对值使用 [软引用]。
CacheBuilder.weakKeys()
使用弱引用存储键。如果没有其他(强或软)对键的引用,这允许条目被垃圾收集。由于垃圾收集仅取决于身份相等性,这会导致整个缓存使用身份(==
)相等性来比较键,而不是equals()
。CacheBuilder.weakValues()
使用弱引用存储值。如果没有对值的其他(强或软)引用,这允许对条目进行垃圾收集。由于垃圾收集仅依赖于身份相等性,这会导致整个缓存使用身份(==
)相等性来比较值,而不是使用equals()
。CacheBuilder.softValues()
将值包装在软引用中。软引用的对象以全局最近最少使用的方式进行垃圾收集,以响应内存需求。由于使用软引用的性能影响,我们通常建议使用更可预测的 [最大缓存大小] [基于大小的逐出]。使用softValues()
将导致使用恒等 (==
) 相等而不是equals()
来比较值。
显式删除
在任何时候,您都可以明确地使缓存条目无效,而不是等待条目被逐出。 这可以做到:
- 单独使用
Cache.invalidate(key)
- 批量,使用
Cache.invalidateAll(keys)
- 所有条目,使用
Cache.invalidateAll()
移除监听器
您可以通过 CacheBuilder.removalListener(RemovalListener)
为您的缓存指定一个移除侦听器,以便在条目被移除时执行某些操作。RemovalListener
被传递一个 RemovalNotification
,它指定了 RemovalCause
、键和值。
请注意,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);
警告: 移除监听器操作默认同步执行,由于缓存维护通常在正常缓存操作期间执行,昂贵的移除监听器会减慢正常缓存功能!
如果你有一个昂贵的移除监听器,使用 RemovalListeners.asynchronous(RemovalListener, Executor)
装饰一个 RemovalListener
来异步操作。
什么时候进行清理?
使用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
,这样每当条目符合刷新条件时,条目的过期计时器不会盲目重置,因此如果条目不是 符合刷新条件后查询,允许过期。
Features
Statistics
通过使用 CacheBuilder.recordStats()
,你可以开启 Guava 缓存的统计收集。Cache.stats()
方法返回一个 CacheStats
对象,它提供统计信息,例如
hitRate()
, 返回命中率averageLoadPenalty()
, 加载新值的平均时间,以纳秒为单位evictionCount()
, 缓存驱逐的数量
以及更多的统计数据。 这些统计信息对于缓存调整至关重要,我们建议在性能关键型应用程序中密切关注这些统计信息。
asMap
您可以使用其 asMap
视图将任何 Cache
视为 ConcurrentMap
,但是 asMap
视图如何与 Cache
交互需要一些解释。
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()
的集合视图的操作。 因此,例如,遍历cache.asMap().entrySet()
不会重置您检索的条目的访问时间。
Interruption
加载方法(如get
)永远不会抛出InterruptedException
。 我们本可以设计这些方法来支持“InterruptedException”,但我们的支持是不完整的,迫使所有用户都付出代价,但只有一部分人受益。 有关详细信息,请继续阅读。
请求未缓存值的get
调用分为两大类:加载值的调用和等待另一个线程正在进行的加载的调用。 两者在我们支持中断的能力上有所不同。 最简单的情况是等待另一个线程正在进行的加载:这里我们可以进入一个可中断的等待。 困难的情况是我们自己加载值。在这里,我们受用户提供的“CacheLoader”支配。 如果恰好支持中断,我们可以支持中断;如果没有,我们不能。
那么,当提供的CacheLoader
支持时,为什么不支持中断呢? 从某种意义上说,我们这样做(但见下文):如果CacheLoader
抛出InterruptedException
,则所有对该键的get
调用将立即返回(就像任何其他异常一样)。
另外,get
将恢复加载线程中的中断位。 令人惊讶的部分是 InterruptedException
被包裹在一个 ExecutionException
中。
原则上,我们可以为您解开这个异常。 然而,这会强制所有 LoadingCache
用户处理 InterruptedException
,即使大多数 CacheLoader
实现从不抛出它。当您考虑到所有非加载线程的等待仍然可能被中断时,也许这仍然是值得的。但是许多缓存仅在单个线程中使用。他们的用户仍然必须捕获不可能的“InterruptedException”。 甚至那些跨线程共享缓存的用户也只能偶尔中断他们的“get”调用,这取决于哪个线程最先发出请求。
我们在这个决定中的指导原则是让缓存的行为就像所有值都加载到调用线程中一样。 这个原则可以很容易地将缓存引入到先前在每次调用时重新计算其值的代码中。 如果旧代码不可中断,那么新代码也不可中断。
我说我们“在某种意义上”支持中断。 还有另一种我们不这样做的意义,使“LoadingCache”成为一个有漏洞的抽象。 如果加载线程被中断,我们会像对待任何其他异常一样对待它。 在许多情况下这很好,但是当多个 get
调用正在等待该值时,这不是正确的做法。 尽管碰巧计算值的操作被中断,但其他需要该值的操作可能没有中断。 然而,所有这些调用者都会收到 InterruptedException
(包装在 ExecutionException
中),即使加载并没有像“中止”那样“失败”。
正确的行为是让剩余线程之一重试加载。 我们有a bug filed for this。 但是,修复可能存在风险。 与其解决问题,我们还可以在提议的“AsyncLoadingCache”中投入更多精力,它会返回具有正确中断行为的“Future”对象。