caffeine与guava缓存框架(待分析底层实现)


caffeine与guava缓存框架

1,概述

??我们为了加快访问速度、提供性能,通常会使用到很多缓存机制,例如:mybatis一级缓存机制,MySQL自己的持久化缓存机制等等。

??我们通常使用的redis也可以看做一种缓存,它可以大大的提高我们的访问并发性,但是在分布式系统中由于网络不可靠问题,我们也不能全部依赖redis缓存,为了进一步加快速度,我们还可以使用本地缓存。例如之前google的guava缓存,以及今天要着重介绍的caffeine缓存。

2,各种缓存综述

2.1, FIFO

??Fist in first out 先进先出:最先进入的缓存被最先淘汰掉,这个基本不会有人用来做缓存

2.2,LRU

??Least recently used 最近最少未使用:每次访问就把这个元素放到队列头部,队列满了淘汰队列尾的元素,也就是淘汰最长时间没有被访问的。

??在HashMap的链式法增加新的引用形成一个双向链表,即是一个HashMap又是一个链表,这样输出即有序,也可以根据访问来动态调整顺序,HashMap+LinkedList(java容器中的LinkedHashMap就可以直接实现改功能)。

??达到FIFO或者LRU的特点,可以明显看出这个存在的问题,线程不安全,需要额外加锁,功能结构单一,没有过期时间容易存在内存泄露。

??缺点也是很明显的,某一时刻大量数据的到来容易把热点数据挤出缓存,而这些数据却是只访问了一次的,今后不会再访问了的或者访问频率极低的

2.3,LFU

??Least frequently used 最不经常使用:也就是淘汰一定时期内被访问次数最少的页,这个和LRU区别是这个讲究的是一定时期中的次数也就是频率最低的被淘汰。

??这个能避免LRU的缺点,因为是根据频率淘汰,不会出现大量进进来的挤压掉老的,如果在数据的访问的模式不随时间变化时候,LFU将会提供绝佳的命中率。但是如果访问模式随着时间而变化(即缓存元素随着时间增大访问次数越小),新进来的被快速淘汰,因为刚刚进来的频率最低,之前老缓存的频率太高。并且它需要额外空间维护频率这个属性,如果建立一个HashMap维护这个属性,当数据量大的情况下,那么这个HashMap也会十分大。

2.4,Guava

??Guava 是Google开源出来的Java常用工具集库,包括集合,缓存,并发,字符串,I/O操作等在Java开发过程中经常需要去实现的工具类。

??在平常开发过程中,很多情况需要使用缓存来避免频繁SQL查询或者其他耗时操作,会采取缓存这些操作结果给下一次请求使用。如果我们的操作结果是一直不改变的,其实我们可以使用 ConcurrentHashMap 来存储这些数据;但是如果这些结果在随后时间内会改变或者我们希望存放的数据所占用的内存空间可控,这样就需要自己来实现这种数据结构了。

2.5,Caffeine

性能优势:

image-20220311154614486

  • 使用谷歌提供的ConcurrentLinkedHashMap有个漏洞,那就是缓存的过期只会发生在缓存达到上限的情况,否则便只会一直放在缓存中。咋一看,这个机制没问题,是没问题,可是却不合理,举个例子,有玩家上线后加载了一堆的数据放在缓存中,之后便不再上线了,那么这份缓存便会一直存在,知道缓存达到上限。
  • ConcurrentLinkedHashMap没有提供基于时间淘汰时间的机制,而Caffeine有,并且有多种淘汰机制,并且支持淘汰通知。
  • 目前Spring也在推荐使用,Caffeine 因使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率。
  • caffeine在springboot2.0开始替代guava

image-20220311155218840

结构

image-20220311154327040

  • Cache的内部包含着一个ConcurrentHashMap,这也是存放我们所有缓存数据的地方,众所周知,ConcurrentHashMap是一个并发安全的容器,这点很重要,可以说Caffeine其实就是一个被强化过的ConcurrentHashMap。
  • Scheduler,定期清空数据的一个机制,可以不设置,如果不设置则不会主动的清空过期数据。
  • Executor,指定运行异步任务时要使用的线程池。可以不设置,如果不设置则会使用默认的线程池,也就是ForkJoinPool.commonPool()

