Caffeine详解

20 阅读9分钟

一,介绍

Caffeine是一个基于Java8开发的提供了近乎最佳命中率的高性能的缓存库。

缓存和ConcurrentMap有点相似,但还是有所区别。最根本的区别是ConcurrentMap将会持有所有加入到缓存当中的元素,直到它们被从缓存当中手动移除。但是,Caffeine的缓存Cache 通常会被配置成自动驱逐缓存中元素,以限制其内存占用。在某些场景下,LoadingCacheAsyncLoadingCache 因为其自动加载缓存的能力将会变得非常实用。

Maven坐标:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.5.5</version>
</dependency>

二,快速入门

Cache<String, String> cache = Caffeine.newBuilder()
        //5秒没有读写自动删除
        .expireAfterAccess(5, TimeUnit.SECONDS)
        //最大容量1024个,超过会自动清理空间
        .maximumSize(1024)
        .build();

//添加值
cache.put("key", "value");
//获取值,如果没有就返回null
cache.getIfPresent("张三key
//remove
cache.invalidate("key");

三,填充策略

填充策略是指如何在key不存在的情况下,如何创建一个对象进行返回,主要分为下面四种:

  • 手动加载
  • 自动加载
  • 手动异步加载
  • 自动异步加载

3.1 手动填充

Cache<String, User> cache = Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(10_000)
        .build();

User user = cache.get("key1", key -> {
    //本地查不到,一般去redis或者数据库查询
    //查询的同时会将查询到的内容put到caffeine中
    return new User("张三",1);
});
  • 通过cache.put(key, value)方法显示的将数控放入缓存,但是这样子会覆盖缓原来key的数据
  • 更加建议使用cache.get(key,k - > value) 的方式,get 方法将一个参数为 key 的 Function 作为参数传入。如果缓存中不存在该键,则调用这个 Function 函数,并将返回值作为该缓存的值插入缓存中。get 方法是以阻塞方式执行调用,即使多个线程同时请求该值也只会调用一次Function方法。这样可以避免与其他线程的写入竞争,这也是为什么使用 get 优于 getIfPresent 的原因。

注意:如果调用该方法返回NULL,则cache.get返回null,如果调用该方法抛出异常,则get方法也会抛出异常。

3.2 自动填充

LoadingCache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> {
        //本地查不到,一般去redis或者数据库查询
        //查询的同时会将查询到的内容put到caffeine中
        return new User("张三",1);
    });
//效果是跟手动填充一样的,只不过将查询代码放到了build里面
User user = cache.get("key1");

3.3 异步手动填充

AsyncCache<String, User> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(10_000)
    .buildAsync();

