Guava-Cache
摘要
本文讲解Google Guava Cache基本用法和写入、过期原理。
1 简介
Guava Cache十分流行,在Apache Calcite等重量级项目中都有使用,比如org.apache.calcite.avatica.jdbc.JdbcMeta
:
private final Cache<String, Connection> connectionCache;
this.connectionCache = CacheBuilder.newBuilder()
.concurrencyLevel(concurrencyLevel)
.initialCapacity(initialCapacity)
.maximumSize(maxCapacity)
.expireAfterAccess(connectionExpiryDuration, connectionExpiryUnit)
.removalListener(new ConnectionExpiryHandler())
.build();
Guava Cache可选的优秀特点如下:
- 线程安全
- 当最大阈值超过时,自动删除最近最少使用的Entry
- 基于时间的过期策略(最后访问/写入)
- 清除Entry时触发通知方法
- 累积缓存访问统计信息
- 可设置key不存在时load默认值等。
2 用法
import com.google.common.cache.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class CacheTest1 {
private static final Logger logger = LoggerFactory.getLogger(CacheTest1.class);
/**
* 元素参数说明
* @throws InterruptedException
*/
public static void method1() throws InterruptedException{
Cache kk = CacheBuilder.newBuilder()
// 1分钟不使用就移除
.expireAfterAccess(1, TimeUnit.SECONDS)
// 并行指hashtable
.concurrencyLevel(8)
// 这里就会创建8个segment每个含有一个hashTable(大小是8)
// 该值设定可以有效避免后续resizing,但也不要设过大造成内存浪费
.initialCapacity(64)
// 设置存放的最大entry数.在达到阈值前cache可能会清除最近很少使用的entry
// 如果设为0,放进cache会被立刻清理
// 该值不能和maximumWeight同时使用
.maximumSize(1024)
// 每当entry被移除时触发该方法
// 注意是移除,不是过期!!
.removalListener((RemovalListener<String, String>) rn -> {
try {
logger.info("发生移除 {}", rn.getKey());
rn.getValue();
}
catch (Exception e) {
e.printStackTrace();
}
})
.build();
kk.put("helloKey","helloValue");
System.out.println("kk=" + kk.getIfPresent("helloKey"));
Thread.sleep(2000);
System.out.println("kk=" + kk.size());
System.out.println("kk=" + kk.getIfPresent("helloKey"));
}
/**
* 测试元素remove
* @throws InterruptedException
*/
public static void method2() throws InterruptedException{
Cache kk = CacheBuilder.newBuilder()
.expireAfterAccess(1, TimeUnit.SECONDS)
.concurrencyLevel(1)
.initialCapacity(1)
.maximumSize(1)
// 每当entry被移除时触发该方法
.removalListener((RemovalListener<String, String>) rn -> {
try {
logger.info("发生移除 {}", rn.getKey());
rn.getValue();
}
catch (Exception e) {
e.printStackTrace();
}
})
.build();
kk.put("helloKey1","helloValue");
kk.put("helloKey2","helloValue");
kk.put("helloKey3","helloValue");
kk.put("helloKey4","helloValue");
kk.put("helloKey5","helloValue");
System.out.println("kk=" + kk.getIfPresent("helloKey1"));
Thread.sleep(2000);
System.out.println("kk=" + kk.size());
System.out.println("kk=" + kk.getIfPresent("helloKey2"));
System.out.println("kk=" + kk.getIfPresent("helloKey3"));
System.out.println("kk=" + kk.getIfPresent("helloKey4"));
System.out.println("kk=" + kk.getIfPresent("helloKey5"));
}
/**
* LoadingCache和Cache不同,
* 使用它,CacheLoader才会有效,调用其load方法
* @throws InterruptedException
* @throws ExecutionException
*/
public static void method3() throws InterruptedException, ExecutionException {
// 注意这里要用LoadingCache
LoadingCache lc3 = CacheBuilder.newBuilder()
//1分钟不使用就移除
.expireAfterAccess(1, TimeUnit.SECONDS)
.removalListener((RemovalListener<String, String>) rn -> {
try {
logger.info("关闭 PalDb reader {}", rn.getKey());
rn.getValue();
}
catch (Exception e) {
e.printStackTrace();
}
})
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
return "loadValue";
}
});
// get,key不存在时,会调用load方法
System.out.println("helloKey1 get=" + lc3.get("helloKey1"));
System.out.println("helloKey1 get=" + lc3.get("helloKey1"));
// getIfPresent,key不存在时,不会调用load方法
System.out.println("helloKey2 getIfPresent=" + lc3.getIfPresent("helloKey2"));
System.out.println("helloKey3 get=" + lc3.get("helloKey3"));
lc3.put("helloKey3","helloValue3");
System.out.println("helloKey3 get=" + lc3.get("helloKey3"));
Thread.sleep(2000);
// size返回的只是近似值
System.out.println("lc3.size =" + lc3.size());
// 数据过期后,getIfPresent拿不到值了,也不会调用load方法
System.out.println("helloKey3 getIfPresent=" + lc3.getIfPresent("helloKey3"));
// 数据过期后,get拿不到值了,会调用load方法
System.out.println("helloKey3 get=" + lc3.get("helloKey3"));
}
public static void main(String[] args) throws Exception {
method3();
}
}
3 源码
3.1 初始化
这里分析时,构建的Cache:
Cache kk = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(5)
// 每当entry被移除时触发该方法
.removalListener((RemovalListener<String, String>) rn -> {
try {
logger.info("发生移除 {}", rn.getKey());
rn.getValue();
} catch (Exception e) {
e.printStackTrace();
}
})
.build();
初始化LocalCache
,进行初始化工作,部分重要内容如下:
- expireAfterAccessNanos、expireAfterWriteNanos和refreshNanos
- removalListener和removalNotificationQueue(是ConcurrentLinkedQueue<RemovalNotification<K, V>>())
在元素移除时触发通知,执行自定义行为 - 初始容量initialCapacity,这里是5
- 根据并行度等信息(CacheBuilder的
concurrencyLevel
等)创建Segment数组 - 创建所有Segment对象,为每个Segment创建:
- AtomicReferenceArray<ReferenceEntry<K, V>> table
这里的泛型就是LocalCache<K, V>
- recencyQueue
ConcurrentLinkedQueue<ReferenceEntry<K, V>>,记录该元素被读过,最近读过的放到队列末尾。 - writeQueue
WriteQueue<K, V>,以写入顺序记录所有缓存元素,最近写入的放到末尾。每个元素有前置和后置,且最后一个元素和head元素有前后置的索引连接方便查找和添加元素到末尾。 - accessQueue
AccessQueue<K, V>,以访问顺序记录所有缓存元素,最近访问的放到末尾。每个元素有前置和后置,且最后一个元素和head元素有前后置的索引连接方便查找和添加元素到末尾。
- AtomicReferenceArray<ReferenceEntry<K, V>> table
3.2 写入
-
写入时,通过算出key的hash,找到Segment数组(大小取决于CacheBuilder的
concurrencyLevel
等,默认值4)中对应Segment(return segments[(hash >>> segmentShift) & segmentMask];
),然后再putpublic V put(K key, V value) { checkNotNull(key); checkNotNull(value); int hash = hash(key); return segmentFor(hash).put(key, hash, value, false); }
-
put
时先尝试对该Segment加锁(Segment
继承自ReentrantLock,所以可以看出LocalCache的并行度是Segment级别)。 -
拿到锁后调用
expireEntries
,会- drainRecencyQueue
从队头开始往后排空recencyQueue
中所有ReferenceEntry
。且如果某个元素包含在accessQueue
内就会该元素重新放在accessQueue队列的末尾,以示该元素最近访问过。 - 清理
writeQueue
中过期ReferenceEntry - 清理
accessQueue
中过期ReferenceEntry
- drainRecencyQueue
-
按需扩容table
-
查找新元素应放入
AtomicReferenceArray<ReferenceEntry<K, V>> table
的位置int index = hash & (table.length() - 1); ReferenceEntry<K, V> first = table.get(index);
-
从找到的
ReferenceEntry<K, V> first
位置开始往后查找是否有 Key 相等的元素,这相当于是一个链表开始从头往尾方向查找。- 如果没找到直接
table.set(index, newEntry)
放入index位置 - 如果找到,就进行替换
- 替换时,如果定义了RemovalListener还会先放入一个queue进行通知
- 如果没找到直接
-
放置key value流程
-
先创建newEntry
注意,这里将原来该table.index位置的链表的first元素作为了当前新元素.nextReferenceEntry<K, V> newEntry = newEntry(key, hash, first)
-
将值包装为
ValueReference
,然后放入ReferenceEntry
-
再次
drainRecencyQueue
-
然后记录下当前使用该Entry时间
if (map.recordsAccess()) { entry.setAccessTime(now); } if (map.recordsWrite()) { entry.setWriteTime(now); }
-
随后放入两个队列
accessQueue
和writeQueue
,以便将来驱逐accessQueue.add(entry); writeQueue.add(entry);
-
最后将newEntry放入到该Segment的table中
注意,这里相当于是头插法,将当前元素作为了该table.index位置的新的first元素!
table.set(index, newEntry)
-
-
判断是否当前LocalCache元素是否超限,并进行相关清理工作
- drainRecencyQueue
- 判断
totalWeight
(元素写入一个增加一个,移除(比如由于过期、替换等)时就减少一个) 是否大于maxSegmentWeight
(maxSegmentWeight = maxWeight / segmentCount + 1,maxWeight = maximumSize = 5,还要处理个余数,这里值为5)来判断是否需要清理元素while (totalWeight > maxSegmentWeight) { // 从accessQueue里,从头往后取首个weight>0的元素(其实就是取最久没有访问的元素) ReferenceEntry<K, V> e = getNextEvictable(); // 1. writeQueue.remove(entry) // 2. accessQueue.remove(entry) if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) { throw new AssertionError(); } }
-
processPendingNotifications
void processPendingNotifications() { RemovalNotification<K, V> notification; while ((notification = removalNotificationQueue.poll()) != null) { try { removalListener.onRemoval(notification); } catch (Throwable e) { logger.log(Level.WARNING, "Exception thrown by removal listener", e); } } }
3.3 读取
System.out.println(kk.getIfPresent("helloKey1"));
-
通过算出key的hash,找到Segment数组(大小取决于CacheBuilder的
concurrencyLevel
等,默认值4)中对应Segment(return segments[(hash >>> segmentShift) & segmentMask];
) -
通过 目标 Segment.get(key, hash)
-
getLiveEntry(key, hash, now) 查找到对应元素
找到后还需要判断是否过期这里就根据用户配置的
expireAfterWrite
或expireAfterAccess
来决定判定过期规则boolean isExpired(ReferenceEntry<K, V> entry, long now) { checkNotNull(entry); if (expiresAfterAccess() && (now - entry.getAccessTime() >= expireAfterAccessNanos)) { return true; } if (expiresAfterWrite() && (now - entry.getWriteTime() >= expireAfterWriteNanos)) { return true; } return false; }
如果该元素过期,就尝试获取锁并执行
expireEntries(now)
进行过期元素处理 -
如果找到了该元素:
- 若开启了
expireAfterWrite
则会entry.setAccessTime(now)
- 将该Entry放入
recencyQueue
队列尾部 - 如果开启了
refreshAfterWrite
就判断是否需要refresh该key的值,需要就更新后返回新值;否则返回旧值
- 若开启了
-
postReadCleanup
通常是在写入时清理过期Entry,但如果在大量读取后仍未观察到清理工作发生,则读取线程尝试清理。清理主要是指:- drainRecencyQueue
排空recencyQueue,已访问的就移动到accessQueue尾部 - 清理writeQueue和accessQueue过期数据
- readCount.set(0)
- processPendingNotifications
将RemovalNotification
通知removalListener
- drainRecencyQueue
-
返回读到的Value
3.4 过期
主要是在数据写入时进行过期行为。
写入时加入writeQueue
和accessQueue
,清理时也会判断writeQueue/accessQueue中元素是否过期,过期就remove。
读数据时,如果读到会将该entry放入recencyQueue
队列尾部。
在drainRecencyQueue
时,会排空recencyQueue,其中已访问的就移动到accessQueue尾部,表示最近访问过,这符合LRU。
3.5 LRU
在写入元素的倒数第二步判断是否当前LocalCache元素是否超限,并进行相关清理工作
,主要就是用了 accessQueue ,从头往尾遍历移除,直到不超限位置。
4 对比其他
4.1 Caffeine
-
Caffeine概念、使用和原理
从而避免了LFU新缓存很快被淘汰、LRU对周期性访问场景支持较弱和LRU对突发流量导致访问一次的大量key把老的key全部挤出缓存的缺点 -
是什么让spring 5放弃了使用Guava Cache?
性能评测综合来说Caffeine的性能都比Guava要好
4.2 EhCache
更多好文
- 各种缓存淘汰算法 FIFO LRU LFU
还有Java Guava Caffeine的实现