官方文档:https://github.com/google/guava/wiki/CachesExplained
guava cache是google开源的一款本地缓存工具库,它的设计灵感来源于ConcurrentHashMap,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求,同时支持多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>19.0</version>
</dependency>
一、创建缓存的两种方式
根据官网上介绍,使用guava cache先问自己一个问题:是否存在一个默认函数来加载或计算与键关联的值?如果是这样,则应使用CacheLoader。如果不是这样,但仍希望使用原子的“ get-if-absent-compute”语义,则应将Callable传递给get调用。虽然可以使用Cache.put直接插入元素,但是首选自动缓存加载,因为这样可以更轻松地推断所有缓存内容的一致性(毕竟是原子的语义操作)。
1、CacheLoader的方式:
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());
}
...
//或者使用下面方法,不抛出异常
return graphs.getUnchecked(key);
在每次从cache中get(K)时,如果不存在会自动调用load方法原子的将值计算出来并加到缓存中。(调用load方法是同步的)
1)get(K)和getUnchecked(K)方法:
由于CacheLoader可能会引发异常,因此LoadingCache.get(K)会引发ExecutionException。还可以选择使用getUnchecked(K)方法获取值,不抛出异常。
2)批量get和批量load:
getAll(Iterable<? extendsK>)方法用来执行批量查询。默认情况下,对每个不在缓存中的键,getAll方法会单独调用CacheLoader.load来加载缓存项。如果批量的加载比多个单独加载更高效,可以重载CacheLoader.loadAll来利用这一点提示getAll(Iterable)的性能。看一个例子:
public LoadingCache<String, String> caches = CacheBuilder
.newBuilder().maximumSize(100)
.expireAfterWrite(100, TimeUnit.SECONDS) // 根据写入时间过期
.build(new CacheLoader<String, String>() {
public String load(final String key) {
return getSchema(key);
}
public Map<String,String> loadAll(final Iterable<? extends String> keys) throws Exception {
//com.google.common.collect.Lists
ArrayList<String> keysList = Lists.newArrayList(keys);
return getSchemas(keysList);
}
});
private static Map<String,String> getSchemas(List<String> keys) {
Map<String,String> map = new HashMap<>();
//...
System.out.println("loadall...");
return map;
}
List<String> keys = new ArrayList<>();
keys.add("key2");
keys.add("key3");
try {
caches.getAll(keys);
} catch (ExecutionException e1) {
e1.printStackTrace();
}
注意:expireAfterWrite时guava重新加载数据时使用的是load方法,不会调用loadAll。
见:
2、callable方式:
LoadingCache<String, String> cache = CacheBuilder
.newBuilder().maximumSize(100)
.expireAfterWrite(100, TimeUnit.SECONDS) // 根据写入时间过期
.build(new CacheLoader<String, String>() {
public String load(String key) {
return getSchema(key);
}
});
private static String getSchema(String key) {
System.out.println("load...");
return key+"schema";
}
try {
String value = cache.get("key4", new Callable<String>() {
public String call() throws Exception {
System.out.println("i am callable...");
return "i am callable...";
}
});
System.out.println(value);
} catch (ExecutionException e1) {
e1.printStackTrace();
}
//输出值:i am callable...
或者:
Cache<String, String> cache2 = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(100, TimeUnit.SECONDS) // 根据写入时间过期
.build(); // look Ma, no CacheLoader
try {
String value = cache2.get("key4", new Callable<String>() {
public String call() throws Exception {
System.out.println("i am callable...");
return "i am callable...";
}
});
System.out.println(value);
} catch (ExecutionException e1) {
e1.printStackTrace();
}
callable同样实现了原子的“ get-if-absent-compute”语义。上面两个例子说明:无论是LoadingCache还是Cache都可以使用callable的方式,需要说明的是:
1)Cache类型的缓存只能使用Callable的方式get(K,Callable)方法;
2)LoadingCache类型的缓存,可以使用get(K)或get(K,Callable)方法,并且如果使用的是get(K,Callable)方法,当K值不存在时,使用的是Callable计算值,不走load方法计算,然后将值放入缓存。
3、显示插入:
除了上面两种方式创建缓存外,还可以显示的使用put(K,V)方法,将值放入缓存中。但是这种方法没有“ get-if-absent-compute”语义。
二、回收(逐出)策略
由于guava是本地缓存,所以需要一个回收策略。guava提供了三种回收策略。
1、基于size回收:
通过CacheBuilder.maximumSize(long)设置缓存项的最大数目,当达到最大数目后,继续添加缓存项,Guava 默认会根据LRU策略回收缓存项来保证不超过最大数目; 另外,可以通过CacheBuilder.weigher(Weigher)设置不同缓存项的权重,Guava Cache根据权重来回收缓存项。
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);
}
});
2、定时回收:
guava Cache提供两种定时回收的方法:
- expireAfterAccess(long, TimeUnit):缓存项(Key)在给定时间范围内没有读/写访问,那么下次访问时,会回收该Key,然后同步load(),这种方式类似于基于size的LRU回收。()
- expireAfterWrite(long, TimeUnit):缓存项(Key)在给定时间范围内没有写访问,那么下次访问时,会回收该Key,然后同步load()。
注:Guava Cache不会专门维护一个线程来回收这些过期的缓存项,只有在读/写访问时,才去判断该缓存项是否过期,如果过期,则会回收。而且注意,回收后会同步调用load方法来加载新值到cache中。
3、基于引用回收:
通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:
- CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。
- CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收
- CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)
以上是guava自动维护的,当然我们也可以手动将缓存值清理出cache,见下面。
三、删除缓存和删除监听器
1、删除缓存Key方法:
在任何时候,可以通过一下方法将Key从缓存中移除,而不用等guava的回收。
- 清除单个Key:Cache.invalidate(key)
- 批量清除:Cache.invalidateAll(keys)
- 清除所有缓存项:Cache.invalidateAll()
2、删除监听器:
通过CacheBuilder.removalListener(RemovalListener),你可以声明一个监听器,以便缓存项被移除时做一些额外操作。缓存项被移除时,RemovalListener会获取移除通知RemovalNotification,其中包含移除原因RemovalCause、键和值。
RemovalListener<String, String> removalListener = new RemovalListener<String,String>() {
public void onRemoval(RemovalNotification<String, String> notification) {
String key = notification.getKey();
//例如是数据库连接,这里可以close该连接
String value = notification.getValue();
}
};
CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.MINUTES)
.removalListener(removalListener)
.build(loader);
四、刷新策略
1、什么时候进行清理?
使用CacheBuilder构建的缓存不会“自动”执行清理和逐出值,也不会在值过期后立即执行清理或逐出值,或类似的任何操作。取而代之的是,如果写操作很少,它会在写操作期间或偶尔的读操作期间执行少量维护。
原因如下:如果我们要连续执行Cache维护,则需要创建一个线程,并且该线程的操作将与用户操作争夺共享锁。此外,某些环境限制了线程的创建,这会使CacheBuilder在该环境中无法使用。
相反,我们会将选择权交给您。如果您的缓存是高吞吐量的,那么您不必担心执行缓存维护以清理过期的条目等。如果您的缓存确实很少写入,并且您不想清理来阻止缓存读取,则您可能希望创建自己的维护线程,该线程定期调用Cache.cleanUp()。
最后一句话怎么理解:如果流量很大,每时每刻都在访问cache,那么guava会自动根据回收策略进行清理数据。如果量很小,由于guava的惰性原理,不会及时回收,用户可以自己定时清理。
2、刷新缓存策略:
1)刷新和回收区别:
刷新策略和上面说的回收策略不太一样:刷新表示为key加载新值,这个过程可以是异步的(需要重写CacheLoader的reload方法,否则仍然是同步的调用load),而回收Key的时候,会调用load方法加载值,这个过程是同步的。
二者的相同点是:都是在访问缓存项(Key)的时候才会触发。
2)刷新方法:
可以调用LoadingCache.refresh(K) 来刷新某个Key。(注:只有LoadingCache类才有refresh方法)
public static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 50, 300, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(50),
new ThreadFactory(){ public Thread newThread(Runnable r) {
return new Thread(r, "pool_" + r.hashCode());
}}, new ThreadPoolExecutor.DiscardOldestPolicy());
public static LoadingCache<String, String> cache = CacheBuilder
.newBuilder().maximumSize(100)
.expireAfterWrite(100, TimeUnit.SECONDS) // 根据写入时间过期
.build(new CacheLoader<String, String>() {
public String load(String key) {
return getSchema(key);
}
public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
ListenableFutureTask<String> task = ListenableFutureTask.create(new Callable<String>() {
public String call() throws Exception {
Thread.sleep(1000);
System.out.println("async....");
return getSchema(key);
}
});
threadPool.submit(task);
return task;
}
});
//调用
cache.refresh(key1);//void
System.out.println("after refresh");
输出:
after refresh
async...
证明refresh方法是一个异步的。如果我们没有重写reload方法,那么看reload源码,就是默认的同步调用load,如:
3、刷新+回收策略:
我们对比一下几种策略:
1)定时过期回收:
前面我们知道可以配置expireAfterWrite或expireAfterAccess来设置定期回收,那我们现在来看下这种策略在高并发情况下是否存在“缓存击穿”问题?当高并发条件下同时进行get操作,而此时缓存值已过期时,会导致大量线程都调用生成缓存值的方法,比如从数据库读取。这时候就容易造成大量请求同时查询数据库中该条记录,也就是“缓存击穿”。
Guava cache则对此种情况有一定控制。当大量线程用相同的key获取缓存值时,只会有一个线程进入load方法,而其他线程则等待,直到缓存值被生成。这样也就避免了缓存击穿的危险。
2)定时刷新:
guava虽然不会有缓存击穿的情况,但是每当某个缓存值过期时,老是会导致大量的请求线程被阻塞。而Guava则提供了另一种缓存策略,缓存值定时刷新:更新线程调用load方法更新该缓存,其他请求线程返回该缓存的旧值。这样对于某个key的缓存来说,只会有一个线程被阻塞,用来生成缓存值,而其他的线程都返回旧的缓存值,不会被阻塞。
这里就需要用到Guava cache的refreshAfterWrite方法。例如:
LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
.maximumSize(100)
.refreshAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Object>() {
public Object load(String key) throws Exception {
return generateValueByKey(key);
}
});
try {
System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
e.printStackTrace();
}
注:前面两种策略中的定时,不是真正意义上的定时。Guava cache的刷新和回收都是需要依靠用户请求触发的。
3)异步刷新策略:
上面解决了同一个key的缓存过期时会让多个线程阻塞的问题,只会让用来执行刷新缓存操作的一个用户线程会被阻塞。由此可以想到另一个问题,当缓存的key很多时,高并发条件下大量线程同时获取不同key对应的缓存,此时依然会造成大量线程阻塞,并且给数据库带来很大压力。这个问题的解决办法就是将刷新缓存值的任务交给后台线程,所有的用户请求线程均返回旧的缓存值,这样就不会有用户线程被阻塞了。
ListeningExecutorService backgroundRefreshPools =
MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));
LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
.maximumSize(100)
.refreshAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Object>() {
public Object load(String key) throws Exception {
return generateValueByKey(key);
}
public ListenableFuture<Object> reload(String key,
Object oldValue) throws Exception {
return backgroundRefreshPools.submit(new Callable<Object>() {
public Object call() throws Exception {
return generateValueByKey(key);
}
});
}
});
try {
System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
e.printStackTrace();
}
重写了CacheLoader的reload方法,在该方法中建立缓存刷新的任务并提交到线程池。
注意:因为刷新动作和回收一样,都是在检索的时候才会触发,所以当你的缓存配置了CacheBuilder.refreshAfterWrite(long, TimeUnit)时,如果部分缓存项很久没有被访问,那么再次被访问时,可能会获得过期很久的数据,这显然是不行的。而单独配置expireAfterWrite(long, TimeUnit)也是有问题的,如果热点数据突然过期,因为同步load()必然会影响读效率。
所以,通常我们都是CacheBuilder.refreshAfterWrite(long, TimeUnit)和expireAfterWrite(long, TimeUnit) 同时配置,并且刷新的时间间隔要比过期的时间间隔短!这样当较长时间没有被访问的缓存项突然被访问时,会触发过期回收而不是刷新,后面会分析这一块的源码,而热点数据只会触发刷新操作不会触发回收操作。