美文网首页Java 开发用到技术
Caffeine Cache 进程缓存之王

Caffeine Cache 进程缓存之王

作者: 不知名的蛋挞 | 来源:发表于2018-11-04 19:10 被阅读616次

    1. 前言

    互联网软件神速发展,用户的体验度是判断一个软件好坏的重要原因,所以缓存就是必不可少的一个神器。在多线程高并发场景中往往是离不开cache的,需要根据不同的应用场景来需要选择不同的cache,比如分布式缓存如redis、memcached,还有本地(进程内)缓存如ehcache、GuavaCache、Caffeine。

    说起Guava Cache,很多人都不会陌生,它是Google Guava工具包中的一个非常方便易用的本地化缓存实现,基于LRU算法实现,支持多种缓存过期策略。由于Guava的大量使用,Guava Cache也得到了大量的应用。但是,Guava Cache的性能一定是最好的吗?也许,曾经,它的性能是非常不错的。但所谓长江后浪推前浪,总会有更加优秀的技术出现。今天,我就来介绍一个比Guava Cache性能更高的缓存框架:Caffeine。

    2. 比较

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

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

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

    2.1 官方性能比较

    场景1:8个线程读,100%的读操作

    场景二:6个线程读,2个线程写,也就是75%的读操作,25%的写操作

    场景三:8个线程写,100%的写操作

    可以清楚的看到Caffeine效率明显的高于其他缓存。

    3. 如何使用

    public static void main(String[] args) {
          LoadingCache<String, String> build = CacheBuilder.newBuilder().initialCapacity(1).maximumSize(100).expireAfterWrite(1, TimeUnit.DAYS)
              .build(new CacheLoader<String, String>() {
                 //默认的数据加载实现,当调用get取值的时候,如果key没有对应的值,就调用这个方法进行加载
                 @Override
                 public String load(String key)  {
                      return "";
                 }
             });
    }
    

    参数方法

    • initialCapacity(1) 初始缓存长度为1
    • maximumSize(100) 最大长度为100
    • expireAfterWrite(1, TimeUnit.DAYS) 设置缓存策略在1天未写入过期缓存(后面讲缓存策略)

    4. 过期策略

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

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

    4. 更新策略

    何为更新策略?就是在设定多长时间后会自动刷新缓存。

    Caffeine提供了refreshAfterWrite()方法来让我们进行写后多久更新策略:

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

    上面的代码我们需要建立一个CacheLodaer来进行刷新,这里是同步进行的,可以通过buildAsync方法进行异步构建。在实际业务中这里可以把我们代码中的mapper传入进去,进行数据源的刷新。

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

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

    5. 填充策略(Population)

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

    5.1 手动加载(Manual)

    // 初始化缓存
    Cache<String, Object> manualCache = Caffeine.newBuilder()
               .expireAfterWrite(10, TimeUnit.MINUTES)
               .maximumSize(10_000)
               .build();
    
    String key = "name1";
    // 根据key查询一个缓存,如果没有返回NULL
    graph = manualCache.getIfPresent(key);
    // 如果缓存中不存在该键,createExpensiveGraph函数将用于提供回退值,该值在计算后插入缓存中
    graph = manualCache.get(key, k -> createExpensiveGraph(k));
    // 使用 put 方法手动填充缓存,如果以前有值就覆盖以前的值
    manualCache.put(key, graph);
    // 删除一个缓存
    manualCache.invalidate(key);
    
    ConcurrentMap<String, Object> map = manualCache.asMap();
    cache.invalidate(key);
    

    Cache接口允许显式的去控制缓存的检索,更新和删除。

    我们可以通过cache.getIfPresent(key) 方法来获取一个key的值,通过cache.put(key, value)方法显示的将数控放入缓存,但是这样子会覆盖缓原来key的数据。更加建议使用cache.get(key,k - > value) 的方式,get 方法将一个参数为 key 的 Function (createExpensiveGraph) 作为参数传入。如果缓存中不存在该键,则调用这个 Function 函数,并将返回值作为该缓存的值插入缓存中。get 方法是以阻塞方式执行调用,即使多个线程同时请求该值也只会调用一次Function方法。这样可以避免与其他线程的写入竞争,这也是为什么使用 get 优于 getIfPresent 的原因。

    注意:如果调用该方法返回NULL(如上面的 createExpensiveGraph 方法),则cache.get返回null,如果调用该方法抛出异常,则get方法也会抛出异常。可以使用Cache.asMap() 方法获取ConcurrentMap进而对缓存进行一些更改。

    5.2 同步加载(Loading)

    // 初始化缓存
    LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
         .maximumSize(10_000)
         .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);
    

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

    注意:您可以编写一个CacheLoader.loadAll来实现为特别请求的key加载值。例如,如果计算某个组中的任何键的值将为该组中的所有键提供值,则loadAll可能会同时加载该组的其余部分。

    5.3 异步加载(Asynchronously Loading)

    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 -> 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) 方法来替换线程池。

    6. 驱逐策略(eviction)

    缓存的驱逐策略是为了预测哪些数据在短期内最可能被再次用到,从而提升缓存的命中率。LRU(Least Recently Used)策略或许是最流行的驱逐策略。但LRU通过历史数据来预测未来是局限的,它会认为最后到来的数据是最可能被再次访问的,从而给与它最高的优先级。

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

    6.1 基于大小(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()); 
    

    注意:maximumWeight与maximumSize不可以同时使用。

    6.2 基于时间(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));
    

    6.3 基于引用(reference-based)

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

    // 当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不支持弱引用和软引用。

    7. 移除监听器(Removal)

    如果我们需要在缓存被移除的时候,得到通知产生回调,并做一些额外处理工作。这个时候RemovalListener就派上用场了。

    7.1 概念

    驱逐(eviction):由于满足了某种驱逐策略,后台自动进行的删除操作
    无效(invalidation):表示由调用方手动删除缓存
    移除(removal):监听驱逐或无效操作的监听器
    手动删除缓存:在任何时候,您都可能明确地使缓存无效,而不用等待缓存被驱逐。

    // individual key
    cache.invalidate(key)
    // bulk keys
    cache.invalidateAll(keys)
    // all keys
    cache.invalidateAll()
    

    7.2 Removal 监听器

    Cache<Key, Graph> graphs = Caffeine.newBuilder()
        .removalListener((Key key, Graph graph, RemovalCause cause) ->
            System.out.printf("Key %s was removed (%s)%n", key, cause))
        .build();
    

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

    删除侦听器的里面的操作是使用Executor来异步执行的。默认执行程序是ForkJoinPool.commonPool(),可以通过Caffeine.executor(Executor)覆盖。当操作必须与删除同步执行时,请改为使用CacheWrite,CacheWrite将在下面说明。

    注意:由RemovalListener抛出的任何异常都会被记录(使用Logger)并不会抛出。

    7.3 移除监听器应用

    public class Main {  
      
        // 创建一个监听器  
        private static class MyRemovalListener implements RemovalListener<Integer, Integer> {  
        @Override  
        public void onRemoval(RemovalNotification<Integer, Integer> notification) {  
            String tips = String.format("key=%s,value=%s,reason=%s", notification.getKey(), notification.getValue(), notification.getCause());  
            System.out.println(tips);  
        }  
        }  
      
        public static void main(String[] args) {  
      
        // 创建一个带有RemovalListener监听的缓存  
        Cache<Integer, Integer> cache = CacheBuilder.newBuilder().removalListener(new MyRemovalListener()).build();  
      
        cache.put(1, 1);  
      
        // 手动清除  
        cache.invalidate(1);  
      
        System.out.println(cache.getIfPresent(1)); // null  
        }  
      
    }  
    

    使用invalidate()清除缓存数据之后,注册的回调被触发了

    8. 统计(Statistics)

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

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

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

    相关文章

      网友评论

        本文标题:Caffeine Cache 进程缓存之王

        本文链接:https://www.haomeiwen.com/subject/iwpgtqtx.html