Caffeine 使用与原理

caffeine、GuavaCache、EhCache 比较

Google Guava工具包中的一个非常方便易用的本地化缓存实现,基于LRU算法实现,支持多种缓存过期策略。

EhCache 是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。

Caffeine是使用Java8对Guava缓存的重写版本,在Spring Boot 2.0中将取代,基于LRU算法实现,支持多种缓存过期策略。

场景1:8个线程读,100%的读操作
在这里插入图片描述
场景2:6个线程读,2个线程写,也就是75%的读操作,25%的写操作
在这里插入图片描述
场景3:8个线程写,100%的写操作
在这里插入图片描述

Caffeine 基础使用

SpringBoot 集成

  1. 添加pom文件
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
  1. 添加配置文件
spring.cache.cache-names=ConfigCache
spring.cache.caffeine.spec=initialCapacity=50,maximumSize=500,expireAfterWrite=4s
spring.cache.type=caffeine

Application.class中添加 @EnableCaching 注解

@SpringBootApplication
@EnableCaching
public class Application {
	public static void main(String[] args) {
		SpringApplication.run(Application .class, args);
	}
}
  1. 添加需要缓存的方法
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class CaffeineService {

    @Cacheable(value = "IZUUL", key = "#key")
    public String cacheIZUUL(String key) {
        log.info("cacheIZUUL()方法执行");
        return getCache(key);
    }

    @CachePut(value = "IZUUL", key = "#key")
    public String cachePutIZUUL(String key) {
        log.info("cachePutIZUUL()方法执行");
        return "cachePutIZUUL--" + key;
    }

    private String getCache(String key) {
        try {
            log.info("getCache()方法执行");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return key;
    }
}
  1. 添加Controller 进行测试
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CaffeineController {

    @Autowired
    private CaffeineService caffeineService;

    @GetMapping("/cache-izuul/{key}")
    public String cacheIZUUL(@PathVariable String key) {

        return caffeineService.cacheIZUUL(key);
    }

    @GetMapping("/cache-put-izuul/{key}")
    public String cachePutIZUUL(@PathVariable String key) {

        return caffeineService.cachePutIZUUL(key);
    }

}

Caffeine配置

  • initialCapacity=[integer]: 初始的缓存空间大小
  • maximumSize=[long]: 缓存的最大条数
  • maximumWeight=[long]: 缓存的最大权重
  • expireAfterAccess=[duration]: 最后一次写入或访问后经过固定时间过期
  • expireAfterWrite=[duration]: 最后一次写入后经过固定时间过期
  • refreshAfterWrite=[duration]: 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
  • weakKeys: 打开key的弱引用
  • weakValues:打开value的弱引用
  • softValues:打开value的软引用
  • recordStats:开发统计功能

注意:
expireAfterWrite和expireAfterAccess同事存在时,以expireAfterWrite为准。
maximumSize和maximumWeight不可以同时使用
weakValues和softValues不可以同时使用

注解

  • @Cacheable 触发缓存入口(这里一般放在创建和获取的方法上)
  • @CacheEvict 触发缓存的eviction(用于删除的方法上)
  • @CachePut 更新缓存且不影响方法执行(用于修改的方法上,该注解下的方法始终会被执行)
  • @Caching 将多个缓存组合在一个方法上(该注解可以允许一个方法同时设置多个注解)
  • @CacheConfig 在类级别设置一些缓存相关的共同配置(与其它缓存配合使用)

@Cacheable

先看看它的源码

public @interface Cacheable {

    /**
     * 设定要使用的cache的名字,必须提前定义好缓存
     */
    @AliasFor("cacheNames")
    String[] value() default {};

    /**
     * 同value(),决定要使用那个/些缓存
     */
    @AliasFor("value")
    String[] cacheNames() default {};

    /**
     * 使用SpEL表达式来设定缓存的key,如果不设置默认方法上所有参数都会作为key的一部分
     */
    String key() default "";

    /**
     * 用来生成key,与key()不可以共用
     */
    String keyGenerator() default "";

    /**
     * 设定要使用的cacheManager,必须先设置好cacheManager的bean,这是使用该bean的名字
     */
    String cacheManager() default "";

    /**
     * 使用cacheResolver来设定使用的缓存,用法同cacheManager,但是与cacheManager不可以同时使用
     */
    String cacheResolver() default "";

    /**
     * 使用SpEL表达式设定出发缓存的条件,在方法执行前生效
     */
    String condition() default "";

    /**
     * 使用SpEL设置出发缓存的条件,这里是方法执行完生效,所以条件中可以有方法执行后的value
     */
    String unless() default "";

    /**
     * 用于同步的,在缓存失效(过期不存在等各种原因)的时候,如果多个线程同时访问被标注的方法
     * 则只允许一个线程通过去执行方法
     */
    boolean sync() default false;

}

使用示例

    /**
     * condition条件判断是否要走缓存,无法使用方法中出现的值(返回结果等),条件为true放入缓存
     * unless是方法执行后生效,决定是否放入缓存,返回true的放缓存
     * */
    @Cacheable(cacheNames = "outLimit",key = "#name",condition = "#value != null ")
    public String getCaffeineServiceTest(String name,Integer age){
        String value = name + " nihao "+ age;
        logger.info("getCaffeineServiceTest value = {}",value);
        return value;
    }

sync 属性
用于保证缓存需要加载时,只会有一个线程计算数据,其他线程阻塞。caffeine 本身也有类似机制,但是使用 sync 属性,其他线程由 spring 阻塞 ,而不是 caffeine。因为 caffeine 的阻塞机制中 ,每个阻塞的线程仍要重复 “获取锁,计算加载缓存,释放锁” 类似的过程,而由 spring 阻塞,阻塞的线程会待计算数据的线程加载完缓存后,直接从缓存中获取数据。

unless 属性
用于否决(veto)缓存,缓存计算结束后判断,若满足该表达式,则计算结果不会加入缓存中。如 unless = “#result == null” ,表示若计算结果为空,则不加入缓存。

@CachePut

使用示例

  @CachePut(value = "outLimit", key = "#key")
    public String cachePutIZUUL(String key) {
        log.info("cachePutIZUUL()方法执行");
        return "cachePutIZUUL--" + key;
    }

这是个一般用于修改方法上的注解,它的代码跟Cacheable基本相同,这里不做介绍。
现在说下CachePut和Cacheable的主要区别。

@Cacheable:它的注解的方法是否被执行取决于Cacheable中的条件,方法很多时候都可能不被执行。 
@CachePut:这个注解不会影响方法的执行,也就是说无论它配置的条件是什么,方法都会被执行,更多的时候是被用到修改上。

@CacheEvict

    /**
     * CacheEvict删除key,会调用cache的evict
     * */
    @CacheEvict(cacheNames = "outLimit",key = "#name")
    public String deleteCaffeineServiceTest(String name){
        String value = name + " nihao";
        logger.info("deleteCaffeineServiceTest value = {}",value);
        return value;
    }

跟上边的两个注解相比,源码中多了两个属性

public @interface CacheEvict {


    /**
     * 是否删除缓存中的所有数据,默认为false,只会删除被注解方法中传入的key的缓存
     */
    boolean allEntries() default false;

    /**
     * 设置缓存的删除在方法执行前执行还是执行后执行。如果设置true,则无论该方法是否正常结束,缓存中的值都会被删除。
     */
    boolean beforeInvocation() default false;

}

@Caching

它是个组合上面三个注解的注解,之前我并没有用到,现在结合spring文档简单说下。
源码

public @interface Caching {
    Cacheable[] cacheable() default {};
    CachePut[] put() default {};
    CacheEvict[] evict() default {};
}

它只是给出了三种注解的组合,并没有给出限制条件,所以其使用也很简单,如下

@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@Caching

类级别的注解,可以设置某类中所有注解的相同部分,这个可以参考spring的类级别的@Mapping来理解。
其代码很简单

public @interface CacheConfig {
    String[] cacheNames() default {};
    String keyGenerator() default "";
    String cacheManager() default "";
    String cacheResolver() default "";
}

使用如下

@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
    @Cacheable
    public Book findBook(ISBN isbn) {...}
}