Caffeine提供了多种灵活的构造方法,从而可以创建多种特性的本地缓存。

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

3种加载方式

  • 手动加载 cache.put(key1, value1);
  • 同步加载 cache.get(key1) --> load(key1)
  • 异步加载 cache.get(key1) --> CompletableFuture.supplyAsync(() -> {return oldValue;},executorService);

4种淘汰机制

  • 基于大小
    • 设置方式:maximumSize(个数),这意味着当缓存大小超过配置的大小限制时会发生回收
  • 基于权重
    • 设置方式:maximumWeight(个数),意味着当缓存大小超过配置的权重限制时会发生回收
    • 例如设置最大权重为2,权重的计算方式是直接用key,当put 1 进来时总权重为1,当put 2 进缓存是总权重为3,超过最大权重2,因此会触发淘汰机制,回收后个数只为1
  • 基于时间
    • 访问后到期,时间节点从最近一次读或者写,也就是get或者put开始算起。
    • 写入后到期,时间节点从写开始算起,也就是put。
    • 自定义策略,自定义具体到期时间。
  • 基于引用
    • .weakKeys() // 设置Key为弱引用,生命周期是下次gc的时候
    • .weakValues() // 设置value为弱引用,生命周期是下次gc的时候

目前数据被淘汰的原因不外有以下几个:

  • EXPLICIT:如果原因是这个,那么意味着数据被我们手动的remove掉了。
  • REPLACED:就是替换了,也就是put数据的时候旧的数据被覆盖导致的移除。
  • COLLECTED:这个有歧义点,其实就是收集,也就是垃圾回收导致的,一般是用弱引用或者软引用会导致这个情况。
  • EXPIRED:数据过期,无需解释的原因。
  • SIZE:个数超过限制导致的移除。

3,使用

3.1,样例

maven依赖:

        
        
            com.github.ben-manes.caffeine
            caffeine
            2.8.8
        

模拟memoryCache、redis二级缓存

package com.springcloud.test;

import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;

import java.util.HashMap;
import java.util.concurrent.*;

/**
 * @author zjk
 * @date 2022/3/11
 * @descript
 * @since V1.0.0
 */
public class CaffeineTest {

  // 模拟redis缓存
  private static HashMap redisLike = new HashMap<>();
  static {
    redisLike.put("k1","v1");
    redisLike.put("k2","v2");
  }

  // 模拟数据库
  private static HashMap dbLike = new HashMap<>();
  static {
    dbLike.put("k1", "v1");
    dbLike.put("k2", "v2");
    dbLike.put("k3", "v3");
    dbLike.put("k4", "v4");
  }

  public static String getValue(String key) {
    return localCache.get(key);
  }


  private static ThreadPoolExecutor executorService = new ThreadPoolExecutor(2,
    4,
    2,
    TimeUnit.MINUTES,
    new LinkedBlockingDeque<>(), new ThreadFactoryBuilder().build());

    // 注意,注意 这里为了方便模拟实际使用过程中可能遇到的情况,参数设置比较极端
  private static LoadingCache localCache = Caffeine.newBuilder()
    .maximumSize(1) // 最大缓存容量个数
    .expireAfterWrite(50, TimeUnit.SECONDS) // 过期时间
    .refreshAfterWrite(1, TimeUnit.SECONDS) // 写入后多久进行缓存刷新
    .build(new CacheLoader() {
        
      @Nullable
      @Override
      public String load(@NonNull String token) {
        // 模拟三级缓存刷入数据
        String value = null;
        if (redisLike.containsKey(token)) {
          System.out.println("从redis中获取数据");
          value = redisLike.get(token);
        } else if (dbLike.containsKey(token)) {
          System.out.println("从db中获取数据");
          value = dbLike.get(token);
          redisLike.put(token, value); // 需要给redis缓存设置过期时间,防止内存满了
        } else {
          System.out.printf("获取到%s的数据为空%n", token);
        }
        return value;
      }

      @Override
      public @NonNull
      CompletableFuture asyncReload(@NonNull String key, @NonNull String oldValue, @NonNull Executor executor) {
//        System.out.println("自动刷新缓存,key:" + key +",value:"+ oldValue);
        return CompletableFuture.supplyAsync(() -> {
//          System.out.println("执行异步刷新");
          return oldValue;
        },executorService);
      }
    });

