官方文档: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直接插入元素,但是首选自动缓存加载,因为这样可以更轻松地推断所有缓存内容的一致性(毕竟是原子的语义操作)。
在每次从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>() { @Override public String load(final String key) { return getSchema(key); } @Override 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。
见:https://blog.csdn.net/liuxiao723846/article/details/108392956
或者:
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>() { @Override 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方法计算,然后将值放入缓存。
除了上面两种方式创建缓存外,还可以显示的使用put(K,V)方法,将值放入缓存中。但是这种方法没有“ get-if-absent-compute”语义。
由于guava是本地缓存,所以需要一个回收策略。guava提供了三种回收策略。
通过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); } });guava Cache提供两种定时回收的方法:
expireAfterAccess(long, TimeUnit):缓存项(Key)在给定时间范围内没有读/写访问,那么下次访问时,会回收该Key,然后同步load(),这种方式类似于基于size的LRU回收。()expireAfterWrite(long, TimeUnit):缓存项(Key)在给定时间范围内没有写访问,那么下次访问时,会回收该Key,然后同步load()。注:Guava Cache不会专门维护一个线程来回收这些过期的缓存项,只有在读/写访问时,才去判断该缓存项是否过期,如果过期,则会回收。而且注意,回收后会同步调用load方法来加载新值到cache中。
通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:
CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)以上是guava自动维护的,当然我们也可以手动将缓存值清理出cache,见下面。
在任何时候,可以通过一下方法将Key从缓存中移除,而不用等guava的回收。
清除单个Key:Cache.invalidate(key)批量清除:Cache.invalidateAll(keys)清除所有缓存项:Cache.invalidateAll()通过CacheBuilder.removalListener(RemovalListener),你可以声明一个监听器,以便缓存项被移除时做一些额外操作。缓存项被移除时,RemovalListener会获取移除通知RemovalNotification,其中包含移除原因RemovalCause、键和值。
RemovalListener<String, String> removalListener = new RemovalListener<String,String>() { @Override 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);使用CacheBuilder构建的缓存不会“自动”执行清理和逐出值,也不会在值过期后立即执行清理或逐出值,或类似的任何操作。取而代之的是,如果写操作很少,它会在写操作期间或偶尔的读操作期间执行少量维护。
原因如下:如果我们要连续执行Cache维护,则需要创建一个线程,并且该线程的操作将与用户操作争夺共享锁。此外,某些环境限制了线程的创建,这会使CacheBuilder在该环境中无法使用。
相反,我们会将选择权交给您。如果您的缓存是高吞吐量的,那么您不必担心执行缓存维护以清理过期的条目等。如果您的缓存确实很少写入,并且您不想清理来阻止缓存读取,则您可能希望创建自己的维护线程,该线程定期调用Cache.cleanUp()。
最后一句话怎么理解:如果流量很大,每时每刻都在访问cache,那么guava会自动根据回收策略进行清理数据。如果量很小,由于guava的惰性原理,不会及时回收,用户可以自己定时清理。
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>() { @Override 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>() { @Override 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,如:
我们对比一下几种策略:
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>() { @Override 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>() { @Override public Object load(String key) throws Exception { return generateValueByKey(key); } @Override public ListenableFuture<Object> reload(String key, Object oldValue) throws Exception { return backgroundRefreshPools.submit(new Callable<Object>() { @Override 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) 同时配置,并且刷新的时间间隔要比过期的时间间隔短!这样当较长时间没有被访问的缓存项突然被访问时,会触发过期回收而不是刷新,后面会分析这一块的源码,而热点数据只会触发刷新操作不会触发回收操作。
参考:
https://juejin.im/post/6844904142645755918
https://juejin.im/entry/6844903793629331470
https://blog.csdn.net/u012859681/article/details/75220605