[03] Caffeine详解

2,241 阅读20分钟

Caffeine详解

Caffeine是一款基于Java8开发的,高性能的,接近最优的本地缓存库,出自于**Benjamin Manes**大神之手,大家熟悉的Spring4.3+和SpringBoot1.4+缓存的实现便是基于Caffeine。本文将从淘汰策略、过期策略、并发控制等方面为大家揭开Caffeine的神秘面纱。

2.1 淘汰策略(Size-Based)

缓存命中率是衡量缓存系统优劣的重要指标,缓存通过保存最近使用或经常使用的数据于速度较快的介质中以提高数据的访问速度,缓存空间被填满后,会面临如何选择丢弃数据为新数据腾出空间的问题,因此选择合适的缓存淘汰算法会直接影响缓存的命中率。常用的缓存算法有:

  • First in first out (FIFO) 顾名思义,就是一个FIFO队列,先进入缓存的先被淘汰,忽略数据访问频率和次数信息。较为原始,命中率很低。
  • Least recently used (LRU) 最近最少被使用算法,其核心思想是【如果数据最近被访问过,那么将来被访问的概率也更大】,忽略了数据的访问次数,最常见的实现是维护一个链表,新数据插入到链表尾部,命中缓存的数据重新移至队尾,队列满后移出队首元素。LRU算法在有热点数据的情况下效率很好,但是如果面临突然流量或者周期性的数据访问是命中率会急剧下降。
  • Least frequently used (LFU) 最近最少频率使用,他的核心思想是【如果数据过去被多次访问,那么将来被访问的概率也更大】,LFU的实现需要为每个缓存数据维护一个引用计数,最后基于访问数量进行数据淘汰操作,因此实现较为复杂。LFU算法的效率通常高于LRU,能较好的介君突发流量或周期性访问问题,但是由于LFU需要一定时间累积自己的访问频率,因此无法应对数据访问方式的改变。
  • 更多的缓存淘汰算法有兴趣的同学可点击传送门

Caffeine的缓存淘汰是通过一种叫做W-TinyLFU的数据结构实现的,这是一种对LRU和LFU进行了组合优化的算法,下图给出了W-TinyLFU相对其他几种算法的表现,可以看出W-TinyLFU在数据查询、搜索、分析等场景均有优异的表现,更详细的性能测试可以点击传送门

image.png

2.1.1 W-TinyLFU

Window TinyLFU主要由以下三个部分组成:

image.png

  • 准入窗口(Admission Window)也称伊甸区,是一个较小的LRU队列,其容量只有缓存大小的1%,这个窗口的作用主要是为了保护一些新进入缓存的数据,给他们一定的成长时间来积累自己的使用频率,避免被快速淘汰掉,同时这个窗口也可以过滤掉一些突发流量。
  • 频次过滤器(TinyLFU)是Caffeine数据淘汰策略的核心所在,他依赖CountMin Sketch非精确的记录数据的历史访问次数,从而决定主缓存区数据的淘汰策略,这个数据结构用很小的成本完成了缓存数据访问频次的记录和查找。
  • 主缓存区(Main region)用于存放大部分的缓存数据,数据结构为一个分段LRU队列(SLRU),包括ProtectedDeque和ProbationDeque两部分,其中ProtectedDeque的大小占总容量的80%,该部分使用TinyLFU的Adminsion策略进行数据的淘汰,一些访问频次很低的数据可以被快速淘汰掉,避免了主缓存区被新缓存污染。

2.1.2 CountMin Sketch

LFU算法实现的关键在于如何能高效的保存和读取数据最近的访问频次信息,通常的做法是使用popularity sketch(一种概率数据结构)来识别数据的"命中"事件,从而记录数据的访问次数。CountMin-Sketch便是其中的一种,他是通过一个计数矩阵和多个哈希算法实现的,如图所示:

image.png