其他功能

监听器(Removal )

您可以通过Caffeine.removalListener(RemovalListener) 为缓存指定一个删除侦听器,以便在删除数据时执行某些操作。 RemovalListener可以获取到key、value和RemovalCause(删除的原因)。

统计(Statistics)

使用Caffeine.recordStats(),您可以打开统计信息收集。Cache.stats() 方法返回提供统计信息的CacheStats,如:

  • hitRate():返回命中与请求的比率
  • hitCount(): 返回命中缓存的总数
  • evictionCount():缓存逐出的数量
  • averageLoadPenalty():加载新值所花费的平均时间

补充说明

spring cache 使用基于动态生成子类的代理机制来对方法的调用进行切面,如果缓存的方法是内部调用而不是外部引用,会导致代理失败,切面失效。

Caffeine 策略分析

过期策略

在Caffeine中分为两种缓存,一个是有界缓存,一个是无界缓存,无界缓存不需要过期并且没有界限。在有界缓存中提供了三个过期API:

  • expireAfterWrite:代表着写了之后多久过期。
  • expireAfterAccess: 代表着最后一次访问了之后多久过期。
  • expireAfter:在expireAfter中需要自己实现Expiry接口,这个接口支持create,update,以及access了之后多久过期。注意这个API和前面两个API是互斥的。这里和前面两个API不同的是,需要你告诉缓存框架,他应该在具体的某个时间过期,也就是通过前面的重写create,update,以及access的方法,获取具体的过期时间。

