1. 概述
缓存是我们日常开发中是必不可少的一种解决性能问题的方法。早期缓存只应用在CPU和内存之间,现在遍布在每一个角落:内存和磁盘,磁盘和网路都存在缓存。缓存同样是做Java应用必不可少的元素。
Cache(缓存)在很广泛的场景下都是很有用的。比如,当一个值的计算或者检索的代价很大,并且在稍后的特定输入发生后,你将不止一次的需要这个值的时候,就应当考虑使用Cache了。
Cache是和ConcurrentMap很相像的东西。最本质的不同就是,ConcurrentMap持有所有被添加进的元素,直到它们专门被移除。从另一方面来说,为了使得内存的使用可控,Cache通常来说是可配置来自动回收元素的。在一些情况下,即使LoadingCache也会很有用,虽然由于他的自动缓存加载机制,它不回收元素。
注意:如果你不需要Cache的一些特性,那么ConcurrentHashMap是更加有内存效率的--但是使用任何旧的ConcurrentMap都会很难或者几乎不可能去完成Cache所支持的一些特性。
通常,Guava Cache组件在下面的场景中适用:
- 你想花费一些内存来提高速度。
- 你预期到一些key的值将被不止一次地被查询。
- 你的缓存将不会需要比内存能够存储的数据更多(Guava Cache对于一个单独运行的应用来说是本地的。它们不回将数据存储在文件中或者外部服务器上。如果这不适合你的需求,那么考虑使用其它的工具,比如Memcached或者Redis)。
如果上面的这些都符合你的需要,那么Guava Cache组件将适合你!
2. Guava Cache的使用示例
使用缓存时,最常遇到的场景需要就是:
“获取缓存-如果没有-则计算”,即[get-if-absent-compute]的原子语义
具体含义:
- 从缓存中取;
- 缓存中存在该数据,直接返回。
- 缓存中不存在该数据,从数据源中取;
- 数据源中存在该数据,放入缓存,并返回。
- 数据源中不存在该数据,返回空。
Guava Cache有两种方式实现:
- 一种是CacheLoader,在定义的时候就设置好缓存的源;
- 另一种是Callable,在调用缓存的时候指定如果缓存中没有的获取的方式。
通过这两种方式创建的cache,和通常用map来缓存的做法比,不同在于,这两种方法都实现了一种逻辑:
从缓存中取key 的值,如果该值已经缓存过了,则返回缓存中的值,如果没有缓存过,可以通过某个方法来获取这个值。
但不同的在于cacheloader的定义比较宽泛,是针对整个cache定义的,可以认为是统一的根据key值load value的方法。而callable的方式较为灵活,允许你在每次get的时候自定义获取Value的方式。
2.1. 准备工作
预先准备好一个MockDB类,用来模拟缓存中没有的时候在数据库中获取。
1 | public class MockDB { |
2.2. 使用CacheLoader
下面是使用CacheLoader的代码:
1 | package com.wxweven.cache.guavatest; |
我们逐行进行解释:
expireAfterWrite(3, TimeUnit.SECONDS)
:定义缓存3秒过期;removalListener
:用来监听当缓存里面的key被移除时候触发的事件;build(new CacheLoader<String, Optional<String>>()
:传入一个CacheLoader类,指定缓存中没有的时候调用 CacheLoader 类的load方法(所以一般需要重写该方法);- Optional:当CacheLoader尝试获取数据库中不存在的数据会抛出异常,所以我们这里使用Optional可空对象处理一下。
- Thread.sleep(2000):缓存我们设置3秒过期,所以两次Sleep以后就会重新获取数据库。
运行输出结果如下:
1 | load key from persistence : github |
证明了再第三次获取的时候因为缓存过期了,所以需要重新在MockDB获取数据。
2.3. 使用Callable
这里我们依然需要使用上面的MockDB类,具体代码如下。
1 | package com.wxweven.cache.guavatest; |
下面我们对程序进行解释:
- 与上面例子唯一的不同就是没有在build的时候传入CacheLoader,而是在cache.get使用Cache的时候用传入Callable对象。
- 这样做可以灵活配置每次获取的缓存源不一样,但是两种方案都各有好处,还是在使用的时候斟酌。
运行程序数据结果如下:
1 | load key from persistence : github |
3. 回收策略
所有的cache都需要定期remove value,下面我们看看guava cache的回收策略。
3.1. 基于容量的回收(Eviction by Size)
3.1.1. maximumSize限定缓存最大容量
我们可以通过maximumSize()方法限制cache的size,如果cache达到了最大限制,默认情况下,Guava将会回收最老的缓存。
1 | package com.wxweven.cache; |
程序输出如下:
1 | 缓存大小key:0 |
由于我们设定了缓存的最大大小为3,所以在获取 forth
时,Guava默认会将缓存中最老的key删除掉,即本例中的 first
。当再次获取 first
时,我们会发现得到的是null。
3.1.2. maximumWeight限定缓存最大容量
我们也可以通过定制weight function来限制cache size,以下为自定义weight function实现的限制cache size 的示例:
1 |
|
程序输出如下:
1 | 缓存大小:0 |
可以看到,由于我们指定了缓存的maximumWeight
为11
,而且是根据缓存中值的长度来计算weight
,所以缓存最大能存放11
个长度的字符串,所以当获取 third
时容量就已经不足,就会让 first
失效。
3.2. 定时回收(Eviction by Time)
除了通过size来回收记录,我们也可以选择定时回收。
CacheBuilder提供两种定时回收的方法:
expireAfterAccess(long, TimeUnit)
:缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。expireAfterWrite(long, TimeUnit)
:缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。
下面代码 将示例expireAfterAccess的用法:
1 |
|
程序输出如下:
1 | 缓存大小:0 |
可以看到,指定缓存访问(Access,读或写)过期时间为2秒,sleep 3秒后,first
过期,第二次获取需要重新load;再sleep 3秒后,first
又过期,从最后的缓存大小为1可以验证。
上面的例子演示的是 expireAfterAccess
, 我们稍加改动,将其改为 expireAfterWrite
,如下所示,然后再来看看:
1 |
|
程序输出:
1 | 缓存大小:0 |
可以看到,第二次获取 first
没有重新加载,而第三次获取 first
重新加载了。就是因为间隔了 2 秒,first 仍然没有被重新写。
如果我们把上面代码中的注释行:
1 | cache.put("first", "first-new"); |
去掉,输出如下:
1 | 缓存大小:0 |
可以看到,第三次获取 first 并没有重新加载,因为在过期之前,缓存被重新写入了。
3.3. 基于引用的回收(Reference-based Eviction)
通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:
- CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用键的缓存用==而不是equals比较键。
- CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是equals比较值。
3.4. 显式清除
任何时候,你都可以显式地清除缓存项,而不是等到它被回收:
- 个别清除:Cache.invalidate(key)
- 批量清除:Cache.invalidateAll(keys)
- 清除所有缓存项:Cache.invalidateAll()
4. 移除监听(RemovalNotification)
通过CacheBuilder.removalListener(RemovalListener),你可以声明一个监听器,以便缓存项被移除时做一些额外操作。缓存项被移除时,RemovalListener会获取移除通知[RemovalNotification],其中包含移除原因[RemovalCause]、键和值。
请注意,RemovalListener抛出的任何异常都会在记录到日志后被丢弃[swallowed]。
1 |
|
5. 刷新( Refresh the Cache)
刷新和回收不太一样。正如LoadingCache.refresh(K)所声明,刷新表示为键加载新值,这个过程可以是异步的。在刷新操作进行时,缓存仍然可以向其他线程返回旧值,而不像回收操作,读缓存的线程必须等待新值加载完成。
如果刷新过程抛出异常,缓存将保留旧值,而异常会在记录到日志后被丢弃[swallowed]。
重载CacheLoader.reload(K, V)可以扩展刷新时的行为,这个方法允许开发者在计算新值时使用旧的值。
1 |
|
CacheBuilder.refreshAfterWrite(long, TimeUnit)可以为缓存增加自动定时刷新功能。和expireAfterWrite相反,refreshAfterWrite通过定时刷新可以让缓存项保持可用,但请注意:缓存项只有在被检索时才会真正刷新(如果CacheLoader.refresh实现为异步,那么检索不会被刷新拖慢)。因此,如果你在缓存上同时声明expireAfterWrite和refreshAfterWrite,缓存并不会因为刷新盲目地定时重置,如果缓存项没有被检索,那刷新就不会真的发生,缓存项在过期时间后也变得可以回收。
1 |
|
6. 处理空值(Handle null Values)
实际上Guava整体设计思想就是拒绝null的,很多地方都会执行com.google.common.base.Preconditions.checkNotNull的检查。
By default, Guava Cache will throw exceptions if you try to load a null value – as it doesn’t make any sense to cache a null.
But if null value means something in your code, then you can make good use of the Optional class as in the following example:
默认情况guava cache将会抛出异常,如果试图加载null value–因为cache null 是没有任何意义的。
但是如果null value 对你的代码而已有一些特殊的含义,你可以尝试用Optional来表达,像下面这个例子:
1 | @Test |
7. 统计
CacheBuilder.recordStats()用来开启Guava Cache的统计功能。统计打开后,Cache.stats()方法会返回CacheStats对象以提供如下统计信息:
- hitRate():缓存命中率;
- averageLoadPenalty():加载新值的平均时间,单位为纳秒;
- evictionCount():缓存项被回收的总数,不包括显式清除。
此外,还有其他很多统计信息。这些统计信息对于调整缓存设置是至关重要的,在性能要求高的应用中我们建议密切关注这些数据。
8. getUnchecked
什么时候用get,什么时候用getUnchecked
官网文档说:
- If you have defined a CacheLoader that does not declare any checked exceptions then you can perform cache lookups using getUnchecked(K);
- However care must be taken not to call getUnchecked on caches whose CacheLoaders declare checked exceptions.
字面意思是,如果你的CacheLoader没有定义任何checked Exception,那你可以使用getUnchecked。但是 ,一定要注意,如果CacheLoader声明了checked exceptions,那就不要调用getUnchecked。
9. 总结
在设计Java分布式应用程序的时候,针对一些基本不变的数据,或者是变化不大然而使用非常频繁的数据可以考虑采用Guava Cache实现Java应用内存级别缓存。