CountMin Sketch的原理类似于布隆过滤器,也是一种概率型的数据结构。其中不同的row对应着不同的哈希算法,depth大小代表着哈希算法的数量,width则表示数据可哈希的范围。当记录某个指定key的访问次数时,分别使用不同的哈希算法在其对应的row上做哈希操作,如果命中了某一个数据格,则将该数据格的引用计数+1。当查询某个指定key的访问次数时,经过哈希定位到具体的多个数据格后,返回最小的数量计为该数据的访问次数。使用多个哈希算法可以降低哈希碰撞带来的数据不准确的概率,宽度上的增加可以提高key的哈希范围,减少碰撞的概率,因此我们可以通过调整矩阵的width和depth达到算法在空间、效率和哈希碰撞产生的错误率之间平衡的目的。Caffeine中的CountMin Sketch是通过四种哈希算法和一个long型数组实现的,具体的实现方法可以参考咖啡拿铁大神的文章——深入解密来自未来的缓存-Caffeine

首先要说到的就是频率记录的问题,我们要实现的目标是利用有限的空间可以记录随时间变化的访问频率。在W-TinyLFU中使用Count-Min Sketch记录我们的访问频率,而这个也是布隆过滤器的一种变种。如下图所示:

image.png

如果需要记录一个值,那我们需要通过多种Hash算法对其进行处理hash,然后在对应的hash算法的记录中+1,为什么需要多种hash算法呢?由于这是一个压缩算法必定会出现冲突,比如我们建立一个Long的数组,通过计算出每个数据的hash的位置。比如张三和李四,他们两有可能hash值都是相同,比如都是1那Long[1]这个位置就会增加相应的频率,张三访问1万次,李四访问1次那Long[1]这个位置就是1万零1,如果取李四的访问评率的时候就会取出是1万零1,但是李四命名只访问了1次啊,为了解决这个问题,所以用了多个hash算法可以理解为long[][]二维数组的一个概念,比如在第一个算法张三和李四冲突了,但是在第二个,第三个中很大的概率不冲突,比如一个算法大概有1%的概率冲突,那四个算法一起冲突的概率是1%的四次方。通过这个模式我们取李四的访问率的时候取所有算法中,李四访问最低频率的次数。所以他的名字叫Count-Min Sketch。

image.png

image.png

这里和以前的做个对比,简单的举个例子:如果一个hashMap来记录这个频率,如果我有100个数据,那这个HashMap就得存储100个这个数据的访问频率。哪怕我这个缓存的容量是1,因为Lfu的规则我必须全部记录这个100个数据的访问频率。如果有更多的数据我就有记录更多的。

在Count-Min Sketch中,我这里直接说caffeine中的实现吧(在FrequencySketch这个类中),如果你的缓存大小是100,他会生成一个long数组大小是和100最接近的2的幂的数,也就是128。而这个数组将会记录我们的访问频率。在caffeine中规定频率最大为15,15的二进制位1111,总共是4位,而Long型是64位。所以每个Long型可以放16种算法,但是caffeine并没有这么做,只用了四种hash算法,每个Long型被分为四段,每段里面保存的是四个算法的频率,这样做的好处是可以进一步减少Hash冲突,原先128大小的hash,就变成了128X4。 一个Long的结构如下:

image.png

我们的4个段分为A,B,C,D,在后面我也会这么叫它们。而每个段里面的四个算法我叫他s1,s2,s3,s4。下面举个例子如果要添加一个访问50的数字频率应该怎么做?我们这里用size=100来举例。

  1. 首先确定50这个hash是在哪个段里面,通过hash & 3(3的二进制是11)必定能获得小于4的数字,假设hash & 3=0,那就在A段。
  2. 对50的hash再用其他hash算法再做一次hash,得到long数组的位置,也就是在长度128数组中的位置。假设用s1算法得到1,s2算法得到3,s3算法得到4,s4算法得到0。
  3. 因为S1算法得到的是1,所以在long[1]的A段里面的s1位置进行+1,简称1As1加1,然后在3As2加1,在4As3加1,在0As4加1。

image.png

这个时候有人会质疑频率最大为15的这个是否太小?没关系在这个算法中,比如size等于100,如果他全局提升了size*10也就是1000次就会全局除以2衰减,衰减之后也可以继续增加,这个算法再W-TinyLFU的论文中证明了其可以较好的适应时间段的访问频率。

2.1.3 淘汰过程