更新策略

LoadingCache<String, String> build = Caffeine.newBuilder().refreshAfterWrite(1, TimeUnit.DAYS)
   .build(new CacheLoader<String, String>() {
          @Override
          public String load(String key)  {
             return "";
          }
    });
}

但是实际使用中,你设置了一天刷新,但是一天后你发现缓存并没有刷新。这是因为必有在1天后这个缓存再次访问才能刷新,如果没人访问,那么永远也不会刷新。你明白了吗?

我们来看看自动刷新他是怎么做的呢?自动刷新只存在读操作之后,也就是我们afterRead()这个方法,其中有个方法叫refreshIfNeeded,他会根据你是同步还是异步然后进行刷新处理。

填充策略

同步加载(Loading)

// 初始化缓存
LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
     .maximumSize(10000)
     .expireAfterWrite(10, TimeUnit.MINUTES)
     .build(key -> createExpensiveGraph(key));

String key = "name1";
// 采用同步方式去获取一个缓存和上面的手动方式是一个原理。在build Cache的时候会提供一个createExpensiveGraph函数。
// 查询并在缺失的情况下使用同步的方式来构建一个缓存
Object graph = loadingCache.get(key);

// 获取组key的值返回一个Map
List<String> keys = new ArrayList<>();
keys.add(key);
Map<String, Object> graphs = loadingCache.getAll(keys);

异步加载(Asynchronously Loading)

AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        // Either: Build with a synchronous computation that is wrapped as asynchronous
        .buildAsync(key -> createExpensiveGraph(key));
        // Or: Build with a asynchronous computation that returns a future
        // .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));

String key = "name1";

// 查询并在缺失的情况下使用异步的方式来构建缓存
CompletableFuture<Object> graph = asyncLoadingCache.get(key);
// 查询一组缓存并在缺失的情况下使用异步的方式来构建缓存
List<String> keys = new ArrayList<>();
keys.add(key);
CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);
// 异步转同步
loadingCache = asyncLoadingCache.synchronous();

