最近来了一个实习生小张,看了我在公司项目中使用的缓存框架Caffeine,三天两头跑来找我取经,说是要把Caffeine吃透,为此无奈的也只能一个个细心解答了。
后来这件事情被总监直到了,说是后面还有新人,让我将相关问题和细节汇总成一份教程,权当共享好了,该份教程也算是全网第一份,结合了目前我司游戏中业务场景的应用和思考,以及踩过的坑。
这是Caffeine教程的第二篇,主要讲解淘汰机制和实际项目中的应用,第一篇请看历史记录。
❝实习生小张:听说Caffeien最屌的地方就是它那健全的淘汰机制,可以说说看吗?
❞
可以的,实际上Caffeine比ConcurrentHashMap和相比,最明显的一点便是提供了一套完整的淘汰机制。
基于基本需求,Caffeine提供了三种淘汰机制:
基于大小
基于权重
基于时间
基于引用
基本上这三个对于我们来说已经是够用了,接下来我针对这几个都给出相关的例子。
首先是基于大小淘汰,设置方式:maximumSize(个数),这意味着当缓存大小超过配置的大小限制时会发生回收。
/**
* @author xifanxiaxue
* @date 2020/11/19 22:34
* @desc 基于大小淘汰
*/
public class CaffeineSizeEvictionTest {
@Test
public void test() throws InterruptedException {
// 初始化缓存,缓存最大个数为1
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.maximumSize(1)
.build();
cache.put(1, 1);
// 打印缓存个数,结果为1
System.out.println(cache.estimatedSize());
cache.put(2, 2);
// 稍微休眠一秒
Thread.sleep(1000);
// 打印缓存个数,结果为1
System.out.println(cache.estimatedSize());
}
}
我这边设置了最大缓存个数为1,当put进二个数据时则第一个就被淘汰了,此时缓存内个数只剩1个。
之所以在demo中需要休眠一秒,是因为淘汰数据是一个异步的过程,休眠一秒等异步的回收结束。
接下来说说基于权重淘汰的方式,设置方式:maximumWeight(个数),意味着当缓存大小超过配置的权重限制时会发生回收。
/**
* @author xifanxiaxue
* @date 2020/11/21 15:26
* @desc 基于缓存权重
*/
public class CaffeineWeightEvictionTest {
@Test
public void test() throws InterruptedException {
// 初始化缓存,设置最大权重为2
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.maximumWeight(2)
.weigher(new Weigher<Integer, Integer>() {
@Override
public @NonNegative int weigh(@NonNull Integer key, @NonNull Integer value) {
return key;
}
})
.build();
cache.put(1, 1);
// 打印缓存个数,结果为1
System.out.println(cache.estimatedSize());
cache.put(2, 2);
// 稍微休眠一秒
Thread.sleep(1000);
// 打印缓存个数,结果为1
System.out.println(cache.estimatedSize());
}
}
我这边设置了最大权重为2,权重的计算方式是直接用key,当put 1 进来时总权重为1,当put 2 进缓存是总权重为3,超过最大权重2,因此会触发淘汰机制,回收后个数只为1。
然后是基于时间的方式,基于时间的回收机制,Caffeine有提供了三种类型,可以分为:
访问后到期,时间节点从最近一次读或者写,也就是get或者put开始算起。
写入后到期,时间节点从写开始算起,也就是put。
自定义策略,自定义具体到期时间。
这三个我举个例子,看好相关的区别了哈。
/**
* @author xifanxiaxue
* @date 2020/11/24 23:41
* @desc 基于时间淘汰
*/
public class CaffeineTimeEvictionTest {
/**
* 访问后到期
*
* @throws InterruptedException
*/
@Test
public void testEvictionAfterProcess() throws InterruptedException {
// 设置访问5秒后数据到期
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.SECONDS).scheduler(Scheduler.systemScheduler())
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
Thread.sleep(6000);
System.out.println(cache.getIfPresent(1));
}
/**
* 写入后到期
*
* @throws InterruptedException
*/
@Test
public void testEvictionAfterWrite() throws InterruptedException {
// 设置写入5秒后数据到期
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS).scheduler(Scheduler.systemScheduler())
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
Thread.sleep(6000);
System.out.println(cache.getIfPresent(1));
}
/**
* 自定义过期时间
*
* @throws InterruptedException
*/
@Test
public void testEvictionAfter() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfter(new Expiry<Integer, Integer>() {
// 创建1秒后过期,可以看到这里必须要用纳秒
@Override
public long expireAfterCreate(@NonNull Integer key, @NonNull Integer value, long currentTime) {
return TimeUnit.SECONDS.toNanos(1);
}
// 更新2秒后过期,可以看到这里必须要用纳秒
@Override
public long expireAfterUpdate(@NonNull Integer key, @NonNull Integer value, long currentTime, @NonNegative long currentDuration) {
return TimeUnit.SECONDS.toNanos(2);
}
// 读3秒后过期,可以看到这里必须要用纳秒
@Override
public long expireAfterRead(@NonNull Integer key, @NonNull Integer value, long currentTime, @NonNegative long currentDuration) {
return TimeUnit.SECONDS.toNanos(3);
}
}).scheduler(Scheduler.systemScheduler())
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
Thread.sleep(6000);
System.out.println(cache.getIfPresent(1));
}
}
上面举了三个demo,已经是很详细了,这里需要额外提的一点是,我构建Cache对象的时候都会调用scheduler(Scheduler.systemScheduler()),Scheduler在上文描述Caffeine结构的时候有提到,Scheduler就是定期清空数据的一个机制,可以不设置,如果不设置则不会主动的清空过期数据。
❝实习生小张:diao大的稀饭,问题来了,如果不设置的时候数据过期了是什么时候清空的呢?
❞
为了找出这个问题的答案,我特地通读了Caffeine的源码,终于找到答案,那就是在我们操作数据的时候会进行异步清空过期数据,也就是put或者get的时候,关于源码部分,等后面讲解完具体用法了我再特地讲讲。
❝实习生小张:还有一个问题,为什么我在我的工程中用了scheduler(Scheduler.systemScheduler())没生效呢?
❞
这确实是一个很好的问题,如果没有像我这么仔细去看文档和跑demo的话根本不知道怎么解答这个问题,实际上是jdk版本的限制,只有java9以上才会生效。
「实际应用:目前在我司项目中,利用了基于缓存大小和访问后到期两种淘汰,目前从线上表现来说,效果是极其明显的,不过要注意一个点,那就是需要入库的缓存记得保存,否则容易产生数据丢失」。
最后一种淘汰机制是基于引用,很多人可能对引用没什么概念,在这里放过如意门:https://mp.weixin.qq.com/s/-NUuITZq0HjXOkMf6zl6WA ,不懂的先学学,再来看这个Caffeine吧。
/**
* @author xifanxiaxue
* @date 2020/11/25 0:43
* @desc 基于引用淘汰
*/
public class CaffeineReferenceEvictionTest {
@Test
public void testWeak() {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置Key为弱引用,生命周期是下次gc的时候
.weakKeys()
// 设置value为弱引用,生命周期是下次gc的时候
.weakValues()
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
// 强行调用一次GC
System.gc();
System.out.println(cache.getIfPresent(1));
}
@Test
public void testSoft() {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置value为软引用,生命周期是GC时并且堆内存不够时触发清除
.softValues()
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
// 强行调用一次GC
System.gc();
System.out.println(cache.getIfPresent(1));
}
}
这里要注意的地方有三个
System.gc() 不一定会真的触发GC,只是一种通知机制,但是并非一定会发生GC,垃圾收集器进不进行GC是不确定的,所以有概率看到设置weakKeys了却在调用System.gc() 的时候却没有丢失缓存数据的情况。
使用异步加载的方式不允许使用引用淘汰机制,启动程序的时候会报错:java.lang.IllegalStateException: Weak or soft values can not be combined with AsyncCache,猜测原因是异步加载数据的生命周期和引用淘汰机制的生命周期冲突导致的,因而Caffeine不支持。
使用引用淘汰机制的时候,判断两个key或者两个value是否相同,用的是 ==,而非是equals(),也就是说需要两个key指向同一个对象才能被认为是一致的,这样极可能导致缓存命中出现预料之外的问题。
因而,总结下来就是慎用基于引用的淘汰机制,其实其他的淘汰机制完全够用了。
❝实习生小张:我这边接到了一个需求,需要用到写后一段时间定时过期,可是如果在一定时间内,数据有访问则重新计时,应该怎么做呢?
❞
关于这种需求,其实并不常见,合理来说使用读写后过期真的够用,但是不排除有上面这种特别的情况。
因而这就要说到Caffeine提供的刷新机制了,使用很简单,用接口refreshAfterWrite 即可,可以说refreshAfterWrite其实就是和expireAfterWrite配套使用的,只不过使用refreshAfterWrite需要注意几个坑点,具体我举例说。
/**
* @author xifanxiaxue
* @date 2020/12/1 23:12
* @desc
*/
public class CaffeineRefreshTest {
private int index = 1;
/**
* 模拟从数据库中读取数据
*
* @return
*/
private int getInDB() {
// 这里为了体现数据重新被get,因而用了index++
index++;
return index;
}
@Test
public void test() throws InterruptedException {
// 设置写入后3秒后数据过期,2秒后如果有数据访问则刷新数据
LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
.refreshAfterWrite(2, TimeUnit.SECONDS)
.expireAfterWrite(3, TimeUnit.SECONDS)
.build(new CacheLoader<Integer, Integer>() {
@Nullable
@Override
public Integer load(@NonNull Integer key) {
return getInDB();
}
});
cache.put(1, getInDB());
// 休眠2.5秒,后取值
Thread.sleep(2500);
System.out.println(cache.getIfPresent(1));
// 休眠1.5秒,后取值
Thread.sleep(1500);
System.out.println(cache.getIfPresent(1));
}
}
可以看到我设置写入后3秒后数据过期,2秒后如果有数据访问则刷新数据,而在put数据后,我先是休眠了2.5秒,再打印取值,再休眠了1.5秒,再打印取值。
❝稀饭:小张,你猜猜看,最终打印是啥?
❞
❝实习生小张:应该是 3 4,因为设置的写后刷新时间是2秒,而第一次休眠已经过了2.5秒了,应该已经主动打印了。
❞
❝稀饭:其实不是,最终打印的结果是:2 3
❞
「坑点:我研究过源码,写后刷新其实并不是方法名描述的那样在一定时间后自动刷新,而是在一定时间后进行了访问,再访问后才自动刷新。也就是在第一次cache.get(1)的时候其实取到的依旧是旧值,在doAfterRead里边做了自动刷新的操作,这样在第二次cache.get(1)取到的才是刷洗后的值。」
❝稀饭:那小张,你说说看,第一次休眠已经过了2.5秒,第二次休眠过了1.5秒,总共时长是4秒,而写后过期时间其实才设置了3秒,为什么第二次取值依旧取得到没有过期呢?
❞
❝实习生小张:应该是这样的,在写后刷新后重新将值填充到了缓存中,因而触发了写后过期时间机制的重新计算,所以虽然看起来在第二次get数据的时候已经过了4秒,其实对于写后过期机制来说,其实才过了1.5秒。
❞
❝稀饭:正解。
❞
「坑点:在写后刷新被触发后,会重新填充数据,因而会触发写后过期时间机制的重新计算。」
第三篇将分享Caffeine与二级缓存的结合使用