image.png

  1. 所有进入缓存区的数据会首先被add进Eden区,当该队列长度达到容量限制后,会触发Eden区的淘汰操作,超出的entries会被下放至主缓存区的Probation队列,这些数据被称为Candidate。
  2. 进入Probation队列的数据如果在没有被主缓存区淘汰之前获得了一次access,该节点会被add进Protected队列,这个过程称之为Node Promotion。
  3. Protecte队列如果达到其容量限制会触发Node Demotion过程,队列首部的元素会被peek出并下放到Probation队列。
  4. 主缓存区的大小(Probation的大小 + Protected的大小)达到了其容量限制会触发主缓存区的数据淘汰,Probation会被优先选择为淘汰队列,如果Probation为空,则选择Protected为淘汰队列。
  5. 分别选取淘汰队列的首部元素作为受害者(victim),尾部元素作为竞争者(candidate),通过对比两者的访问频次选择最终的淘汰者,其中访问频次通过CountMin Sketch获得。

这里Caffeine对于竞争者的淘汰并不只是简单的判断其访问频次小于或等于受害者,而是加入了以下逻辑:

  • 如果竞争者的访问频次大于5且小于或等于受害者频次,随机淘汰,这么做的原因主要是为了一定程度的避免哈希碰撞引起的受害者访问频次非自然增长,从而导致新数据无法被写入主缓存区。
  • 如果竞争者的访问频次小于等于5则直接淘汰竞争者,这是因为TinyLFU中记录数据的访问频次最大值为15,当超过这个最大值触发全局的reset后只有7,因此如果不加一个数据预热的过程,可能会导致一个频率较低的攻击者因为随机淘汰策略挤掉了热点数据。
boolean admit(K candidateKey, K victimKey) {
    int victimFreq = frequencySketch().frequency(victimKey);
    int candidateFreq = frequencySketch().frequency(candidateKey);
    if (candidateFreq > victimFreq) {
      return true;
    } else if (candidateFreq <= 5) {
      return false;
    }
    int random = ThreadLocalRandom.current().nextInt();
    return ((random & 127) == 0);
}

2.2 过期策略(Time-Based)

缓存数据过期清理的必要性和重要性不仅仅体现在应用场景的要求上,对于缓存本身,及时清理掉过期数据可以节省空间,降低缓存的维护成本,提高缓存性能,缓存过期清理的方式可以分为以下两种(是我按照自己的理解分的,没有资料依据,有不当之处望大家指正):

  • 访问清理,即每次访问某个具体的缓存数据时判断其是否过期然后执行相应的操作。这种策略的优点就是实现简单,缺点是缓存的过期依赖于缓存的访问,会造成过期数据污染缓存区现象的发生,如果一个数据过期后没有被再次访问,那么他可能会长期存在于缓存中,直到淘汰策略将其移除。
  • 全局清理,即在某些特定的时期全局性的扫描缓存区,清理掉过期数据。这种方式的优点是可以及时清理掉一些过期的数据,不必依赖数据的访问。缺点是实现成本高,需要使用额外的数据结构去维护一个过期队列,实现复杂,除此之外全局清理的发生时间也是一个棘手的问题。

2.2.1 清理策略

Caffeine中的对于访问清理和全局清理都有支持,对于访问清理的实现方式如下图所示,因为Caffeine缓存区的实现是一个LRU队列,本身就维护了数据访问的时间顺序,因此不必使用额外的数据结构进行存储,每次只用peek出缓存队列的首部元素对其进行过期判断即可。

void expireAfterAccessEntries(AccessOrderDeque<Node<K, V>> accessOrderDeque, long now) {
    long duration = expiresAfterAccessNanos();
    for (;;) {
      Node<K, V> node = accessOrderDeque.peekFirst();
      if ((node == null) || ((now - node.getAccessTime()) < duration)) {
        return;
      }
      evictEntry(node, RemovalCause.EXPIRED, now);
    }
}

对于全局清理而言主要有两个要解决的问题点,即如何选择清理时间和如何快速找到过期数据并移除。我们首先来看第一个问题,通常来讲有两种方式,一是依赖缓存的淘汰策略,缓存区达到容量限制后发生缓存淘汰时触发一次缓存过期清理,二是开启一个清理线程定期的执行过期清理工作。Caffeine中选择的第二种方式,不同的是这里的实现并不是通过一个轮询线程定期执行,而是由缓存的读写触发的,具体的触发方式和触发规则将在下一节【并发控制】里详细讲解。解决了如何选择清理时间的问题我们再来看如何能快速找到过期数据,通常的做法是维护一个基于过期时间的优先队列,这样做的复杂度是O(lgn),Caffeine中采用的是Kafka中的时间轮(hierarchical timing wheels)方法,他的插入删除时间复杂度都为O(1),相信做过定时任务调度的同学对这个数据结构一定不陌生,这里只做一点简单的介绍,想要深入了解的同学可以点击传送门

