Guava Cache 总结
想对Guava cache部分进行总结,但思索之后,文档才是最全面、详细的。所以,决定对Guava cache文档进行翻译。
原文地址:https://github.com/google/guava/wiki/CachesExplained
花费了一些时间进行翻译,仍有待提高,不准确的地方,希望各位客官留言,指出不足之处,共同讨论,也可以发我邮箱:
466178395@qq.com
如果本文对您有帮助,倍感欣慰!
一 概要
Guava cache是google开发的,目前常用在单机环境中,如果是分布式环境,它就无能为力了。下面进入正文。
二内存解释
Example
1 LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() 2 .maximumSize(1000) 3 .expireAfterWrite(10, TimeUnit.MINUTES) 4 .removalListener(MY_LISTENER) 5 .build( 6 new CacheLoader<Key, Graph>() { 7 public Graph load(Key key) throws AnyException { 8 return createExpensiveGraph(key); 9 } 10 });
应用性
缓存在许多的地方非常的有用。比如:当一个计算或是查询一个值花费很大代价时,或者,你需要多次用到一个值时,你应该考虑使用缓存。
Cache 跟ConcurrentMap 很像,但不一样。最大的功能上的区別,ConcurrentMap允许所有的元素直到被手动移除为止,一直存在。另一方面,Cache为了限制内存的占用,通常会自动地移除值。某些时候,LoadingCache 即使不驱除元素,但由于他自动导入缓存的特点,它也是十分有用的。
一般情况下,当满足以下场景时:
・希望消耗一些内存来提高速度
・有些keys会被多次查询
・你的cache保存的东西不会超过你机器的内存量
你应该选择Guava cache
获得一个Cache 用上面的code例子就可以了,但是自定义一个cache 会更有趣。
渲染
问自己关于你的内存的第一个问题应该是:有什么默认的方法来导入或是计算key的值吗。如果是这样的话,你应使用CacheLoader 。如果不是的话,或者说,你需要覆盖掉默认的方法,但你仍想保留“存在就直接获取,不存在就去计算”这种机制时,你应该往get方法调用中传一个callable 。使用Cache.put 可以直接插入元素,但是从所有数据缓存一致性方面来说,使用自动的缓存导入方法更加简单。
使用CacheLoader
一个LoadingCache 就是关联了一个CacheLoader 的缓存。创建一个CacheLoader 就跟实现方法V load(K key) throws Exception 一样简单。你可以用如下的例子来创建LoadingCache :
1 LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() 2 .maximumSize(1000) 3 .build( 4 new CacheLoader<Key, Graph>() { 5 public Graph load(Key key) throws AnyException { 6 return createExpensiveGraph(key); 7 } 8 }); 9 10 ... 11 try { 12 return graphs.get(key); 13 } catch (ExecutionException e) { 14 throw new OtherException(e.getCause()); 15 }
查询LoadingCache 的权威方法是用get(K) 。如果已经换存了值,就会直接返回;如果没有,就会使用CacheLoader 来往缓存中自动导入一个新值。因为CacheLoader 会抛出Exception ,LoadingCache.get(K)可能会抛出ExecutionException 。你也可以用getUnchecked(K) ,它在UncheckedExecutionException 中包装了所有的UncheckedExecutionException ,但是,如果CacheLoader 抛出了 checked exceptions的话,会导致奇怪的行为发生。
1 LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() 2 .expireAfterAccess(10, TimeUnit.MINUTES) 3 .build( 4 new CacheLoader<Key, Graph>() { 5 public Graph load(Key key) { // no checked exception 6 return createExpensiveGraph(key); 7 } 8 }); 9 10 ... 11 return graphs.getUnchecked(key);
体积查询可以用方法getAll(Iterable<? extends K>) 。默认情况下,getAll 会对CacheLoader.load 产生一个单独的调用,对cache中每个不存在缓存值的key ,进行取值。当体积的查询已经比单个查询效率更高时,你可以通过覆盖CacheLoader.loadAll 方法,来开发它。
注意:你可以写一个CacheLoader.loadAll 的实现为那些没有特殊指定的key来导入值。比如:如果计算某些group中的任意key的值,会给你group内所有key的值,loadAll 也许会同时导入group内其他key的值。
From a Callable
所有Guava缓存,无论是否是导入,都支持get(K, Callable<V>) 方法。这个方法返回内存中这个key关联的值,或是用Callable 接口计算得到的值并将它加入内存中。知道load() 使用,对内存的修改才有了一个可观察的状态。这个方法为“如果缓存了,返回缓存之;没有缓存则创建,缓存并放回”这个模式提供了一个简单的替代品。
1 Cache<Key, Value> cache = CacheBuilder.newBuilder() 2 .maximumSize(1000) 3 .build(); // look Ma, no CacheLoader 4 ... 5 try { 6 // If the key wasn't in the "easy to compute" group, we need to 7 // do things the hard way. 8 cache.get(key, new Callable<Value>() { 9 @Override 10 public Value call() throws AnyException { 11 return doThingsTheHardWay(key); 12 } 13 }); 14 } catch (ExecutionException e) { 15 throw new OtherException(e.getCause()); 16 }
直接插入
值必须用cache.put(key, value) 方法来插入到缓存中。这个覆写了内存中制定key的元素。值的变化也可以使用被Cache.asMap() 暴露出来的、ConcurrentMap 的任意的一个方法。注意的是,asMap 视图中没有任何方法会让键值对自动导入到内存中,所以使用Cache.get(K, Callable<V>) 与使用CacheLoader 或是 Callable 来导入内存的Cache.asMap().putIfAbsent相比,前者更好。
驱逐
残酷的事实是我们没有足够的内存缓存所有东西。你必须决定:何时内存值不值得保存了。Guava 提供三种驱逐方式:基于大小,基于时间,基于引用。
容量驱逐
如果你缓存的值的数量不应该超过一定的数量,那么就用CacheBuilder.maximumSize(long) 方法。缓存会驱逐最近没被使用的,或是不常用的。警告:内存可能会在数量超过前,将键值对驱逐,基本上是当数量达到限定值。
如果内存的键值对有不通的权重时,它们会交替执行,比如:如果你的内存值有完全不同的内存覆盖范围,你可以制定一个权重的函数CacheBuilder.weigher(Weigher) 和一个最大缓存权重的函数CacheBuilder.maximumWeight(long) 。此外,正如maximumSize 所要求的,要意识到权重时每回创建时计算的,并且,那之后,是静态的。
1 LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() 2 .maximumWeight(100000) 3 .weigher(new Weigher<Key, Graph>() { 4 public int weigh(Key k, Graph g) { 5 return g.vertices().size(); 6 } 7 }) 8 .build( 9 new CacheLoader<Key, Graph>() { 10 public Graph load(Key key) { // no checked exception 11 return createExpensiveGraph(key); 12 } 13 });
超时驱逐
CacheBuilder 提供两种超时驱逐:
expireAfterAccess(long, TimeUnit) 只用最后被读过或是写过的内存,经历过存活时间之后,才会死亡。注意键值对被驱逐的时间容量驱逐很相似。
expireAfterWrite(long, TimeUnit) 当被创建的键值对或是最近被替换过的,经过一段存活期间后,会走向死亡。这个可用于经历过一段期间后,缓存的数据变得过期数据,这样场景下使用。
Testing Timed Eviction
测试超时驱逐不是很难,也不必花上2秒钟去测试一个2秒超时。使用Ticker 接口和 CacheBuilder.ticker(Ticker) 方法在你的cache 中指定时间,而不是去等待系统时钟的2秒。
基于引用的驱逐
Guava 允许你建立基于垃圾回收的缓存,可以使用弱引用和软引用。
(注:Java中的引用分为四种:强、软、弱、虚
强引用:Java之中普遍存在,如Object object = new Object() 只要引用存在,垃圾回收器永远不会回收掉被引用的对象
软引用:描述一些有用,但非必须的对象。在系统将要发生内存溢出时,会将这些对象放进回收范围之内,进行回收
弱引用:描述非必需的对象,强度比软引用弱,无论当前内存是否充足,垃圾回收时都会对其进行回收
虚医用:最弱的一种引用关系。设置虚引用,唯一的目的就是,在这个对象呗收集器回收时收到一个系统通知
引自《深入理解Java虚拟机-周志华 )
・CacheBuilder.weakKeys() 使用弱引用来保存key值。如果没有其他引用指向这个key,那么它将允许被垃圾收集器回收掉。既然垃圾回收仅依赖于恒等式的一致,这就导致整个缓存用 == 来比较key,而不是equals()。
・CacheBuilder.weakValues() 使用弱引用来保存value值。如果没有其他引用指向这个value,那么它将允许被垃圾收集器回收掉。既然垃圾回收仅依赖于恒等式的一致,这就导致整个缓存用 == 来比较value,而不是equals()。
・CacheBuilder.softValues() 用软引用包装值。应对内存的需求,软引对象使用最近最少使用规则,来进行垃圾回收。因为使用软引用的性能上的关系,我们通常建议使用最大缓存数量。softValues() 的使用会导致使用整个缓存用 == 比较value,而不是equals()。
监视移除
你会制定一个监视器,可以通过CacheBuilder.removalListener(RemovalListener) ,来监视键值对在缓存中被移除。RemovalListener 获得了一个RemovalNotification, 它指定了RemovalCause ,键和值。
注意,任何被RemovalListener 抛出的异常都会被打进log里。
1 CacheLoader<Key, DatabaseConnection> loader = new CacheLoader<Key, DatabaseConnection> () { 2 public DatabaseConnection load(Key key) throws Exception { 3 return openConnection(key); 4 } 5 }; 6 RemovalListener<Key, DatabaseConnection> removalListener = new RemovalListener<Key, DatabaseConnection>() { 7 public void onRemoval(RemovalNotification<Key, DatabaseConnection> removal) { 8 DatabaseConnection conn = removal.getValue(); 9 conn.close(); // tear down properly 10 } 11 }; 12 13 return CacheBuilder.newBuilder() 14 .expireAfterWrite(2, TimeUnit.MINUTES) 15 .removalListener(removalListener) 16 .build(loader);
警告:监视器的操作默认是同步的,因此,内存的保持一般来说都是正常操作。花费(时间)较大的监视器会拖慢缓存的功能。如果,你有一个花费(时间)较大的监视器,异步地使用RemovalListeners.asynchronous(RemovalListener, Executor) 来装饰RemovalListener 。
什么时候发生清空操作?
用CacheBuilder 建立的缓存不会发生清空,不会自动驱逐值,不会当值过期后立即清除,不会清除任何排序的东西。相反,在读写操作发生后,它会有短暂的保留。
原因如下:如果要缓存一直可用,那么我们需要创建一个线程,它的操作需要user的操作来完成。此外,一些环境限制我们创建线程,这样,会导致CacheBuilder 不可用。
相反呢,我们让您来决定。如果缓存有比较高的吞吐量,那么你不必担心缓存一直可用会清理掉过期的键值对。如果你的缓存,仅仅的写操作,你不想让清空来锁住缓存的读取,你会希望创建你自己的保持线程,以常规的间隔来调用Cache.cleanUp() 。
如果你想为几乎只有写操作的缓存来定制常规的内存保持,那么就用ScheduledExecutorService 。
刷新
刷新和驱逐不太一样。正如LoadingCache.refresh(K) 中指定的,刷新key导入一个新值,可能是异步地操作。和驱逐做对比,当刷新时,强制查询直到获取新值时,返回的仍是旧值。
如果刷新时有异常发生,异常会被记录在log中。
CacheLoader 会以通过覆盖CacheLoader.reload(K, V) 这个方法来使用刷新。这个方法允许你在计算新值时使用旧值。
1 // Some keys don't need refreshing, and we want refreshes to be done asynchronously. 2 LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() 3 .maximumSize(1000) 4 .refreshAfterWrite(1, TimeUnit.MINUTES) 5 .build( 6 new CacheLoader<Key, Graph>() { 7 public Graph load(Key key) { // no checked exception 8 return getGraphFromDatabase(key); 9 } 10 11 public ListenableFuture<Graph> reload(final Key key, Graph prevGraph) { 12 if (neverNeedsRefresh(key)) { 13 return Futures.immediateFuture(prevGraph); 14 } else { 15 // asynchronous! 16 ListenableFutureTask<Graph> task = ListenableFutureTask.create(new Callable<Graph>() { 17 public Graph call() { 18 return getGraphFromDatabase(key); 19 } 20 }); 21 executor.execute(task); 22 return task; 23 } 24 } 25 });
使用CacheBuilder.refreshAfterWrite(long, TimeUnit) 方法可以将时间刷新加入到缓存中。和expireAfterWrite 相比较,refreshAfterWrite 会让一个值在指定的时间段之后进行刷新,但是刷新也只有当键值对被查询时才会开始。所以,举例子来说,你可以同时指定refreshAfterWrite 和 expireAfterWrite ,所以当键值对可以被刷新时,驱逐计时器不会盲目地被重置,所以,当一个键值对可以被刷新时,但是此时没有被查询,那么,它将会被驱逐。
特性
统计数据
通过CacheBuilder.recordStats() 你可以为Guava 缓存打开数据收集。Cache.stats() 方法返回一个Cache.stats() 对象,这个对象提供了统计数据,如:
・hitRate() 返回采样数的比率
・averageLoadPenalty() 导入新值平均花费时长 单位:纳秒
・evictionCount() 缓存驱逐的个数
此外还有许多其他的统计数据。这些统计数据在缓存优化方面启动决定性作用,我们建议在性能很重要的应用中,留心这些统计数据。asMap
你可以将缓存看做是一个使用asMap 视图的ConcurrentMap 。但是,asMap 视图和缓存如何交互需要下面的一些解释。
・cache.asMap() 包含了所有现在导入缓存中的键值对。所以,比如,cache.asMap().keySet() 包含了所有导入的key
・asMap().get(key) 本质上与cache.getIfPresent(key) 相等,从不会引起值的导入。这个和Map相比,是一致的。
・读写操作会导致access time被重置。但containsKey(Object) 和Cache.asMap() 操作不会导致重置发生。举例子来说,用cache.asMap().entrySet() 来迭代不会导致access time被重置。
中断
像get() 这样的导入方法永远不会抛出InterruptedException。不过,我们可以设计这些方法来支持InterruptedException 。但是,我们的支持并不是完整的,强制地在所用用户上产生花销只会收益很少。具体来说,比如读取。
get 把那些请求的、未缓存的值大体分为两类:那些导入的的值和那些等待另一个线程导入的值。这两者以不同方式支持中断。简单的方法是等待另一个正在执行的线程完事后,再进行导入。这里呢,我们就会进入可中断的等待。比较难的方法是我们自己导入值。我们用用户定义的CacheLoader 。如果它支持中断,那么我们可以支持中断;如果不行,那么我们也不能支持中断。
那么为什么当提供的CacheLoader 支持中断,而自定义的不支持呢?某种意义上来说,我们支持中断。如果CacheLoader 抛出中断异常,所有关于key 的调用会立即返回。此外,get 会在导入线程中存储中断标记位。惊奇的是,InterruptedException 被包装在ExecutionException 中。
原则上讲,我们可以不为你包装这个异常。然而,这将导致强迫所有LoadingCache 用户去处理InterruptedException ,即使是那些从未抛出中断异常的、CacheLoader 的实现。也许你考虑那些非导入线程的登台可以诶中断是值得的,但是需要缓存只是单一线程。他们额用户必须仍要catch不可能的InterruptedException 。
在这部分我们的原则是让缓存在所有调用的线程中导入值。这个原则让每个调用中再计算值变得简单。如果旧代码不可被中断,那么,或许对于新代码来说也是不可被中断。
我说过我们在某种意义上支持中断。在另一层(让LoadingCache 作为有漏洞的抽象)来说,我们不支持中断。如果导入线程被中断了,我们很可能将这个异常看做其他异常。这个,在很多地方来说,没有大碍。但是当多次调用get 等待返回值时,就会出错。虽然,刚巧要计算值得操作被中断了,其他的需要这个值的一些操作不会被执行。然而,这些调用者收到InterruptedException (包装在ExecutionException中), 即使导入没有将失败作为终止。正确的行为将是遗留下来的一个线程再次进行尝试。关于我们有个一个bug列表(https://github.com/google/guava/issues/1122)。然而,修正的话也有一定风险。并非是修正问题,我们会投入额外的精力到被推荐的AsyncLoadingCache 中,它面对中断会做出正确的行为,同时返回Future 对象。