AsyncLoadingCache是继承自LoadingCache类的,异步加载使用Executor去调用方法并返回一个CompletableFuture。异步加载缓存使用了响应式编程模型。

如果要以同步方式调用时,应提供CacheLoader。要以异步表示时,应该提供一个AsyncCacheLoader,并返回一个CompletableFuture。

synchronous()这个方法返回了一个LoadingCacheView视图,LoadingCacheView也继承自LoadingCache。调用该方法后就相当于你将一个异步加载的缓存AsyncLoadingCache转换成了一个同步加载的缓存LoadingCache。

默认使用ForkJoinPool.commonPool()来执行异步线程,但是我们可以通过Caffeine.executor(Executor) 方法来替换线程池。

驱逐策略

Caffeine提供三类驱逐策略:基于大小(size-based),基于时间(time-based)和基于引用(reference-based)。

基于大小(size-based)

基于大小驱逐,有两种方式:一种是基于缓存大小,一种是基于权重。

// 根据缓存的计数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> createExpensiveGraph(key));

// 根据缓存的权重来进行驱逐(权重只是用于确定缓存大小,不会用于决定该缓存是否被驱逐)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((Key key, Graph graph) -> graph.vertices().size())
    .build(key -> createExpensiveGraph(key));

我们可以使用Caffeine.maximumSize(long)方法来指定缓存的最大容量。当缓存超出这个容量的时候,会使用Window TinyLfu策略来删除缓存。我们也可以使用权重的策略来进行驱逐,可以使用Caffeine.weigher(Weigher) 函数来指定权重,使用Caffeine.maximumWeight(long) 函数来指定缓存最大权重值。

让我们看看如何计算缓存中的对象。当缓存初始化时,其大小等于零:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
                      .maximumSize(1)
                      .build(k -> DataObject.get("Data for " + k));    
assertEquals(0, cache.estimatedSize()); 

当我们添加一个值时,大小明显增加:

cache.get("A");    
assertEquals(1, cache.estimatedSize()); 

我们可以将第二个值添加到缓存中,这导致第一个值被删除:

cache.get("B"); 
assertEquals(1, cache.estimatedSize()); 

基于时间(Time-based)

// 基于固定的到期策略进行退出
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
      .expireAfterAccess(5, TimeUnit.MINUTES)
      .build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
      .expireAfterWrite(10, TimeUnit.MINUTES)
      .build(key -> createExpensiveGraph(key));

// 要初始化自定义策略,我们需要实现 Expiry 接口
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
      .expireAfter(new Expiry<Key, Graph>() {
          @Override
          public long expireAfterCreate(Key key, Graph graph, long currentTime) {
            // Use wall clock time, rather than nanotime, if from an external resource
            long seconds = graph.creationDate().plusHours(5)
                   .minus(System.currentTimeMillis(), MILLIS)
                   .toEpochSecond();
            return TimeUnit.SECONDS.toNanos(seconds);
         }

          @Override
          public long expireAfterUpdate(Key key, Graph graph, 
            long currentTime, long currentDuration) {
            return currentDuration;
          }

          @Override
          public long expireAfterRead(Key key, Graph graph,
             long currentTime, long currentDuration) {
             return currentDuration;
          }
      })
      .build(key -> createExpensiveGraph(key));

基于引用(reference-based)

强引用,软引用,弱引用概念说明请点击连接,这里说一下各各引用的区别:

引用类型被垃圾回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时终止
软引用在内存不足时对象缓存内存不足时终止
弱引用被垃圾回收时对象缓存GC运行后终止
虚引用UnknownUnknownUnknown
// 当key和value都没有引用时驱逐缓存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
                                          .weakKeys()
                                          .weakValues()
                                          .build(key -> createExpensiveGraph(key));

// 当垃圾收集器需要释放内存时驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
                                          .softValues()
                                          .build(key -> createExpensiveGraph(key));