2.2.2 时间轮(Timing Wheels)

image.png 时间轮是一个由时间插槽组成的环状数组,插槽由一组同样时间范围内的定时任务组成。每一个插槽代表一个时间间隔(u),时间轮由n个插槽组成,因此一个时间轮所能承载的调度时间是u*n。时间轮中时间指针(ticks)用于标记时间,每次转动一个插槽的间隔,ticks指向的插槽代表当前插槽内的所有任务过期,清空当前插槽,插入任务时,基于当前时间算出ticks所在的插槽,再根据任务的过期时间插入相应的插槽即可,可以看出时间轮对于任务的插入和删除(过期)操作时间复杂度都是O(1)。单个时间轮所能代表的时间是有限的,当一个任务的过期时间超过了时间轮所能表示的调度范围时,单个时间轮就无法胜任了,当然我们可以选择像HashMap一样做扩容,重新划分时间间隔或增加插槽数,显然成本太高,不能接受,因此引进了层级时间轮的概念,更通俗一点就是多来几个轮子,每个时间轮代表的一种时间间隔,如果上层时间轮的精度太粗不能满足要求,则交给下一级的时间轮处理,直至找到满足要求的时间间隔要求的时间轮,此时任务插入的时间成本变成了O(m),其中m等于时间轮的个数,时间轮的个数不可能很大,这是可以接受的。插槽内定时任务的存储是用一个双向链表实现的,这样设计的好处是如果我们持有列表中一个节点的引用时可以以O(1)的时间复杂度对该节点做删除或插入操作。

2.3 并发控制(Concurrency-Control)

缓存的并发控制是一个比较头疼的问题,因为缓存的淘汰策略、过期策略等等都会涉及到了对同一块缓存区内容的修改,最简单省事的办法无非就是给缓存区加锁,分段锁是一种比较通用的解决方案,ConcurrentyHashMap的实现(Java8之前)就是基于分段锁,然而由于缓存中锁的竞争主要发生在一些热点数据上,因此分段锁带来的收益也是有限的。Caffeine中采用了缓冲队列回放机制来减轻并发带来的锁竞争问题,同时对于缓存而言读操作是远远大于写操作的,因此Caffeine中对于读写的缓冲队列也采用了不同的实现思路和方式,下面详细讨论。

2.3.1 缓冲队列——读

Caffeine中缓存每次的读操作都会先被写入缓冲队列,其底层的数据结构是一个striped ring buffer,这里的striped是指一种通过哈希获取锁的算法,对于同一个key(Java中可能对象地址不同)哈希后获得的将是同一个锁对象,Caffeine基于striped hash后的key分配相应的ring buffer,值得注意的是这里的key并不是缓存数据的key,而是线程本身,这样设计的好处是可以对热点数据访问引起的激增流量起到削峰作用。写入缓存成功后会基于一定的条件判断是否需要触发一个异步的缓存区调度任务,代码如下。可以看到当ring buffer满了之后并且调度状态满足一定的条件才会触发,如果当前ring buffer满之后,后续写入该队列的读操作会被直接被丢弃,这种信息的缺失并不会产生很大的影响,因为TinyLFU可以记录哪些是热点数据,这里状态机之间的流转如图所示,具体的状态机转移过程情况较多比较复杂,有兴趣的同学可以去看源码。

void afterRead(Node<K, V> node, long now, boolean recordHit) {
    if (recordHit) {
      statsCounter().recordHits(1);
    }
    boolean delayable = skipReadBuffer() || (readBuffer.offer(node) != Buffer.FULL);
    if (shouldDrainBuffers(delayable)) {
      scheduleDrainBuffers();
    }
    refreshIfNeeded(node, now);
}
boolean shouldDrainBuffers(boolean delayable) {
  switch (drainStatus()) {
    case IDLE:
      return !delayable;
    case REQUIRED:
      return true;
    case PROCESSING_TO_IDLE:
    case PROCESSING_TO_REQUIRED:
      return false;
    default:
      throw new IllegalStateException();
  }
}