// 查找一个缓存元素, 没有查找到的时候返回null
CompletableFuture<User> user1 = cache.getIfPresent(key);
// 查找缓存元素,如果不存在,则异步生成
CompletableFuture<User>  user2 = cache.get("key", key -> {
    return new User("张三",12);
});
// 添加或者更新一个缓存元素
cache.put("key", new User("张三"20));
// 移除一个缓存元素
cache.synchronous().invalidate(key);
  • 异步手动填充和手动填充的区别就是,没有查询到就会异步执行get里面的方法
  • 默认的线程池实现是 ForkJoinPool.commonPool()(,当然你也可以通过覆盖并实现 Caffeine.executor(Executor)方法来自定义你的线程池选择。

3.4 异步自动填充

AsyncLoadingCache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    // 你可以选择: 去异步的封装一段同步操作来生成缓存元素
    .buildAsync(key -> {
        //本地查不到,一般去redis或者数据库查询
        //查询的同时会将查询到的内容put到caffeine中
        return new User("张三",13);
    });

// 查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<User> user = cache.get(key);
// 批量查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<Map<String, User>> graphs = cache.getAll(keys);
  • 异步自动填充和异步手动填充区别就是将查询放到了build里面

四,驱逐策略

Caffeine提供三类驱逐策略:

  • 基于大小(size-based)
  • 基于时间(time-based)
  • 基于引用(reference-based)

4.1 基于大小

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

// 基于缓存内的元素个数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> createExpensiveGraph(key));

// 基于缓存内元素权重进行驱逐
LoadingCache<String, User> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((String key, User user) -> user.getAge())
    .build(key -> createExpensiveGraph(key));

4.2 基于时间

// 基于固定的过期时间驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));

// 基于不同的到期策略进行退出
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    // 设置自定义的到期策略
    .expireAfter(new Expiry<Key, Graph>() {
      
      // 创建时的到期策略
      @Override
      public long expireAfterCreate(Key key, Graph graph, long currentTime) {
        // 使用挂钟时间,而不是纳秒时间,如果数据来自外部资源
        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;
      }
    })
    // 创建缓存对象时,调用该方法生成 Graph 实例
    .build(key -> createExpensiveGraph(key));

Caffeine提供了三种定时驱逐策略:

  • expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。
  • expireAfterWrite(long, TimeUnit): 在最后一次写入缓存后开始计时,在指定的时间后过期。
  • expireAfter(Expiry): 自定义策略,过期时间由Expiry实现独自计算。

4.3 基于引用

// 当key和缓存元素都不再存在其他强引用的时候驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));

// 当进行GC的时候进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));
  • Caffeine 允许你配置你的缓存去让GC去帮助清理缓存当中的元素,其中key支持弱引用,而value则支持弱引用和软引用。记住 AsyncCache不支持软引用和弱引用。

  • Caffeine.weakKeys() 在保存key的时候将会进行弱引用。这允许在GC的过程中,当key没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行key之间的比较。

  • Caffeine.weakValues()在保存value的时候将会使用弱引用。这允许在GC的过程中,当value没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

  • Caffeine.softValues()在保存value的时候将会使用软引用。为了相应内存的需要,在GC过程中被软引用的对象将会被通过LRU算法回收。由于使用软引用可能会影响整体性能,我们还是建议通过使用基于缓存容量的驱逐策略代替软引用的使用。同样的,使用 softValues() 将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

五,移除

概念:

  • 驱逐 缓存元素因为策略被移除
  • 失效 缓存元素被手动移除
  • 移除 由于驱逐或者失效而最终导致的结果

5.1 显式移除

// 失效key
cache.invalidate(key)
// 批量失效key
cache.invalidateAll(keys)
// 失效所有的key
cache.invalidateAll()

5.2 移除监听器

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .evictionListener((Key key, Graph graph, RemovalCause cause) ->
        System.out.printf("Key %s was evicted (%s)%n", key, cause))
    .removalListener((Key key, Graph graph, RemovalCause cause) ->
        System.out.printf("Key %s was removed (%s)%n", key, cause))
    .build();
  • 通过Caffeine.removalListener(RemovalListener)方法定义一个移除监听器在一个元素被移除的时候进行相应的操作。这些操作是使用 Executor 异步执行的
  • 默认的 Executor 实现是 ForkJoinPool.commonPool()并且可以通过覆盖Caffeine.executor(Executor)方法自定义线程池的实现。

六,刷新

LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    // 指定在创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
    .refreshAfterWrite(Duration.ofMinutes(1))
    .build(key -> createExpensiveGraph(key));
  • 刷新和驱逐并不相同。可以通过LoadingCache.refresh(K)方法,异步为key对应的缓存元素刷新一个新的值。与驱逐不同的是,在刷新的时候如果查询缓存元素,其旧值将仍被返回,直到该元素的刷新完毕后结束后才会返回刷新后的新值。

  • 与expireAfterWrite不同的是,refreshAfterWrite将在查询数据的时候判断该数据是不是符合查询条件,如果符合条件该缓存就会去执行刷新操作。

    可以在同一个缓存中同时指定refreshAfterWrite和expireAfterWrite,只有当数据具备刷新条件的时候才会去刷新数据,不会盲目去执行刷新操作。如果数据在刷新后就一直没有被再次查询,那么该数据也会过期。

  • 刷新操作是使用Executor异步执行的。默认执行程序是ForkJoinPool.commonPool(),可以通过Caffeine.executor(Executor)覆盖。

  • 如果刷新时引发异常,则使用log记录日志,并不会抛出。

七,统计

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

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

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

八,策略

在创建缓存的时候,缓存的策略就指定好了。但是我们可以在运行时可以获得和修改该策略。这些策略可以通过一些选项来获得,以此来确定缓存是否支持该功能。

8.1 Size-based

cache.policy().eviction().ifPresent(eviction -> {
  eviction.setMaximum(2 * eviction.getMaximum());
});
  • 如果缓存配置的时基于权重来驱逐,那么我们可以使用weightedSize() 来获取当前权重。这与获取缓存中的记录数的Cache.estimatedSize() 方法有所不同。

  • 缓存的最大值(maximum)或最大权重(weight)可以通过getMaximum()方法来读取,并使用setMaximum(long)进行调整。当缓存量达到新的阀值的时候缓存才会去驱逐缓存。

  • 如果有需用我们可以通过hottest(int)coldest(int)方法来获取最有可能命中的数据和最有可能驱逐的数据快照。

8.2 Time-based

cache.policy().expireAfterAccess().ifPresent(expiration -> ...);
cache.policy().expireAfterWrite().ifPresent(expiration -> ...);
cache.policy().expireVariably().ifPresent(expiration -> ...);
cache.policy().refreshAfterWrite().ifPresent(expiration -> ...);

ageOf(key,TimeUnit) 提供了从expireAfterAccess,expireAfterWrite或refreshAfterWrite策略的角度来看条目已经空闲的时间。最大持续时间可以从getExpiresAfter(TimeUnit)读取,并使用setExpiresAfter(long,TimeUnit)进行调整。

如果有需用我们可以通过hottest(int)coldest(int)方法来获取最有可能命中的数据和最有可能驱逐的数据快照。

九,CleanUp

缓存的删除策略使用的是惰性删除和定时删除,但是我也可以自己调用cache.cleanUp()方法手动触发一次回收操作。cache.cleanUp()是一个同步方法。