Caffeine-基于JDK的缓存

Caffeine

简介

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

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

Caffeine提供了灵活的构造器去创建一个拥有下列特性的缓存:

  • 自动加载元素到缓存当中,异步加载的方式也可供选择
  • 当达到最大容量的时候可以使用基于就近度和频率的算法进行基于容量的驱逐
  • 将根据缓存中的元素上一次访问或者被修改的时间进行基于过期时间的驱逐
  • 当向缓存中一个已经过时的元素进行访问的时候将会进行异步刷新
  • key将自动被弱引用所封装
  • value将自动被弱引用或者软引用所封装
  • 驱逐(或移除)缓存中的元素时将会进行通知
  • 写入传播到一个外部数据源当中
  • 持续计算缓存的访问统计指标

算法比较

Caffeine 使用 Window TinyLfu策略因为其高命中率和更低的内存开销。

Adaptive Replacement Cache

ARC使用一个队列储存只看了一次的元素,另一个队列存放查看多次的元素,和非常驻队列用来存放被监控的驱逐元素。队列的最大大小将根据工作负载模式和缓存性能动态调整。

这个策略容易被实现,但是其要求缓存的容量加倍以存放被驱逐的元素。并且已经获得专利,未经IBM许可不可使用。

Low Inter-reference Recency Set

LIRS通过IRR(一个数据块被访问两次的间隔)来组织数据块,并将元素根据热数据块(LIR)或者冷数据块(HIR)进行分组。LIR热数据元素将会被更倾向保留在缓存当中,而被驱逐的HIR冷数据元素江北保留为非常驻HIR冷数据元素。在数次缓存未命中后,允许非常驻HIR元素被晋升为LIR元素。

这个策略实现起来非常复杂,并且需要扩大缓存大小为原来的三倍以保留被驱逐的元素达到最高效的性能。

Window TinyLfu

W-TinyLfu通过LRU将小范围窗口中的数据数据驱逐到更大的范围中进行Segmented LRU进行驱逐。 TinyLfu依赖一个频率sketch 来预估元素的历史访问频率。小范围的数据窗口的设计使该策略在新数据大量加入的时候具有较高的命中率。小范围的时间窗口大小和大范围的主要空间大小将根据hill climbing算法进行动态优化。这允许缓存能以较低的开销估计元素的访问频率和就近度。

这通过4-bit CountMinSketch实现,为了准确性每个元素消耗8字节空间。与ARCLIRS不同的是,这个策略不会保留被驱逐的元素。

模拟

将各个驱逐策略和Bélády's optimal进行比较以得到其理论上限。评估追踪程序中的数据子集用来描述一系列策略下的工作负载。

Wikipedia

WikiBench提供了所有向Wikipedia发出的请求中的10%的追踪程序。

image

填充策略

Caffeine 为我们提供了三种填充策略:手动、同步和异步

手动加载(Manual)

Cache<String, String> manualCache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES) //写入后十分钟过期删除,基于时间驱逐策略
                .maximumSize(10_000) //最大1w个,超过则按照lfu策略删除,基于大小策略驱逐
                .build(); //key, value类型使用范型,可修改


String key = "name1";
manualCache.put(key, "张三");

// 根据key查询一个缓存,如果没有返回NULL
String person = manualCache.getIfPresent(key);
logger.info("缓存中的键值对 key:{} value:{}", key, person); // key:name1 value:张三
person = manualCache.get(key, CaffeineCache::syncUpdateKeyAndReturnNewValue);
logger.info("缓存中的键值对 key:{} value:{}", key, person); // key:name1 value:张三
// 根据Key查询一个缓存,如果没有调用createExpensiveGraph方法,并将返回值保存到缓存。
// 如果该方法返回Null则manualCache.get返回null,如果该方法抛出异常则manualCache.get抛出异常
String key2 = "name2";
person = manualCache.get(key2, CaffeineCache::syncUpdateKeyAndReturnNewValue);
logger.info("缓存中的键值对 key:{} value:{}", key, person); //key:name1 value:newValue
// 将一个值放入缓存,如果以前有值就覆盖以前的值
manualCache.put(key, "李四");
person = manualCache.getIfPresent(key);
logger.info("缓存中的键值对 key:{} value:{}", key, person); //key:name1 value:李四
// 删除一个缓存
manualCache.invalidate(key);
person = manualCache.getIfPresent(key);
logger.info("缓存中的键值对 key:{} value:{}", key, person == null ? "" : person); //key:name1 value:

ConcurrentMap<String, String> map = manualCache.asMap();

get 方法是以阻塞方式执行调用,即使多个线程同时请求该值也只会调用一次Function方法。这样可以避免与其他线程的写入竞争,这也是为什么使用 get 优于 getIfPresent 的原因。

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

同步加载(Loading)

LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build(key -> syncUpdateKeyAndReturnNewValue(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);

批量查找可以使用getAll方法。默认情况下,getAll将会对缓存中没有值的key分别调用CacheLoader.load方法来构建缓存的值。我们可以重写CacheLoader.loadAll方法来提高getAll的效率。

异步加载

AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            // Either: Build with a synchronous computation that is wrapped as asynchronous
            .buildAsync(key -> syncUpdateKeyAndReturnNewValue(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();

驱逐策略(eviction)

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

基于大小

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

// Evict based on the number of entries in the cache
// 根据缓存的计数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> syncUpdateKeyAndReturnNewValue(key));

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

基于时间

// Evict based on a fixed expiration policy
// 基于固定的到期策略进行退出
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));

// Evict based on a varying expiration policy
// 基于不同的到期策略进行退出
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));

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

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

基于引用

Java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用

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

// Evict when the garbage collector needs to free memory
// 当垃圾收集器需要释放内存时驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));

Github地址:https://github.com/ben-manes/caffeine#download

参考:

1.https://www.jianshu.com/p/9a80c662dac4

posted @ 2021-12-09 20:36  orangeScc  阅读(432)  评论(0编辑  收藏  举报