image.png

2.3.2 缓冲队列——写

Caffeine认为写的操作远小于读,因此所有的写操作共享同一个缓冲队列,这里的队列一个多生产者单消费者模型,具体的实现是用了JCtools里的无锁MPSC(Multi Producer Single Consumer)自动扩容队列,由于没有锁因此效率很高。写操作的丢失是不能被容许的,因此Caffeine中每次写操作都会触发一次缓存区调度任务。缓存区的任务回放(读和写都有)和缓存数据的写入会产生竞态条件,可能会导致对某个缓存数据的增删改查操作不能被有序的执行,从而产生悬空索引的问题,Caffenie通过引入了状态机定义节点的生命周期来解决这个问题。Alive状态代表该节点还在缓存中,Retired状态代表节点不在缓存中但是正在被淘汰策略清除,Dead状态代表该节点已经被移出缓存区了。

image.png

2.3.1 缓冲队列任务调度(Maintenance Work

上面提到读写操作都会触发缓存区的任务调度,那这个任务调度到底是干了那些事情呢,上代码。可以看到Caffeine中的缓存区任务调度(scheduleDrainBuffers)实际上是异步执行了一个叫做执行清理的任务(PerformCleanupTask),这里的线程池采用了ForkJoinPool实现。继续往下可以看到清理任务其实最终执行的是maintenance方法中的内容,执行之前会获取一个叫做evictionLock的锁,这里其实已经可以猜到这部分的主要工作肯定是和缓存淘汰有关,查看maintenance内容也验证了前面的猜想。至此可以总结出Caffeine中引用淘汰、容量限制淘汰、缓存过期淘汰是由缓存的读写操作经过一定规则计算后触发的,这也解释了上述遗留的缓存过期淘汰发生时间问题。

void scheduleDrainBuffers() {
    if (drainStatus() >= PROCESSING_TO_IDLE) {
      return;
    }
    if (evictionLock.tryLock()) {
      try {
        int drainStatus = drainStatus();
        if (drainStatus >= PROCESSING_TO_IDLE) {
          return;
        }
        lazySetDrainStatus(PROCESSING_TO_IDLE);
        executor().execute(drainBuffersTask);
      } catch (Throwable t) {
        logger.log(Level.WARNING, "Exception thrown when submitting maintenance task", t);
        maintenance();
      } finally {
        evictionLock.unlock();
      }
    }
}
final class PerformCleanupTask extends ForkJoinTask<Void> implements Runnable {
    @Override
    public void run() {
      performCleanUp();
    }
}
void performCleanUp(@Nullable Runnable task) {
    evictionLock.lock();
    try {
      maintenance(task);
    } finally {
      evictionLock.unlock();
    }
}
void maintenance(@Nullable Runnable task) {
    lazySetDrainStatus(PROCESSING_TO_IDLE);
    try {
      drainReadBuffer();
      drainWriteBuffer();
      if (task != null) {
        task.run();
      }
      drainKeyReferences();
      drainValueReferences();
      expireEntries();
      evictEntries();
    } finally {
      if ((drainStatus() != PROCESSING_TO_IDLE) || !casDrainStatus(PROCESSING_TO_IDLE, IDLE)) {
        lazySetDrainStatus(REQUIRED);
      }
    }
}

2.4 总结

上述章节已经详细的介绍了Caffeine的一些核心能力及其实现原理,除此之外Caffeine还提供了异步刷新、弱引用key、弱软引用value、淘汰监听、打点监控等功能,由于篇幅关系这里也不再多做介绍。说了这么多,那到底Caffeine怎么用呢?是不是还要花时间去熟悉API?我想说这部分大家完全不必担心,因为Caffeine的API是完全参考Google Guava的,如果你有Guava的使用经验切换成本几乎为零,这里贴上一段Caffeine的Cache构建代码,大家可以发现是不是和Guava一模一样。

public static void main(String[] args) {
    Cache<String, Object> manualCache = Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .expireAfterAccess(1000, TimeUnit.SECONDS)
        .maximumSize(10000)
        .build();

    String key = "learning";
    manualCache.put(key, "caffeine");
    manualCache.get(key, e -> getFromMysql(key));
}
private static String getFromMysql(String key) {
    return "hha";
}

3 caffnine 使用

参考: cloud.tencent.com/developer/a… segmentfault.com/a/119000003…

Caffeine的依赖,其实还是很简单的,直接引入maven依赖即可。

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

Caffeine 借鉴了Guava Cache 的设计思想,如果之前使用过 Guava Cache,那么Caffeine 很容易上手,只需要改变相应的类名就行。构造一个缓存 Cache 示例代码如下:

Cache cache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(6, TimeUnit.MINUTES).softValues().build();

Caffeine 类相当于建造者模式的 Builder 类,通过 Caffeine 类配置 Cache,配置一个Cache 有如下参数:

  • expireAfterWrite:写入间隔多久淘汰;
  • expireAfterAccess:最后访问后间隔多久淘汰;
  • refreshAfterWrite:写入后间隔多久刷新,该刷新是基于访问被动触发的,支持异步刷新和同步刷新,如果和 expireAfterWrite 组合使用,能够保证即使该缓存访问不到、也能在固定时间间隔后被淘汰,否则如果单独使用容易造成OOM;
  • expireAfter:自定义淘汰策略,该策略下 Caffeine 通过时间轮算法来实现不同key 的不同过期时间;
  • maximumSize:缓存 key 的最大个数;
  • weakKeys:key设置为弱引用,在 GC 时可以直接淘汰;
  • weakValues:value设置为弱引用,在 GC 时可以直接淘汰;
  • softValues:value设置为软引用,在内存溢出前可以直接淘汰;
  • executor:选择自定义的线程池,默认的线程池实现是 ForkJoinPool.commonPool();
  • maximumWeight:设置缓存最大权重;
  • weigher:设置具体key权重;
  • recordStats:缓存的统计数据,比如命中率等;
  • removalListener:缓存淘汰监听器;
  • writer:缓存写入、更新、淘汰的监听器。

缓存填充策略

Caffeine Cache提供了三种缓存填充策略:手动、同步加载和异步加载。

1.手动加载

在每次get key的时候指定一个同步的函数,如果key不存在就调用这个函数生成一个值。

/**
* 手动加载
* @param key
* @return
*/
public Object manulOperator(String key) {
    Cache<String, Object> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        .build();
    //如果一个key不存在,那么会进入指定的函数生成value
    Object value = cache.get(key, t -> setValue(key).apply(key));
    cache.put("hello",value);

    //判断是否存在如果不存返回null
    Object ifPresent = cache.getIfPresent(key);
    //移除一个key
    cache.invalidate(key);
    return value;
}

public Function<String, Object> setValue(String key){
    return t -> key + "value";
}

2. 同步加载

构造Cache时候,build方法传入一个CacheLoader实现类。实现load方法,通过key加载value。

/**
* 同步加载
* @param key
* @return
*/
public Object syncOperator(String key){
    LoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build(k -> setValue(key).apply(key));
    return cache.get(key);
}

public Function<String, Object> setValue(String key){
    return t -> key + "value";
}

3. 异步加载

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

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

/**
* 异步加载
*
* @param key
* @return
*/
public Object asyncOperator(String key){
    AsyncLoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .buildAsync(k -> setAsyncValue(key).get());

    return cache.get(key);
}

public CompletableFuture<Object> setAsyncValue(String key){
    return CompletableFuture.supplyAsync(() -> {
        return key + "value";
    });
}

回收策略

Caffeine提供了3种回收策略:基于大小回收,基于时间回收,基于引用回收。

1. 基于大小的过期方式

基于大小的回收策略有两种方式:一种是基于缓存大小,一种是基于权重。

// 根据缓存的计数进行驱逐
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10000)
    .build(key -> function(key));


