废掉GuavaCache,Spring官方推荐Caffeine系列一

1,418 阅读11分钟

最近来了一个实习生小张,看了我在公司项目中使用的缓存框架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提供了多种灵活的构造方法,从而可以创建多种特性的本地缓存。

  1. 自动把数据加载到本地缓存中,并且可以配置异步;
  2. 基于数量剔除策略;
  3. 基于失效时间剔除策略,这个时间是从最后一次操作算起【访问或者写入】;
  4. 异步刷新;
  5. Key会被包装成Weak引用;
  6. Value会被包装成Weak或者Soft引用,从而能被GC掉,而不至于内存泄漏;
  7. 数据剔除提醒;
  8. 写入广播机制;
  9. 缓存访问可以统计;

实习生小张:我擦,这么强大,为什么可以这么强大呢,稀饭你不是自称最熟悉Caffeine的人吗?能否给我大概所说内部结构呢?

我日,我没有,我只是说在我们项目组我最熟悉,别污蔑我img

那接下来我大概介绍下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(123));
        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();

第二篇将分享Caffeine的淘汰机制在项目中的应用,对Caffeine有兴趣或者想咨询Caffeine相关问题,请关注我,微信搜:稀饭下雪