Guava Cache
缓存一般分为远程缓存和本地缓存。常见的远程缓存有 redis。与远程缓存相比本地缓存不需要进行网络的传输所以更加的快。本地缓存可以使用 map 实现,使用 get 查询缓存,put 设置缓存。本地缓存需要提供对应的缓存清理机制,否则会导致缓存溢出,并且需要保证操作的并发安全。
介绍
Guava Cache 是 Google Guava 库中的一个组件,用于在内存中实现本地缓存。它类似于 ConcurrentMap,但提供了更丰富的特性,如自动加载、过去策略、淘汰策略等。
Guava Cache 提供了丰富的构造参数:
参数类别 | 参数名 | 数据类型 | 作用 |
---|---|---|---|
容量控制 | initialCapacity | int | 初始化容量 |
maximumSize | long | 缓存最大条目数,超过时按LRU淘汰 | |
maximumWeight | long | 缓存最大权重和,需配合 weigher 使用,超过时淘汰条目 | |
weigher | weigher<K,V> | 定义计算缓存条目权重的函数,与maximumWeight 配合使用 | |
并发控制 | concurrencyLevel | int | 设置并发级别,并发级别是指同时可以写缓存的线程数 |
过期策略 | expireAfterWrite | long,TimeUnit | 条目在写入后一段时间过期 |
expireAfterAccess | long,TimeUnit | 条目在最后一次访问(读或写)后一段时间过期 | |
refreshAfterWrite | long,TimeUnit | 条目在写入后一段时间自动刷新(仅LoadingCache支持) | |
引用基淘汰 | weakKeys | 将键设置为弱引用,允许GC回收 | |
weakValues | 将值设置为弱引用,允许GC回收 | ||
softValues | 将值设置为软应用,在内容不足时GC优先回收 | ||
移除监听 | removalListener | 设置缓存条目被移除时的监听器 | |
统计 | recordStats | 开启缓存统计功能(如命中率) |
初始化
初始容量设置
我们在构建缓存时可以为缓存设置一个合理大小初始容量,由于Guava的缓存使用了分离锁的机制,扩容的代价非常昂贵。所以合理的初始容量能够减少缓存容器的扩容次数。
Cache<String, String> cache = CacheBuilder.newBuilder().initialCapacity(10) // 初始化容量.build();
最大容量
Guava Cache可以在构建缓存对象时指定缓存所能够存储的最大记录数量。当Cache中的记录数量达到最大值后再调用put方法向其中添加对象,Guava会先从当前缓存的对象记录中选择一条删除掉,腾出空间后再将新的对象存储到Cache中。
Cache<String, String> cache = CacheBuilder.newBuilder().maximumSize(100) // 设置缓存最大容量,超过根据LRU算法回收.build();
监听器
removalListener() 设置缓存条目被移除时的监听器
package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {Cache<String,String> cache = CacheBuilder.newBuilder().maximumSize(1).removalListener(removal -> {System.out.println(removal.getKey() + "被删除了");}).build();cache.put("key1", "value1");cache.put("key2", "value2");cache.put("key3", "value3");}
}
输出:
key1被删除了
key2被删除了
缓存清除策略
基于存活时间
expireAfterWrite 写入缓存后n秒过期
Cache<String, String> cache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS) // 设置写缓存后n秒过期.builder();
expireAfterAccess 读写缓存后n秒过期
Cache<String, String> cache = CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.SECONDS) // 设置读写缓存后n秒过期.builder();
基于最大容量容量
使用 CacheBuilder.maximumSize()方法时,当缓存数量达到该最大值时,将清除掉那些最近最少使用的缓存(LRU)
基于权重
在缓存大型文件或图片的场景下,每个缓存条目(文件内容)的大小差异很大,如果我们只限制缓存条目的数量(maximumSize)是不合理的。比如,缓存10个1MB的图片和缓存10个100MB的视频,占用的内存天差地别。
使用基于权重的回收(maximumWeight + weigher),我们可以限制缓存的总内存占用,而不是条目数量。
下面定义了一个最大权重为10的 cache,当所有条目的内存占用超过10时进行回收。
package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {// 1. 创建缓存,设置最大权重为10LoadingCache<String, String> cache = CacheBuilder.newBuilder().maximumWeight(10) // 总权重不能超过10.weigher((Weigher<String, String>) (key, value) -> value.length()) // 权重=值的长度.build(new CacheLoader<String, String>() {@Overridepublic String load(String key) {return key.toUpperCase(); // 简单的加载逻辑:转大写}});// 2. 使用缓存try {System.out.println("获取 A: " + cache.get("a")); // 权重=1 (A)System.out.println("获取 Hello: " + cache.get("hello")); // 权重=5 (HELLO)System.out.println("获取 Test: " + cache.get("test")); // 权重=4 (TEST)// 现在总权重=1+5+4=10,刚好满// 为了看到效果,我们手动检查哪些还在System.out.println("\n缓存中现有内容:");System.out.println("A: " + cache.getIfPresent("a")); // 可能被回收System.out.println("Hello: " + cache.getIfPresent("hello")); // 可能被回收System.out.println("Test: " + cache.getIfPresent("test")); // 可能还在System.out.println();// 3. 尝试再放入一个,会触发回收System.out.println("获取 World: " + cache.get("world")); // 权重=5 (WORLD)// 为了看到效果,我们手动检查哪些还在System.out.println("\n缓存中现有内容:");System.out.println("A: " + cache.getIfPresent("a")); // 可能被回收System.out.println("Hello: " + cache.getIfPresent("hello")); // 可能被回收System.out.println("Test: " + cache.getIfPresent("test")); // 可能还在System.out.println("World: " + cache.getIfPresent("world")); // 新加入的} catch (Exception e) {e.printStackTrace();}}
}
执行结果:
获取 A: A
获取 Hello: HELLO
获取 Test: TEST缓存中现有内容:
A: A
Hello: HELLO
Test: TEST获取 World: WORLD缓存中现有内容:
A: null
Hello: null
Test: TEST
World: WORLD
显示清除
清除单个key
cache.invalidate("key1");
清除所有key
cache.invalidateAll();
使用弱引用储存键。在下个GC时,若键在缓存外部没有其他强引用,整个条目被回收。
Cache<String,String> cache = CacheBuilder.newBuilder().weakKeys().build();
使用弱引用存储值。在下个GC时,若值在缓存外部没有其他强引用,值将会被回收,键可能仍存在
Cache<String,String> cache = CacheBuilder.newBuilder().weakValues().build();
使用软引用储存值。当一个对象只被软引用关联时,如果内存充足,它就不会被垃圾回收器回收,就像强引用一样。但是,当内存不足,垃圾收集器即将抛出OutOfMemoryError时,它会清理掉这些仅被软引用关联的对象,从而释放内存
Cache<String,String> cache = CacheBuilder.newBuilder().softValues().build();
写入
cache
显示put操作置入内存
package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {Cache<String,String> cache = CacheBuilder.newBuilder().initialCapacity(10).build();cache.put("key1","value1");cache.put("key2","value2");cache.put("key3","value3");}
}
LoadingCache
使用自定义 ClassLoader 加载数据,置入内存中。从 LoadingCache 中获取数据时,若数据存在则直接返回;若数据不存在,则根据 ClassLoader 的 load 方法加载数据至内存,然后返回该数据
package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {// 1. 创建缓存,设置最大权重为10LoadingCache<String, String> cache = CacheBuilder.newBuilder().maximumWeight(10) // 总权重不能超过10.weigher((Weigher<String, String>) (key, value) -> value.length()) // 权重=值的长度.build(new CacheLoader<String, String>() {@Overridepublic String load(String key) {return key.toUpperCase(); // 简单的加载逻辑:转大写}});// 2. 使用缓存try {System.out.println(cache.get("hello"));}catch (Exception e){return;}}
}
实现原理
数据结构
LocalMaunalCache
实现了 Cache 接口,是手动加载型缓存。需要显式调用 put() 方法向缓存中添加数据,获取数据时若不存在缓存命中,需手动处理(如返回 null 或自行加载数据)
LocalLoadingCache
实现了 LoadingCache 接口(LoadingCache 继承自 Cache),是自动加载型缓存。初始化时需传入 CacheLoader,当缓存未命中时,会自动调用 CacheLoader.load() 方法加载数据并放入缓存,无需手动 put()。
LocalMaunalCache 和 LocalLoadingCache 两者都继承自 AbstractLocalCache,共享底层的分段锁(Segment)、哈希表(table)、过期 / 淘汰策略等核心机制
LocalCache
LocalCache是 Guava Cache的核心类,本质上是一个Map
class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V> {// 缓存的最大容量,必须是2的幂次方且不超过1<<30static final int MAXIMUM_CAPACITY = 1 << 30;// 允许的最大段数,用于限制构造函数的参数static final int MAX_SEGMENTS = 1 << 16; // slightly conservative// 在containsValue方法中未同步的重试次数static final int CONTAINS_VALUE_RETRIES = 3;// 每个段的访问次数阈值static final int DRAIN_THRESHOLD = 0x3F;// 一次清理中可以清理的最大条目数static final int DRAIN_MAX = 16;static final Logger logger = Logger.getLogger(LocalCache.class.getName());// 用于索引到段中的掩码值,键的哈希码的高位用于选择段final int segmentMask;// 用于在段内进行索引的偏移值,有助于防止最终位于同一段中的条目也最终位于同一个桶中。final int segmentShift;// 段组数,每个段都是一个专门的哈希表final Segment<K, V>[] segments;// 并发级别final int concurrencyLevel;// 键的比较策略final Equivalence<Object> keyEquivalence;// 值的比较策略final Equivalence<Object> valueEquivalence;// 引用键的策略final Strength keyStrength;// 引用值的策略final Strength valueStrength;// 最大重量final long maxWeight;// 键值对的重量final Weigher<K, V> weigher;// 键值对在最后一次访问后保留的时间final long expireAfterAccessNanos;// 键值对在最后一次写入后保留的时间final long expireAfterWriteNanos;// 键值对在最后一次写入后成为刷新候选的时间final long refreshNanos;// 等待被移除监听器消费的键值对队列final Queue<RemovalNotification<K, V>> removalNotificationQueue;// 键值对移除监听器final RemovalListener<K, V> removalListener;// 以可测试的方式测量时间的工具final Ticker ticker;// 创建新键值对的工厂final EntryFactory entryFactory;// 累积全局缓存统计信息的计数器final StatsCounter globalStatsCounter;// 在加载操作中使用的默认缓存加载器final @Nullable CacheLoader<? super K, V> defaultLoader;}
Segment
LocalCache里维护了一个Segment数组
static class Segment<K, V> extends ReentrantLock {// 指向外部 LocalCache 实例的弱引用,用于访问缓存@Weak final LocalCache<K, V> map;// 记录当前段Segment中活跃元素的数量volatile int count;// 当前段中所有活跃元素的总权重,受this锁保护,确保线程安全@GuardedBy("this")long totalWeight;// 记录对段结构进行修改的次数,用于在批量读取操作中检测一致性,若在遍历过程中modCount发生变化,需要重试int modCount;// 扩容阈值,元素数量超过此值时触发扩容,计算公式为 capacity * 0.75 int threshold;// 哈希表,存储具体数据volatile @Nullable AtomicReferenceArray<ReferenceEntry<K, V>> table;// 最大权重。默认为无限制final long maxSegmentWeight;// 键的引用队列。用于清理被回收的键final @Nullable ReferenceQueue<K> keyReferenceQueue;// 值的引用队列。用于清理被回收的值final @Nullable ReferenceQueue<V> valueReferenceQueue;// 用于记录哪些条目被访问过,以更新访问列表的顺序final Queue<ReferenceEntry<K, V>> recencyQueue;// 记录自上次写入以来的读操作次数,用于在少量读操作上排空队列final AtomicInteger readCount = new AtomicInteger();// 按写入时间排序的元素队列,用于管理键值对的顺序@GuardedBy("this")final Queue<ReferenceEntry<K, V>> writeQueue;// 按访问时间排序的元素队列,用于管理键值对的顺序@GuardedBy("this")final Queue<ReferenceEntry<K, V>> accessQueue;// 用于累积缓存统计信息final StatsCounter statsCounter;
}
源码分析
put
调用 put 方法写入key value的链路如下:
-
对 key 进行哈希运算,定位分段 segment
-
段内加锁,保证线程安全
-
更新当前活动元素数量,如果超过阈值则进行两倍扩容
-
计算获取对应位置的 table,遍历,判断 key 是否已经存在
-
如果 key 已存在
- 如果 value为空且 value的引用仍为活跃状态,则将原键值对的移除事件写入通知队列,并更新数据
- 如果 value不为空且仅当key不存在时更新,则返回旧值
- 如果vlaue不为空,则将原键值对的移除事件写入通知队列,并更新数据
-
如果 key 不存在,插入数据
-
-
如果设置了 maxSegmentWeight 且当前超重,则执行清理逻辑
- 如果新插入的键值对的重量超过 maxSegmentWeight,则删除当前键值对
- 从 accessQueue 中依次删除队头元素(最久未访问),直到当前段的 totalWeight <= maxSegmentWeight
@NullableV put(K key, int hash, V value, boolean onlyIfAbsent) {// 加锁,保证线程安全lock();try {long now = map.ticker.read();preWriteCleanup(now);// 更新当前活动元素数量int newCount = this.count + 1;// 如果当前活动元素数量大于阈值,触发扩容if (newCount > this.threshold) { // ensure capacityexpand();newCount = this.count + 1;}// 计算插入的表,并获取该表的首个元素 firstAtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;int index = hash & (table.length() - 1);ReferenceEntry<K, V> first = table.get(index);// 遍历table,查找是否已经存在相同的键for (ReferenceEntry<K, V> e = first; e != null; e = e.getNext()) {K entryKey = e.getKey();if (e.getHash() == hash&& entryKey != null&& map.keyEquivalence.equivalent(key, entryKey)) {// 如果 key 已存在,获取 valueValueReference<K, V> valueReference = e.getValueReference();V entryValue = valueReference.get();if (entryValue == null) {// 更新修改次数++modCount;if (valueReference.isActive()) {// 如果值引用活跃的,将原数据移除事件封装成通知并放入通知队列enqueueNotification(key, hash, entryValue, valueReference.getWeight(), RemovalCause.COLLECTED);// 更新值setValue(e, key, value, now);newCount = this.count; // count remains unchanged} else {// 如果值引用不活跃,直接更新数据setValue(e, key, value, now);newCount = this.count + 1;}this.count = newCount; // 如果设置了 maxSegmentWeight 且当前超重,则执行清理逻辑:// 1. 如果新插入的键值对的重量超过 maxSegmentWeight,则删除当前键值对// 2. 从 accessQueue 中依次删除队头元素(最久未访问),直到当前段的 totalWeight <= maxSegmentWeightevictEntries(e);return null;} else if (onlyIfAbsent) {// 如果 key 不为空仅当 key 不存在时更新,则返回旧值recordLockedRead(e, now);return entryValue;} else {// 如果key不为空,且onlyIfAbsent为fasle,覆盖现有键值对,计数保持不变++modCount;// 将原数据移除事件封装成通知并放入通知队列enqueueNotification(key, hash, entryValue, valueReference.getWeight(), RemovalCause.REPLACED);// 更新值setValue(e, key, value, now);evictEntries(e);return entryValue;}}}// 如果key不存在,插入新的键值对++modCount;ReferenceEntry<K, V> newEntry = newEntry(key, hash, first);setValue(newEntry, key, value, now);table.set(index, newEntry);newCount = this.count + 1;this.count = newCount; // write-volatileevictEntries(newEntry);return null;} finally {unlock();postWriteCleanup();}}
get
调用get方法的执行链路如下:
- 对 key 进行哈希运算,定位分段 segment
- 如果当前key不存在,则加载并返回 loader 方法的返回值
- 如果当前key存在,检查该键值对是否有效
- 如果值有效,更新该键值对的最后读取时间,增加命中统计,并尝试刷新该值
- 如果值无效,但条目的应用正在加载,则等待加载完成
LocalMaunalCache:
public V get(K key, final Callable<? extends V> valueLoader) throws ExecutionException {checkNotNull(valueLoader);// 调用 LocalCache的get方法return localCache.get(key,new CacheLoader<Object, V>() {@Overridepublic V load(Object key) throws Exception {return valueLoader.call();}});}
LocalCache:
V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {int hash = hash(checkNotNull(key));// 通过hash定位到segment,再调用segment的get方法return segmentFor(hash).get(key, hash, loader);}
Segment:
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {checkNotNull(key);checkNotNull(loader);try {// 如果 count == 0 不会存在数据if (count != 0) { // 定位键值对,并判断该键值对是否有效ReferenceEntry<K, V> e = getEntry(key, hash);if (e != null) {long now = map.ticker.read();// 获取还没过去的value,如果过期了,则返回nullV value = getLiveValue(e, now);if (value != null) {// 如果缓存过期策略为expireAfterAccess,刷新键值对的访问时间// 将该键值对添加到 recencyQueuerecordRead(e, now);// 增加命中统计statsCounter.recordHits(1);// 尝试刷新缓存return scheduleRefresh(e, key, hash, value, now, loader);}// 如果值无效但键值对的应用正在加载中,则等待加载完成ValueReference<K, V> valueReference = e.getValueReference();if (valueReference.isLoading()) {return waitForLoadingValue(e, key, valueReference);}}}// 之前没有写入过数据 || 数据已经过期 || 数据不是在加载中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();}}// 如果键值对无效返回null,有效返回value V getLiveValue(ReferenceEntry<K, V> entry, long now) {// 被GC回收if (entry.getKey() == null) {tryDrainReferenceQueues();return null;}V value = entry.getValueReference().get();// 被GC回收if (value == null) {tryDrainReferenceQueues();return null;}// 判断是否过期if (map.isExpired(entry, now)) {tryExpireEntries(now);return null;}return value;}
scheduleRefresh
如果 entry 还没过期,则会调用此方法,尝试刷新数据V scheduleRefresh(ReferenceEntry<K, V> entry,K key,int hash,V oldValue,long now,CacheLoader<? super K, V> loader) {// 1. 配置了refreshAfterWrite// 2. 用writeTime判断是否达到刷新时间// 3. 是否在加载中,如果是则没必要再进行刷新if (map.refreshes()&& (now - entry.getWriteTime() > map.refreshNanos)&& !entry.getValueReference().isLoading()) {// 异步刷新数据V newValue = refresh(key, hash, loader, true);// 返回新值if (newValue != null) {return newValue;}}return oldValue;}// refresh
V refresh(K key, int hash, CacheLoader<? super K, V> loader, boolean checkTime) {// 为key插入一个LoadingValueReferencefinal LoadingValueReference<K, V> loadingValueReference =insertLoadingValueReference(key, hash, checkTime);if (loadingValueReference == null) {return null;}// 通过loader异步加载数据ListenableFuture<V> result = loadAsync(key, hash, loadingValueReference, loader);// 判断Futer是否已经完成,如果是则返回结果。否则返回nullif (result.isDone()) {try {return Uninterruptibles.getUninterruptibly(result);} catch (Throwable t) {// don't let refresh exceptions propagate; error was already logged}}return null;}LoadingValueReference<K, V> insertLoadingValueReference(final K key, final int hash, boolean checkTime) {ReferenceEntry<K, V> e = null;// segment加锁lock();try {long now = map.ticker.read();// 预清理preWriteCleanup(now);AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;int index = hash & (table.length() - 1);ReferenceEntry<K, V> first = table.get(index);// 如果key对应的entry存在.for (e = first; e != null; e = e.getNext()) {K entryKey = e.getKey();if (e.getHash() == hash&& entryKey != null&& map.keyEquivalence.equivalent(key, entryKey)) {// We found an existing entry.ValueReference<K, V> valueReference = e.getValueReference();if (valueReference.isLoading()|| (checkTime && (now - e.getWriteTime() < map.refreshNanos))) {// 如果是在加载中,或者还没达到刷新时间,则返回nullreturn null;}// new一个LoadingValueReference,然后把entry的valueReference替换掉++modCount;LoadingValueReference<K, V> loadingValueReference =new LoadingValueReference<>(valueReference);e.setValueReference(loadingValueReference);return loadingValueReference;}}// 如果key对应的entry不存在,则新建一个entry++modCount;LoadingValueReference<K, V> loadingValueReference = new LoadingValueReference<>();e = newEntry(key, hash, first);e.setValueReference(loadingValueReference);table.set(index, e);return loadingValueReference;} finally {unlock();postWriteCleanup();}}
lockedGetOrLoad
如果之前没有写如果数据 || 数据已过期 || 数据不是在加载中,则会调用lockedGetOrLoad
V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {ReferenceEntry<K, V> e;ValueReference<K, V> valueReference = null;LoadingValueReference<K, V> loadingValueReference = null;// 是否需要创建一个新的entryboolean createNewEntry = true;// segment加锁lock();try {// re-read ticker once inside the locklong now = map.ticker.read();// 预清理preWriteCleanup(now);int newCount = this.count - 1;AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;int index = hash & (table.length() - 1);// 定位到具体的tableReferenceEntry<K, V> first = table.get(index);for (e = first; e != null; e = e.getNext()) {K entryKey = e.getKey();// 定位keyif (e.getHash() == hash&& entryKey != null&& map.keyEquivalence.equivalent(key, entryKey)) {valueReference = e.getValueReference();// 如果value在加载中则不需要重复创建entryif (valueReference.isLoading()) {createNewEntry = false;} else {V value = valueReference.get();// value为null说明已经过期且被清理掉了if (value == null) {// 写通知queueenqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.COLLECTED);// 过期但还没被清理} else if (map.isExpired(e, now)) {// 写通知queue// This is a duplicate check, as preWriteCleanup already purged expired// entries, but let's accommodate an incorrect expiration queue.enqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.EXPIRED);} else {recordLockedRead(e, now);statsCounter.recordHits(1);// 其他情况直接返回value// we were concurrent with loading; don't consider refreshreturn value;}// immediately reuse invalid entrieswriteQueue.remove(e);accessQueue.remove(e);this.count = newCount; // write-volatile}break;}}// 创建一个 entry,切set一个新的LoadingValueReferenceif (createNewEntry) {loadingValueReference = new LoadingValueReference<>();if (e == null) {e = newEntry(key, hash, first);e.setValueReference(loadingValueReference);table.set(index, e);} else {e.setValueReference(loadingValueReference);}}} finally {unlock();postWriteCleanup();}// 同步加载数据if (createNewEntry) {try {// Synchronizes on the entry to allow failing fast when a recursive load is// detected. This may be circumvented when an entry is copied, but will fail fast most// of the time.synchronized (e) {return loadSync(key, hash, loadingValueReference, loader);}} finally {statsCounter.recordMisses(1);}} else {// The entry already exists. Wait for loading.return waitForLoadingValue(e, key, valueReference);}}
总结
Guava Cache 作为一个成熟的本地缓存实现,在很多场景下都能很好地满足需求。理解其内部实现(如分段锁机制、过期策略实现等)有助于我们更好地使用它,并在出现问题时能够快速定位和解决。