// 根据缓存的权重来进行驱逐(权重只是用于确定缓存大小,不会用于决定该缓存是否被驱逐)
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .maximumWeight(10000)
    .weigher(key -> function1(key))
    .build(key -> function(key));

maximumWeight与maximumSize不可以同时使用。

2.基于时间的过期方式

// 基于固定的到期策略进行退出
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .build(key -> function(key));
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> function(key));

// 基于不同的到期策略进行退出
LoadingCache<String, Object> cache2 = Caffeine.newBuilder()
    .expireAfter(new Expiry<String, Object>() {
        @Override
        public long expireAfterCreate(String key, Object value, long currentTime) {
            return TimeUnit.SECONDS.toNanos(seconds);
        }

        @Override
        public long expireAfterUpdate(@Nonnull String s, @Nonnull Object o, long l, long l1) {
            return 0;
        }

        @Override
        public long expireAfterRead(@Nonnull String s, @Nonnull Object o, long l, long l1) {
            return 0;
        }
    }).build(key -> function(key));

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

expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。 expireAfterWrite(long, TimeUnit): 在最后一次写入缓存后开始计时,在指定的时间后过期。 expireAfter(Expiry): 自定义策略,过期时间由Expiry实现独自计算。 缓存的删除策略使用的是惰性删除和定时删除。这两个删除策略的时间复杂度都是O(1)。

