Caches:正确地进行本地缓存,并支持各种过期行为。
1.示例
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);
}
});
2.适用
缓存在各种各样的用例中都非常有用。例如,当值的计算或检索成本很高时,并且在某个输入上将需要多次使用该值时,你应该考虑使用缓存。
Cache
类似于ConcurrentMap
,但并不完全相同。最根本的区别是,ConcurrentMap
会保留所有添加到其中的元素,直到将其显式删除为止。另一方面,通常将Cache
配置为自动淘汰条目,以限制其内存占用量。在某些情况下,由于LoadingCache
自动加载缓存,即使不淘汰条目,它也很有用。
通常,Guava缓存工具适用于以下情况:
- 你愿意花费一些内存来提高速度。
- 你希望有时会多次查询键。
- 你的缓存将不需要存储超出RAM容量的数据。(Guava缓存是 本地 应用程序的单次运行。它们不将数据存储在文件中或外部服务器上。如果这不满足你的需求,请考虑使用像Memcached这样的工具)
如果这些都适用于你的用例,那么Guava缓存工具将很适合你!
如上面的示例代码所示,使用CacheBuilder
构建器模式可以获取Cache
,但是自定义缓存是有趣的部分。
注意: 如果不需要Cache
的功能,则ConcurrentHashMap
的内存使用效率更高——但要用任何旧的ConcurrentMap
复制大多数Cache
功能是极其困难或不可能的。
3.种类
有关缓存,问自己的第一个问题是:是否有一些合理的默认函数来加载或计算与键关联的值?如果是这样,则应使用CacheLoader
。如果不是这样,或者如果你需要覆盖默认值,但仍希望使用原子性的"get-if-absent-compute"语义,则应将Callable
传递给get
调用。可以使用Cache.put
直接插入元素,但是首选自动缓存加载,因为这样可以更容易推断所有缓存内容的一致性。
3.1来自于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
。(如果缓存加载器抛出未经检查的异常,则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
可能会同时加载该组中的其余部分。
3.2来自于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());
}
3.3直接插入
可以使用cache.put(key, value)
直接将值插入到缓存中。这将覆盖缓存中指定键的任何先前条目。也可以使用Cache.asMap()
视图公开的任何ConcurrentMap
方法对缓存进行更改。请注意,asMap
视图上的任何方法都不会导致条目自动加载到缓存中。此外,该视图上的原子操作在自动缓存加载范围之外进行,因此,在使用CacheLoader
或Callable
加载值的缓存中,与Cache.asMap().putIfAbsent
相比,应始终首选Cache.get(K, Callable<V>)
。
4.淘汰
残酷的现实是,我们几乎肯定没有足够的内存来缓存我们可以缓存的所有内容。你必须决定:什么时候不值得保留缓存条目?Guava提供三种基本的淘汰类型:基于大小的淘汰,基于时间的淘汰和基于引用的淘汰。
4.1基于大小的淘汰
如果你的缓存不应超过特定大小,则只需使用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);
}
});
4.2基于时间的淘汰
CacheBuilder
提供了两种定时淘汰方法:
expireAfterAccess(long, TimeUnit)
仅在自从上次通过读取或写入访问条目以来经过指定的持续时间后,条目才会过期。请注意,逐出条目的顺序将类似于基于大小的淘汰。expireAfterWrite(long, TimeUnit)
自创建条目以来经过指定的时间或该值的最新替换之后,使条目过期。如果经过一定时间后缓存的数据过时,则可能需要这样做。
定时过期是在写期间和偶尔在读期间进行定期维护的,如下所述。
4.2.1测试定时淘汰
测试定时淘汰并不一定会很痛苦…实际上你也不需要花两秒钟来测试两秒钟的过期时间。使用Ticker接口和CacheBuilder.ticker(Ticker)
方法可以在缓存生成器中指定时间源,而不必等待系统时钟。
4.3基于引用的淘汰
Guava允许你通过对键或值使用弱引用,对值使用软引用来设置缓存以允许对条目进行垃圾回收。
CacheBuilder.weakKeys()
使用弱引用存储键。如果没有其他(强或软)对键的引用,则允许对条目进行垃圾回收。由于垃圾回收仅取决于标识相等,因此这导致整个缓存使用标识(==
)相等来比较键,而不是equals()
。CacheBuilder.weakValues()
使用弱引用存储值。如果没有其他(强或软)对值的引用,则允许对条目进行垃圾回收。由于垃圾回收仅取决于标识相等,因此这导致整个缓存使用身份(==
)相等来比较值,而不是equals()
。CacheBuilder.softValues()
将值包装在软引用中。软引用对象以全局最近最少使用的方式进行垃圾回收,以响应内存需求。由于使用软引用会对性能产生影响,因此我们通常建议使用更可预测的最大缓存大小。使用softValues()
将导致使用标识(==
)相等而不是equals()
来比较值。
4.4显式删除
在任何时候,你都可以显式使缓存条目无效,而不必等待条目被淘汰。可以这样做:
- 个别地,使用
Cache.invalidate(key)
- 批量地,使用
Cache.invalidateAll(keys)
- 对所有条目,使用
Cache.invalidateAll()
4.5删除监听器
你可以通过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
以便异步操作。
4.6什么时候进行清除?
使用CacheBuilder
构建的缓存不会在值过期后,或任何类似的操作下立即执行"自动"的清除和淘汰值。相反,如果写操作很少,则在写操作期间或偶尔的读操作期间,它将执行少量维护。
这样做的原因如下:如果我们要连续执行Cache
维护,则需要创建一个线程,并且该线程的操作将与用户操作竞争共享锁。此外,一些环境限制了线程的创建,这将使CacheBuilder
在该环境中不可用。
相反,我们将选择权交给你。如果你的缓存是高吞吐量的,那么你不必担心执行缓存维护以清除过期的条目之类的操作等。如果你的缓存确实很少执行写操作,并且你不想清除操作来阻塞缓存读取操作,则你可能希望创建自己的维护线程,该线程定期调用Cache.cleanUp()
。
如果要为一个很少有写操作的缓存安排定期的缓存维护,只需使用ScheduledExecutorService
安排维护即可。
4.7刷新
刷新与淘汰并不完全相同。如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
,这样只要条目符合刷新资格,就不会盲目地重置条目的过期计时器,因此,如果条目在符合刷新资格后未被查询,则允许该条目过期。
5.特性
5.1统计
通过使用CacheBuilder.recordStats()
,可以打开Guava缓存的统计信息收集。Cache.stats()
方法返回CacheStats
对象,该对象提供统计信息,例如
hitRate()
,它返回匹配数与请求数的比率averageLoadPenalty()
,加载新值所花费的平均时间,以纳秒为单位evictionCount()
,缓存淘汰的数量,不包括显式清除
以及其他更多统计信息。这些统计信息在缓存调优中至关重要,我们建议在性能关键型应用中留意这些统计信息。
5.2asMap
你可以使用其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()
不会重置你检索的条目的访问时间。
6.中断
加载方法(如get
)从不抛出InterruptedException
。我们本可以设计这些方法来支持InterruptedException
,但是我们的支持将是不完整的,迫使所有用户付出代价,但只有部分受益。有关详细信息,请继续阅读。
get
调用请求未缓存的值可分为两大类:一类是加载值的和另一类是等待另一个正在运行的线程加载的。两者在支持中断的能力上有所不同。最简单的情况是等待另一个正在进行的线程的加载:在这里我们可以输入一个可中断的等待。困难的情况是我们自己加载值。在这里,我们由用户提供的CacheLoader
决定。如果它碰巧支持中断,我们可以支持中断;如果不支持,我们不能。
那么,为什么在提供的CacheLoader
支持时不支持中断呢?从某种意义上说,我们这样做(但请参见下文):如果CacheLoader
抛出InterruptedException
,则对该键的所有get
调用将立即返回(与任何其他异常一样)。另外,get
将恢复加载线程中的中断位。令人惊讶的部分是InterruptedException
包装在ExecutionException
中。
原则上,我们可以为你解开此异常。但是,这将强制所有LoadingCache
用户处理InterruptedException
,即使大多数CacheLoader
实现从不抛出该异常。当你考虑到所有非加载线程的等待仍可能被中断时,也许这仍然是值得的。但是许多缓存仅在单个线程中使用。他们的用户仍然必须捕获不可能的InterruptedException
。而且,即使是那些在线程间共享缓存的用户,有时也只能根据哪个线程首先发出请求来中断其get
调用。
在此决策中,我们的指导原则是使缓存的行为就像所有值都已加载到调用线程中一样。这一原则可以轻松地将缓存引入到先前在每次调用时重新计算其值的代码中。而且,如果旧代码不可中断,那么新代码也可以。
我说过,我们"在某种意义上"支持中断。另一种意义上,那就是使LoadingCache
成为泄漏的抽象。如果加载线程被中断,我们会像对待其他异常一样对待它。在许多情况下都可以,但是当多个get
调用正在等待该值时,这不是正确的选择。尽管恰好正在计算该值的操作被中断,但其他需要该值的操作可能并未中断。然而,所有这些调用者都接收InterruptedException
(包装在ExecutionException
中),即使负载并没有像“中止”那么“失败”。正确的行为是让剩余线程之一重试加载。我们为此提交了一个错误。然而,修复可能会有风险。我们可以在提议的AsyncLoadingCache
中投入额外精力而不是修复这个问题,该方法将返回具有正确中断行为的Future
对象。