我们可以将缓存的驱逐配置成基于垃圾回收器。当没有任何对对象的强引用时,使用 WeakRefence 可以启用对象的垃圾收回收。SoftReference 允许对象根据 JVM 的全局最近最少使用(Least-Recently-Used)的策略进行垃圾回收。

注意:AsyncLoadingCache不支持弱引用和软引用。

Caffeine 原理分析

缓存算法

缓存算法(FIFO 、LRU、LFU三种算法的区别)

FIFO

FIFO 算法是一种比较容易实现的算法。它的思想是先进先出(FIFO,队列),这是最简单、最公平的一种思想,即如果一个数据是最先进入的,那么可以认为在将来它被访问的可能性很小。空间满的时候,最先进入的数据会被最早置换(淘汰)掉。

FIFO 算法的描述:设计一种缓存结构,该结构在构造时确定大小,假设大小为 K,并有两个功能:

  1. set(key,value):将记录(key,value)插入该结构。当缓存满时,将最先进入缓存的数据置换掉。
  2. get(key):返回key对应的value值。

实现:维护一个FIFO队列,按照时间顺序将各数据(已分配页面)链接起来组成队列,并将置换指针指向队列的队首。再进行置换时,只需把置换指针所指的数据(页面)顺次换出,并把新加入的数据插到队尾即可。

缺点:判断一个页面置换算法优劣的指标就是缺页率,而FIFO算法的一个显著的缺点是,在某些特定的时刻,缺页率反而会随着分配页面的增加而增加,这称为Belady现象。产生Belady现象现象的原因是,FIFO置换算法与进程访问内存的动态特征是不相容的,被置换的内存页面往往是被频繁访问的,或者没有给进程分配足够的页面,因此FIFO算法会使一些页面频繁地被替换和重新申请内存,从而导致缺页率增加。因此,现在不再使用FIFO算法。

LRU

LRU(The Least Recently Used,最近最久未使用算法)是一种常见的缓存算法,在很多分布式缓存系统(如Redis, Memcached)中都有广泛使用。

LRU算法的思想是:如果一个数据在最近一段时间没有被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最久没有访问的数据最先被置换(淘汰)。

LRU算法的描述: 设计一种缓存结构,该结构在构造时确定大小,假设大小为 K,并有两个功能:

  1. set(key,value):将记录(key,value)插入该结构。当缓存满时,将最久未使用的数据置换掉。
  2. get(key):返回key对应的value值。

实现:最朴素的思想就是用数组+时间戳的方式,不过这样做效率较低。因此,我们可以用双向链表(LinkedList)+哈希表(HashMap)实现(链表用来表示位置,哈希表用来存储和查找),在Java里有对应的数据结构LinkedHashMap。

LFU

LFU(Least Frequently Used ,最近最少使用算法)也是一种常见的缓存算法。

顾名思义,LFU算法的思想是:如果一个数据在最近一段时间很少被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最小频率访问的数据最先被淘汰。

LFU 算法的描述:
设计一种缓存结构,该结构在构造时确定大小,假设大小为 K,并有两个功能:

  1. set(key,value):将记录(key,value)插入该结构。当缓存满时,将访问频率最低的数据置换掉。
  2. get(key):返回key对应的value值。

算法实现策略:考虑到 LFU 会淘汰访问频率最小的数据,我们需要一种合适的方法按大小顺序维护数据访问的频率。LFU 算法本质上可以看做是一个 top K 问题(K = 1),即选出频率最小的元素,因此我们很容易想到可以用二项堆来选择频率最小的元素,这样的实现比较高效。最终实现策略为小顶堆+哈希表。

Caffeine 源码分析

caffeine的load put 和invalidate操作都是原子的,这个意思是这3个操作是互斥的,load和put是不能同时执行的,load和invalidate也是不能同时执行的。
先load再invalidate,invalidate操作是要等load操作执行完的。如果load操作执行比较慢,那invalidate操作就要等很久了。
caffeine的存储就是ConcurrentHashMap,利用了ConcurrentHashMap自己的node节点锁。