3. 基于引用的过期方式

Java中四种引用类型

引用类型被垃圾回收时间用途生存时间
强引用 Strong Reference从来不会对象的一般状态JVM停止运行时终止
软引用 Soft Reference在内存不足时对象缓存内存不足时终止
弱引用 Weak Reference在垃圾回收时对象缓存gc运行后终止
虚引用 Phantom Reference从来不会可以用虚引用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知JVM停止运行时终止
// 当key和value都没有引用时驱逐缓存
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> function(key));

// 当垃圾收集器需要释放内存时驱逐
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .softValues()
    .build(key -> function(key));

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

Caffeine.weakKeys():使用弱引用存储key。如果没有其他地方对该key有强引用,那么该缓存就会被垃圾回收器回收。由于垃圾回收器只依赖于身份(identity)相等,因此这会导致整个缓存使用身份 (==) 相等来比较 key,而不是使用 equals()。

Caffeine.weakValues() :使用弱引用存储value。如果没有其他地方对该value有强引用,那么该缓存就会被垃圾回收器回收。由于垃圾回收器只依赖于身份(identity)相等,因此这会导致整个缓存使用身份 (==) 相等来比较 key,而不是使用 equals()。

Caffeine.softValues() :使用软引用存储value。当内存满了过后,软引用的对象以将使用最近最少使用(least-recently-used ) 的方式进行垃圾回收。由于使用软引用是需要等到内存满了才进行回收,所以我们通常建议给缓存配置一个使用内存的最大值。softValues() 将使用身份相等(identity) (==) 而不是equals() 来比较值。

Caffeine.weakValues()和Caffeine.softValues()不可以一起使用。

移除事件监听

Cache<String, Object> cache = Caffeine.newBuilder()
    .removalListener((String key, Object value, RemovalCause cause) ->
                     System.out.printf("Key %s was removed (%s)%n", key, cause))
    .build();

写入外部存储

CacheWriter 方法可以将缓存中所有的数据写入到第三方。

LoadingCache<String, Object> cache2 = Caffeine.newBuilder()
    .writer(new CacheWriter<String, Object>() {
        @Override public void write(String key, Object value) {
            // 写入到外部存储
        }
        @Override public void delete(String key, Object value, RemovalCause cause) {
            // 删除外部存储
        }
    })
    .build(key -> function(key));

如果你有多级缓存的情况下,这个方法还是很实用。

注意:CacheWriter不能与弱键或AsyncLoadingCache一起使用。

统计

与Guava Cache的统计一样。

Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

通过使用Caffeine.recordStats(), 可以转化成一个统计的集合. 通过 Cache.stats() 返回一个CacheStats。CacheStats提供以下统计方法:

hitRate(): 返回缓存命中率
evictionCount(): 缓存回收数量
averageLoadPenalty(): 加载新值的平均时间

4 参考文献

Design Of A Modern Cache(Part1)

Design Of A Modern Cache(Part2)

Approximating Data with the Count-Min Data Structure

TinyLFU: A Highly Efficient Cache Admission Policy

github.com/ben-manes/c…

深入解密来自未来的缓存-Caffeine

你应该知道的缓存进化史

Cache Replacement Policies

Apache Kafka, Purgatory, and Hierarchical Timing Wheels