guava cache详细介绍

tech2024-07-26  44

官方文档: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>() { @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

2、callable方式:

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); } }); private static String getSchema(String key) { System.out.println("load..."); return key+"schema"; } try { String value = cache.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(); } //输出值: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>() { @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方法计算,然后将值放入缓存。

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>() { @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);

四、刷新策略

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>() { @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,如:

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>() { @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

最新回复(0)