  public static void main(String[] args) throws InterruptedException {
    System.out.println("value:"+localCache.get("k0"));
    Thread.sleep(300);
    System.out.println("value:"+localCache.get("k0"));
    dbLike.put("k0","v0"); // 模拟中途刷新数据
    Thread.sleep(300);
    System.out.println("value:"+localCache.get("k0"));

    System.out.println("value:"+localCache.get("k1"));
    Thread.sleep(300);
    System.out.println("value:"+localCache.get("k1"));
    Thread.sleep(300);
    System.out.println("value:"+localCache.get("k1"));

    System.out.println("value:"+localCache.get("k3"));
    Thread.sleep(1000);
    System.out.println("value:"+localCache.get("k3"));
    Thread.sleep(1000);
    System.out.println("value:"+localCache.get("k3"));

    localCache.refresh("k4");// 模拟主动刷新数据
    System.out.println("主动刷新数据k4数据");
    System.out.println("value:"+localCache.get("k4"));
    localCache.put("k4","v004");
    System.out.println("主动刷新数据k4数据");
    Thread.sleep(1000);
    System.out.println("value:"+localCache.get("k4"));
    Thread.sleep(1000);
    System.out.println("value:"+localCache.get("k4"));

    Thread.sleep(2000);// k0过期后重新获取
    System.out.println("value:"+localCache.get("k0"));
  }
}

执行结果:

获取到k0的数据为空
value:null
获取到k0的数据为空
value:null
从db中获取数据
value:v0
从redis中获取数据
value:v1
value:v1
value:v1
从db中获取数据
value:v3
value:v3
value:v3
从db中获取数据
主动刷新数据k4数据
value:v4
主动刷新数据k4数据
value:v004
value:v004
从redis中获取数据
value:v0

3.2,结果分析

由上述代码可以完美的模拟(memoryCache、redis、db三级缓存)三级缓存操作,先去memoryCache中查询数据,没有再去redis中查询数据(有就直接返回),如果还没有(就去db里面查询,如果有就刷新redis并直接返回),如果还没有就直接返回null。

分析获取k0:

获取到k0的数据为空
value:null
获取到k0的数据为空
value:null
从db中获取数据
value:v0
....(最后一段逻辑获取k0)
从redis中获取数据
value:v0

// 由于redis和db中都没有目标数据,所以没有刷新数据直接返回空
// 在某一时刻,db中刷入数据,获取到数据直接刷新至memoryCache和redis
// 最后一段逻辑获取k0,可以直接在redis中获取k0数据

分析获取k1、k3:由于redis、db中有相关数据所以直接刷新至memoryCache即可

分析获取k4:主动预热数据操作、刷新k4-v004

实际使用过程中的配置(根据实际情况修改):

Caffeine.newBuilder()
    .maximumSize(1000) // 最大缓存容量个数
    .expireAfterWrite(10, TimeUnit.MINUTES) // 过期时间,10分钟后过期
    .refreshAfterWrite(1, TimeUnit.SECONDS) // 写入后多久进行缓存刷新
    .build();

参考链接

  1. 为什么Caffeine比Guava好?
  2. Caffeine与Guava对比
  3. CacheManager与配置文件
  4. Guava cacha 机制及源码分析
  5. 全网最权威的Caffeine教程
  6. Caffeine的Window TinyLfu算法分析
  7. java引用
  8. 聊聊MyBatis缓存机制