使用Guava Cache原理及最佳实践
作者:GNG
缓存的种类有很多,需要根据不同的应用场景来选择不同的cache,比如分布式缓存如redis、memcached,还有本地(进程内)缓存如:ehcache、GuavaCache、Caffeine。
本篇主要围绕全内存缓存-Guava Cache做一些详细的讲解和分析。
1. Guava Cache是什么
1.1 简介
Guava cache是一个支持高并发的线程安全的本地缓存。多线程情况下也可以安全的访问或者更新Cache。这些都是借鉴了ConcurrentHashMap的结果,不过,guava cache 又有自己的特性 :
"automatic loading of entries into the cache"
即 :当cache中不存在要查找的entry的时候,它会自动执行用户自定义的加载逻辑,加载成功后再将entry存入缓存并返回给用户未过期的entry,如果不存在或者已过期,则需要load,同时为防止多线程并发下重复加载,需要先锁定,获得加载资格的线程(获得锁的线程)创建一个LoadingValueRefrerence并放入map中,其他线程等待结果返回。
1.2 核心功能
- 自动将entry节点加载进缓存结构中;
- 当缓存的数据超过设置的最大值时,使用LRU算法移除;
- 具备根据entry节点上次被访问或者写入时间计算它的过期机制;
- 缓存的key被封装在
WeakReference
引用内; - 缓存的Value被封装在
WeakReference
或SoftReference
引用内; - 统计缓存使用过程中命中率、异常率、未命中率等统计数据。
小结:Guava Cache说简单点就是一个支持LRU的ConcurrentHashMap,并提供了基于容量,时间和引用的缓存回收方式。(简单概括)
1.3 适用场景
- 愿意消耗一些内存空间来提升速度(以空间换时间,提升处理速度);
- 能够预计某些key会被查询一次以上;
- 缓存中存放的数据总量不会超出内存容量(
Guava Cache
是单个应用运行时的本地缓存)。
- 计数器(如可以利用基于时间的过期机制作为限流计数)
2. Guava Cache的使用
GuavaCache使用时主要分二种模式:LoadingCache
、CallableCache
核心区别在于:LoadingCache创建时需要有合理的默认方法来加载或计算与键关联的值,CallableCache创建时无需关联固定的CacheLoader使用起来更加灵活。
前置准备:
- 引入jar包
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>29.0-jre</version> </dependency>
- 了解CacheBuilder的配置方法
- mock RPC调用方法,用于获取数据
private static List<String> rpcCall(String cityId) { // 模仿从数据库中取数据 try { switch (cityId) { case "0101": System.out.println("load cityId:" + cityId); return ImmutableList.of("上海", "北京", "广州", "深圳"); } } catch (Exception e) { // 记日志 } return Collections.EMPTY_LIST; }
2.1 创建LoadingCache缓存
使用CacheBuilder来构建LoadingCache实例,可以链式调用多个方法来配置缓存的行为。其中CacheLoader可以理解为一个固定的加载器,在创建LoadingCache时指定,然后简单地重写V load(K key) throws Exception方法,就可以达到当检索不存在的时候自动加载数据的效果。
//创建一个LoadingCache,并可以进行一些简单的缓存配置 private static LoadingCache<String, Optional<List<String>> > loadingCache = CacheBuilder.newBuilder() //配置最大容量为100,基于容量进行回收 .maximumSize(100) //配置写入后多久使缓存过期-下文会讲述 .expireAfterWrite(3, TimeUnit.SECONDS) //配置写入后多久刷新缓存-下文会讲述 .refreshAfterWrite(3, TimeUnit.SECONDS) //key使用弱引用-WeakReference .weakKeys() //当Entry被移除时的监听器-下文会讲述 .removalListener(notification -> System.out.println("notification=" + notification)) //创建一个CacheLoader,重写load方法,以实现"当get时缓存不存在,则load,放到缓存并返回的效果 .build(new CacheLoader<String, Optional<List<String>>>() { //重点,自动写缓存数据的方法,必须要实现 @Override public Optional<List<String>> load(String cityId) throws Exception { return Optional.ofNullable(rpcCall(cityId)); } //异步刷新缓存-下文会讲述 @Override public ListenableFuture<Optional<List<String>>> reload(String cityId, Optional<List<String>> oldValue) throws Exception { return super.reload(cityId, oldValue); } }); // 测试 public static void main(String[] args) { try { System.out.println("load from cache once : " + loadingCache.get("0101").orElse(Lists.newArrayList())); Thread.sleep(4000); System.out.println("load from cache two : " + loadingCache.get("0101").orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load from cache three : " + loadingCache.get("0101").orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load not exist key from cache : " + loadingCache.get("0103").orElse(Lists.newArrayList())); } catch (ExecutionException | InterruptedException e) { //记录日志 } }
执行结果
2.2 创建CallableCache缓存
在上面的build方法中是可以不用创建CacheLoader的,不管有没有CacheLoader,都是支持Callable的。Callable在get时可以指定,效果跟CacheLoader一样,区别就是两者定义的时间点不一样,Callable更加灵活,可以理解为Callable是对CacheLoader的扩展。CallableCache的方式最大的特点在于可以在get的时候动态的指定load的数据源
//创建一个callableCache,并可以进行一些简单的缓存配置 private static Cache<String, Optional<List<String>>> callableCache = CacheBuilder.newBuilder() //最大容量为100(基于容量进行回收) .maximumSize(100) //配置写入后多久使缓存过期-下文会讲述 .expireAfterWrite(3, TimeUnit.SECONDS) //key使用弱引用-WeakReference .weakKeys() //当Entry被移除时的监听器 .removalListener(notification -> System.out.println("notification=" + notification)) //不指定CacheLoader .build(); // 测试 public static void main(String[] args) { try { System.out.println("load from callableCache once : " + callableCache.get("0101", () -> Optional.ofNullable(rpcCall("0101"))).orElse(Lists.newArrayList())); Thread.sleep(4000); System.out.println("load from callableCache two : " + callableCache.get("0101", () -> Optional.ofNullable(rpcCall("0101"))).orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load from callableCache three : " + callableCache.get("0101", () -> Optional.ofNullable(rpcCall("0101"))).orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load not exist key from callableCache : " + callableCache.get("0103", () -> Optional.ofNullable(rpcCall("0103"))).orElse(Lists.newArrayList())); } catch (ExecutionException | InterruptedException e) { //记录日志 } }
执行结果:
2.3 其他用法
// 声明一个CallableCache,不需要CacheLoader private static Cache<String, Optional<List<String>>> localCache = CacheBuilder .newBuilder() .maximumSize(100) .expireAfterAccess(10, TimeUnit.MINUTES) .removalListener(notification -> System.out.println("notification=" + notification)) .build(); // 测试。使用时自主控制get、put等操作 public static void main(String[] args) { try { String cityId = "0101"; Optional<List<String>> ifPresent1 = localCache.getIfPresent(cityId); System.out.println("load from localCache one : " + ifPresent1); // 做判空,不存在时手工获取并put数据到localCache中 if (ifPresent1 == null || ifPresent1.isPresent() || CollectionUtils.isEmpty(ifPresent1.get())) { List<String> stringList = rpcCall(cityId); if (CollectionUtils.isNotEmpty(stringList)) { localCache.put(cityId, Optional.ofNullable(stringList)); } } Optional<List<String>> ifPresent2 = localCache.getIfPresent(cityId); System.out.println("load from localCache two : " + ifPresent2); // 失效某个key,或者loadingCache.invalidateAll() 方法 localCache.invalidate(cityId); Optional<List<String>> ifPresent3 = localCache.getIfPresent(cityId); System.out.println("load from localCache three : " + ifPresent3); } catch (Exception e) { throw new RuntimeException(e); } }
执行结果
通过上面三个案例的讲解,相信大家对于guava cache的使用应该没啥问题了,接下来一起学习缓存的失效机制!
3.缓存失效回收策略
前面说到Guava Cache与ConcurrentHashMap很相似,包括其并发策略,数据结构等,但也不完全一样。
最基本的区别是ConcurrentHashMap会一直保存所有添加的元素,直到显式地移除,而guava cache可以自动回收元素,在某种情况下Guava Cache 会根据一定的算法自动移除一些条目,以确保缓存不会占用太多内存,避免内存浪费。
3.1 基于容量回收
基于容量的回收是一种常用策略。在构建缓存时使用 CacheBuilder 的 maximumSize
方法来设置缓存的最大条目数。
当缓存中的条目数量超过了最大值时,Guava Cache 会根据LRU(最近最少使用)
算法来移除一些条目。例如:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() // 缓存最多可以存储1000个条目 .maximumSize(1000) .build();
除了 maximumSize,Guava Cache 还提供了 maximumWeight
方法和 weigher
方法,允许你根据每个条目的权重来限制缓存,而不是简单的条目数量。
这在缓存的条目大小不一致时特别有用。需要注意的是,淘汰的顺序仍然是根据条目的访问顺序,而不是权重大小。 例如:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() // 缓存最多可以存储的总权重 .maximumWeight(10000) .weigher(new Weigher<KeyType, ValueType>() { public int weigh(KeyType key, ValueType value) { // 定义如何计算每个条目的权重 return getSizeInBytes(key, value); } }) .build();
注意事项:
1、权重是在缓存创建时计算的,因此要考虑权重计算的复杂度。
3.2 定时回收
Guava Cache提供了两种基于时间的回收策略。
- 基于写操作的回收(expireAfterWrite)
使用 expireAfterWrite 方法设置的缓存条目在给定时间内没有被写访问(创建或覆盖),则会被回收。这种策略适用于当信息在一段时间后就不再有效或变得陈旧时。 例如,下面的代码创建了一个每当条目在30分钟内没有被写访问(创建或覆盖)就会过期的缓存:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .expireAfterWrite(30, TimeUnit.MINUTES) .build();
- 基于访问操作的回收(expireAfterAccess)
使用 expireAfterAccess 方法设置的缓存条目在给定时间内没有被读取或写入,则会被回收。这种策略适用于需要回收那些可能很长时间都不会被再次使用的条目。 例如,下面的代码创建了一个每当条目在15分钟内没有被访问(读取或写入)就会过期的缓存:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .expireAfterAccess(15, TimeUnit.MINUTES) .build();
3.3 基于引用回收
Guava Cache 提供了基于引用的回收机制,这种机制允许缓存通过使用弱引用(weak references)或软引用(soft references)来存储键(keys)或值(values),以便在内存紧张时能够自动回收这些缓存条目。
- 弱引用键(Weak Keys)
使用 weakKeys()
方法配置的缓存会对键使用弱引用。当键不再有其他强引用时,即使它还在缓存中,也可能被垃圾回收器回收。
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .weakKeys() .build();
弱引用键的缓存主要用于缓存键是可丢弃的或由外部系统管理生命周期的对象。例如,缓存外部资源的句柄,当句柄不再被应用程序使用时,可以安全地回收。
- 软引用值(Soft Values)
使用 softValues()
方法配置的缓存会对值使用软引用。软引用对象在内存充足时会保持不被回收,但在JVM内存不足时,软引用对象可能被垃圾回收器回收。
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .softValues() .build();
软引用值的缓存适合用于缓存占用内存较大的对象,例如图片或文档数据。当应用程序内存需求增加时,这些大对象可以被回收以释放内存。
注意事项:
1、基于引用的回收策略不是由缓存大小或元素的存活时间决定的,而是与JVM的垃圾回收机制紧密相关,而垃圾回收的行为会受到JVM配置和当前内存使用情况的影响,因此,引用回收策略下缓存回收具有不确定性,会导致缓存行为的不可预测性。
2、基于引用的回收策略通常不应与需要精确控制内存占用的场景混用。在使用基于引用的回收策略时,应该仔细考虑应用程序的内存需求和垃圾回收行为,以确保缓存能够按照预期工作。
3.4 显式清除
Guava Cache 提供了几种显式清除缓存条目的方法,允许你手动移除缓存中的某个或某些条目。
- 移除单个条目
使用 invalidate(key)
方法可以移除缓存中的特定键对应的条目。
cache.invalidate(key);
- 移除多个条目
使用 invalidateAll(keys)
方法可以移除缓存中所有在给定集合中的键对应的条目。
cache.invalidateAll(keys);
- 移除所有条目
使用 invalidateAll() 方法可以移除缓存中的所有条目。
cache.invalidateAll();
- 使用 Cache.asMap() 视图进行移除
通过缓存的 asMap() 方法获取的 ConcurrentMap 视图,你可以使用 Map 接口提供的方法来移除条目。
// 移除单个条目 cache.asMap().remove(key); // 批量移除条目 for (KeyType key : keys) { cache.asMap().remove(key); } // 移除满足特定条件的条目 cache.asMap().entrySet().removeIf(entry -> entry.getValue().equals(someValue));
注意事项:
asMap 视图提供了缓存的 ConcurrentMap 形式,这种方式在使用时和直接操作缓存的交互有区别,如下:
1、cache.asMap()包含当前所有加载到缓存的项。因此cache.asMap().keySet()包含当前所有已加载键;
2、asMap().get(key)实质上等同于 cache.getIfPresent(key),而且不会引起缓存项的加载。这和 Map 的语义约定一致。
3、所有读写操作都会重置相关缓存项的访问时间,包括 Cache.asMap().get(Object)方法和 Cache.asMap().put(K, V)方法,但不包括 Cache.asMap().containsKey(Object)方法,也不包括在 Cache.asMap()的集合视图上的操作。比如,遍历 Cache.asMap().entrySet()不会重置缓存项的读取时间。
- 注册移除监听器
可以在构建缓存时注册一个移除监听器(RemovalListener),它会在每次条目被移除时调用。
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .removalListener(new RemovalListener<KeyType, ValueType>() { @Override public void onRemoval(RemovalNotification<KeyType, ValueType> notification) { // 处理移除事件 } }) .build();
在实际项目实践中,往往是多种回收策略一起使用,让Guava Cache缓存提供多层次的回收保障。
4、缓存失效回收时机
缓存回收策略讲清楚后,那么这些策略到底是在什么时候触发的呢?我们直接说结论:
Guava Cache基于容量和时间的回收策略,清理操作不是实时的。缓存的维护清理通常发生在写操作期间,如新条目的插入或现有条目的替换,以及在读操作期间的偶然清理。这意味着,缓存可能会暂时超过最大容量限制和时间限制,直到下一次写操作触发清理。
Guava 文档中提到,清理工作通常是在写操作期间完成的,但是在某些情况下,读操作也会导致清理,尤其是当缓存的写操作比较少时。这是为了确保即使在没有写操作的情况下,缓存也能够维护其大小和条目的有效性。如果你需要确定缓存何时被清理,或者你想手动控制清理操作的时机可以通过「显式清除」的方式,条目删除操作会立即执行。
为了更好的理解上述说的结论,我们通过上面LoadingCache缓存的使用 结合idea debug执行分析一下。 源码分析见下一部分。
5、源码分析(简短分析)
以下是guava-20.0版本的源码分析。
- Segment中的get方法
@Override // 1、执行LocalLoadingCache中的get方法 public V get(K key) throws ExecutionException { return localCache.getOrLoad(key); } // 2、执行get 或 load方法 V getOrLoad(K key) throws ExecutionException { return get(key, defaultLoader); } // 3、核心get方法 V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException { int hash = hash(checkNotNull(key)); // segmentFor方法根据hash的高位从segments数组中取出相应的segment实例,执行segment实例的get方法 return segmentFor(hash).get(key, hash, loader); } // 4、Segment中的get方法 V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException { checkNotNull(key); checkNotNull(loader); try { // 当前Segment中存活的条目个数不为0 if (count != 0) { // read-volatile // don't call getLiveEntry, which would ignore loading values // getEntry会校验key,所以key为弱引用被回收的场景,取到的e是null。稍后展开介绍该方法 LocalCache.ReferenceEntry<K, V> e = getEntry(key, hash); if (e != null) { long now = map.ticker.read(); // 此处有个getLiveValue(),这个方法是拿到当前存活有效的缓存值,稍后展开介绍该方法 V value = getLiveValue(e, now); if (value != null) { // 记录该缓存被访问了。此时expireAfterAccess相关的时间会被刷新 recordRead(e, now); // 记录缓存击中 statsCounter.recordHits(1); // 用来判断是直接返回现有value,还是等待刷新 return scheduleRefresh(e, key, hash, value, now, loader); } LocalCache.ValueReference<K, V> valueReference = e.getValueReference(); // 只有key存在,但是value不存在(被回收)、或缓存超时的情况会到达这里 // 如果已经有线程在加载缓存了,后面的线程不会重复加载,而是等待加载的结果 if (valueReference.isLoading()) { return waitForLoadingValue(e, key, valueReference); } } } // at this point e is either null or expired; // 如果不存在或者过期,就通过loader方法进行加载(该方法会对当前整个Segment加锁,直到从数据源加载数据,更新缓存); // 走到这里的场景: // 1)segment为空 // 2)key或value不存在(没有缓存,或者弱引用、软引用被回收), // 3)缓存超时(expireAfterAccess或expireAfterWrite触发的) return lockedGetOrLoad(key, hash, loader); } catch (ExecutionException ee) { Throwable cause = ee.getCause(); if (cause instanceof Error) { throw new ExecutionError((Error) cause); } else if (cause instanceof RuntimeException) { throw new UncheckedExecutionException(cause); } throw ee; } finally { postReadCleanup(); } }
注意事项:
在cache get数据的时候,如果链表上找不到entry,或者value已经过期,则调用lockedGetOrLoad()方法,这个方法会锁住整个segment,直到从数据源加载数据,更新缓存。
如果并发量比较大,又遇到很多key失效的情况就会很容易导致线程block。 项目实践中需要慎重考虑这个问题,可考虑采用定时refresh机制规避该问题(下文会讲述refresh机制)。
- 根据hash和key获取键值对:getEntry
@Nullable ReferenceEntry<K, V> getEntry(Object key, int hash) { // getFirst用来根据hash获取table中相应位置的链表的头元素 for (ReferenceEntry<K, V> e = getFirst(hash); e != null; e = e.getNext()) { // hash不相等的,key肯定不相等。hash判等是int判等,比直接用key判等要快得多 if (e.getHash() != hash) { continue; } K entryKey = e.getKey(); // entryKey == null的情况,是key为软引用或者弱引用,已经被GC回收了。直接清理掉 if (entryKey == null) { // tryDrainReferenceQueues(); continue; } if (map.keyEquivalence.equivalent(key, entryKey)) { return e; } } return null; }
- getLiveValue方法
V getLiveValue(LocalCache.ReferenceEntry<K, V> entry, long now) { // 软引用或者弱引用的key被清理掉了 if (entry.getKey() == null) { // 清理非强引用的队列 tryDrainReferenceQueues(); return null; } V value = entry.getValueReference().get(); // 软引用的value被清理掉了 if (value == null) { // 清理非强引用的队列 tryDrainReferenceQueues(); return null; } // 在这里map.isExpired(entry, now)满足条件执行清除tryExpireEntries(now) if (map.isExpired(entry, now)) { tryExpireEntries(now); return null; } return value; }
源码分析部分先写到这里。我们掌握了,基于容量、时间的回收策略,不是实时执行的。回收清理通常是在写操作期间顺带进行的,或者可以通过调用 cleanUp() 方法来显式触发。读操作也可能偶尔触发清理,尤其是在写操作较少时。
6、刷新
了解了Guava Cache的使用和回收策略后,我们会发现这种用法还存在以下两个问题:
- 缓存击穿。数据大批量过期会导致对后端存储的高并发访问,加载数据过程中会锁住整个segment,很容易导致线程block。
- 数据不新鲜。缓存中的数据不是最新的,特别是对于那些定期变化的数据无法做到定期刷新。
Guava Cache 的刷新机制允许缓存项在满足特定条件时自动刷新。这意味着缓存项的值将被重新计算和替换,但这个过程是异步的,即刷新操作不会阻塞对缓存项的读取请求。
刷新机制主要通过 LoadingCache的refresh方法来实现,该方法会根据缓存的 CacheLoader重新加载缓存项的值。通过 CacheBuilder 的 refreshAfterWrite
方法设置自动刷新的触发条件,即在写入缓存项后的指定时间间隔。例如:
LoadingCache<KeyType, ValueType> cache = CacheBuilder.newBuilder() // 在写入后的10分钟后自动刷新 .refreshAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<KeyType, ValueType>() { @Override public ValueType load(KeyType key) { // 缓存项不存在时加载数据的方法 return loadData(key); } @Override public ListenableFuture<ValueType> reload(KeyType key, ValueType oldValue) throws Exception { // 异步刷新缓存项的方法 // 使用ListenableFuture来异步执行刷新操作 return listeningExecutorService.submit(() -> loadData(key)); } });
在上述代码中,refreshAfterWrite 设置了自动刷新的条件,而 CacheLoader 的 reload
方法定义了如何异步刷新缓存项。当缓存项在指定的时间间隔后被访问时,Guava Cache 会调用 reload 方法来异步加载新值。在新值加载期间,旧值仍然会返回给任何请求它的调用者。
需要注意的是,reload 方法应该返回一个 ListenableFuture 对象,这样刷新操作就可以异步执行,而不会阻塞其他缓存或线程操作。如果 reload 方法没有被重写,Guava Cache 将使用 load 方法进行同步刷新。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。