最近来了一个实习生小张,看了我在公司项目中使用的缓存框架Caffeine,三天两头跑来找我取经,说是要把Caffeine吃透,为此无奈的也只能一个个细心解答了。
后来这件事情被总监知道了,说是后面还有新人,让我将相关问题和细节汇总成一份教程,权当共享好了,该份教程也算是全网第一份,结合了目前我司游戏中业务场景的应用和思考,以及踩过的坑。
实习生小张:稀饭稀饭,以前我们游戏中应用的缓存其实是谷歌提供的ConcurrentLinkedHashMap,为什么后面你强烈要求换成用Caffeine呢?
关于上面的问题,具体有以下几个原因:
- 使用谷歌提供的ConcurrentLinkedHashMap有个漏洞,那就是缓存的过期只会发生在缓存达到上限的情况,否则便只会一直放在缓存中。咋一看,这个机制没问题,是没问题,可是却不合理,举个例子,有玩家上线后加载了一堆的数据放在缓存中,之后便不再上线了,那么这份缓存便会一直存在,知道缓存达到上限。
- ConcurrentLinkedHashMap没有提供基于时间淘汰时间的机制,而Caffeine有,并且有多种淘汰机制,并且支持淘汰通知。
- 目前Spring也在推荐使用,Caffeine 因使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率。
实习生小张:哦哦哦,我了解了,是否可以跟我介绍下Caffeine呢?
可以的,Caffeine是基于Java8的高性能缓存库,可提供接近最佳的命中率。Caffeine的底层使用了ConcurrentHashMap,支持按照一定的规则或者自定义的规则使缓存的数据过期,然后销毁。
再说一个劲爆的消息,很多人都听说过Google的GuavaCache,而没有听说过Caffeine,其实和Caffeine相比,GuavaCache简直就是个弟中弟,这不SpringFramework5.0(SpringBoot2.0)已经放弃了Google的GuavaCache,转而选择了Caffeine。
caffeine对比caffeine对比
为什么敢这么夸Caffeine呢?我们可以用官方给出的数据说话。
Caffeine提供了多种灵活的构造方法,从而可以创建多种特性的本地缓存。
- 自动把数据加载到本地缓存中,并且可以配置异步;
- 基于数量剔除策略;
- 基于失效时间剔除策略,这个时间是从最后一次操作算起【访问或者写入】;
- 异步刷新;
- Key会被包装成Weak引用;
- Value会被包装成Weak或者Soft引用,从而能被GC掉,而不至于内存泄漏;
- 数据剔除提醒;
- 写入广播机制;
- 缓存访问可以统计;
实习生小张:我擦,这么强大,为什么可以这么强大呢,稀饭你不是自称最熟悉Caffeine的人吗?能否给我大概所说内部结构呢?
我日,我没有,我只是说在我们项目组我最熟悉,别污蔑我
那接下来我大概介绍下Caffeine的内部结构
-
Cache的内部包含着一个ConcurrentHashMap,这也是存放我们所有缓存数据的地方,众所周知,ConcurrentHashMap是一个并发安全的容器,这点很重要,可以说Caffeine其实就是一个被强化过的ConcurrentHashMap。
-
Scheduler,定期清空数据的一个机制,可以不设置,如果不设置则不会主动的清空过期数据。
-
Executor,指定运行异步任务时要使用的线程池。可以不设置,如果不设置则会使用默认的线程池,也就是ForkJoinPool.commonPool()
实习生小张:听起来就是一个强化版的ConcurrentHashMap,那么需要导入什么包吗?
Caffeine的依赖,其实还是很简单的,直接引入maven依赖即可。
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
实习生小张:可以,导入成功了,你一直和我说Caffeine的数据填充机制设计的很优美,不就是put数据吗?有什么优美的?说说看吗?
是put数据,只是针对put数据,Caffeine提供了三种机制,分别是
- 手动加载
- 同步加载
- 异步加载
我分别举个例子,比如手动加载
/**
* @author xifanxiaxue
* @date 2020/11/17 0:16
* @desc 手动填充数据
*/
public class CaffeineManualTest {
@Test
public void test() {
// 初始化缓存,设置了1分钟的写过期,100的缓存最大个数
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100)
.build();
int key1 = 1;
// 使用getIfPresent方法从缓存中获取值。如果缓存中不存指定的值,则方法将返回 null:
System.out.println(cache.getIfPresent(key1));
// 也可以使用 get 方法获取值,该方法将一个参数为 key 的 Function 作为参数传入。如果缓存中不存在该 key
// 则该函数将用于提供默认值,该值在计算后插入缓存中:
System.out.println(cache.get(key1, new Function<Integer, Integer>() {
@Override
public Integer apply(Integer integer) {
return 2;
}
}));
// 校验key1对应的value是否插入缓存中
System.out.println(cache.getIfPresent(key1));
// 手动put数据填充缓存中
int value1 = 2;
cache.put(key1, value1);
// 使用getIfPresent方法从缓存中获取值。如果缓存中不存指定的值,则方法将返回 null:
System.out.println(cache.getIfPresent(1));
// 移除数据,让数据失效
cache.invalidate(1);
System.out.println(cache.getIfPresent(1));
}
}
上面提到了两个get数据的方式,一个是getIfPercent,没数据会返回Null,而get数据的话则需要提供一个Function对象,当缓存中不存在查询的key则将该函数用于提供默认值,并且会插入缓存中。
实习生小张:如果同时有多个线程进行get,那么这个Function对象是否会被执行多次呢?
实际上不会的,可以从结构图看出,Caffeine内部最主要的数据结构就是一个ConcurrentHashMap,而get的过程最终执行的便是ConcurrentHashMap.compute,这里仅会被执行一次。
接下来说说同步加载数据
/**
* @author xifanxiaxue
* @date 2020/11/19 9:47
* @desc 同步加载数据
*/
public class CaffeineLoadingTest {
/**
* 模拟从数据库中读取key
*
* @param key
* @return
*/
private int getInDB(int key) {
return key + 1;
}
@Test
public void test() {
// 初始化缓存,设置了1分钟的写过期,100的缓存最大个数
LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100)
.build(new CacheLoader<Integer, Integer>() {
@Nullable
@Override
public Integer load(@NonNull Integer key) {
return getInDB(key);
}
});
int key1 = 1;
// get数据,取不到则从数据库中读取相关数据,该值也会插入缓存中:
Integer value1 = cache.get(key1);
System.out.println(value1);
// 支持直接get一组值,支持批量查找
Map<Integer, Integer> dataMap
= cache.getAll(Arrays.asList(1, 2, 3));
System.out.println(dataMap);
}
}
所谓的同步加载数据指的是,在get不到数据时最终会调用build构造时提供的CacheLoader对象中的load函数,如果返回值则将其插入缓存中,并且返回,这是一种同步的操作,也支持批量查找。
「实际应用:在我司项目中,会利用这个同步机制,也就是在CacheLoader对象中的load函数中,当从Caffeine缓存中取不到数据的时候则从数据库中读取数据,通过这个机制和数据库结合使用」
最后一种便是异步加载
/**
* @author xifanxiaxue
* @date 2020/11/19 22:34
* @desc 异步加载
*/
public class CaffeineAsynchronousTest {
/**
* 模拟从数据库中读取key
*
* @param key
* @return
*/
private int getInDB(int key) {
return key + 1;
}
@Test
public void test() throws ExecutionException, InterruptedException {
// 使用executor设置线程池
AsyncCache<Integer, Integer> asyncCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100).executor(Executors.newSingleThreadExecutor()).buildAsync();
Integer key = 1;
// get返回的是CompletableFuture
CompletableFuture<Integer> future = asyncCache.get(key, new Function<Integer, Integer>() {
@Override
public Integer apply(Integer key) {
// 执行所在的线程不在是main,而是ForkJoinPool线程池提供的线程
System.out.println("当前所在线程:" + Thread.currentThread().getName());
int value = getInDB(key);
return value;
}
});
int value = future.get();
System.out.println("当前所在线程:" + Thread.currentThread().getName());
System.out.println(value);
}
}
执行结果如下
可以看到getInDB是在线程池ForkJoinPool提供的线程中执行的,而且asyncCache.get()返回的是一个CompletableFuture,熟悉流式编程的人对这个会比较熟悉,可以用CompletableFuture来实现异步串行的实现。
实习生小张:我看到默认是线程池ForkJoinPool提供的线程,实际上不大可能用默认的,所以我们可以自己指定吗?
答案是可以的,实例如下
// 使用executor设置线程池
AsyncCache<Integer, Integer> asyncCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100).executor(Executors.newSingleThreadExecutor()).buildAsync();
作者:饭谈编程
链接:https://juejin.cn/post/6909266995087147015
来源:掘金
网友评论