• 1

  • 520

caffeine实现原理(源码分析)

智能的司机

我是老司机

1星期前

前言

上篇文章介绍了常用的缓存框架,了解了目前内存缓存框架性能最好的是Caffeine

本篇文章将详细从源码级讲解Caffeine内部实现原理,包含以下内容

  • 淘汰策略 tinyLFU
  • Caffeine 内部接口关系
  • load put invalidate 操作的原子性
  • 缓存过期策略解析

缓存淘汰算法

缓存淘汰算法的作用是在有限的资源内,尽可能识别出哪些数据在短时间会被重复利用,从而提高缓存的命中率

LRU (Least Recently Used)

该算法认为最近访问过的数据将来被访问的几率会更高,通常使用链表来实现

如果数据添加或被访问则把数据移动到链表的头部,链表的头部为热数据尾部则为冷数据,当数据满时淘汰尾部的数据

其实现方式比较简单,但是也带来了一些问题.如果存在数据遍历时会导致LRU命中率急剧下降,缓存污染情况比较严重

LRU算法并非一无是处,它在突发流量下表现良好

LFU (Least FrequentlyUsed)

该算法根据数据的历史访问频率来淘汰数据,其核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高

根据LFU的思想如果想要实现这个算法,需要额外去维护一套统计每个元素访问次数的逻辑,会造成内存资源的浪费

FIFO (First In First Out)

先进先出的思想,判断被存储的时间,离目前最远的数据优先被淘汰.实现起来比较简单

TinyLFU

Caffeine采用了一种结合LRU、LFU优点的算法: W-TinyLFU 特点:高命中率、低内存占用

在搞懂W-TinyLFU算法之前,首先了解一下TinyLFU算法

TinyLFU目的是为了解决传统LFU算法空间存储比较大的问题,它也是基于LFU算法

它可以在较大访问量的场景下替代LFU的数据统计部分,原理有些类似BloomFilter(布隆过滤器)

既然说到布隆过滤器那么这里就说下它的原理

BloomFilter是使用一个大的bit数组用来存储所有key的标记,每个key通过多次哈希取模计算来映射bit数组对应的bit位

如果key存在则将对应的bit位设置为1,这样就可以通过少量的存储空间进行大量的数据过滤

布隆过滤器不能够准确判断出某个key是否一定存在,但是它说不存在那就一定不存在

了解过hashmap原理的伙伴们就反应过来了,即两个不一样的key通过哈希取模后可能会得到一样的哈希槽

在缓存应用中,更多是用它来解决缓存穿透的问题

TinyLFU中把多个bit位看做一个整体,用于统计一个key的使用频率

该key是通过多次不同的哈希计算来映射多个不同的bit位,在读取时取映射地最小值作key的一个使用频率

在Caffeine中维护了一个4bit位的CountMinSketch用来记录key的使用频率

4-bit也就意味着统计的key最大使用频率为15,具体的实现逻辑可以参考源代码中的FrequencySketch类

如上图FrequencySketch类描述信息很明确的说明了

最大频率被限制在15(4位)和周期性的老化过程,所有元素的受欢迎程度减半

TinyLFU在应对突发流量时可能由于没有及时构建足够的频率数据来保证自己驻留在缓存中,从而导致缓存的命中率下降,为了解决这个问题产生了W-TinyLFU算法

W-TinyLFU

W-TinyLFU由窗口缓存和主缓存两部分组成,同时也是Caffeine采用的缓存策略

主缓存使用SLRU和TinyLFU回收策略,占用总缓存比例为99%

窗口缓存使用没有任何回收策略的LRU算法,占用总缓存比例为1%,窗口缓存用于应对突发流量的问题

Caffeine可以根据工作负载特性动态地调整窗口和主空间的大小

窗口缓存

窗口缓存内部包含两部分,大窗口和小窗口

如果新增数据频率高大窗口更受欢迎,新增数据频率低小窗口缓存就更受欢迎

主缓存

主缓存内部包含两个部分,Probation和Protected

Probation用于存相对比较冷的数据,占用主缓存20%空间

Protected用于存比较热的数据,它占用主缓存80%空间

数据可在这两部分空间里面相互转移

缓存淘汰的过程:

新添加的数据首先放入窗口缓存中,如果窗口缓存满了则从窗口缓存淘汰数据(候选人)

转移到主缓存Probation区域中,如果Probation也满了则从Probation区域淘汰数据(受害者)

接下来候选人和受害者根据TinyFLU记录的访问频率来决定去留

具体可以通过查看Caffeine框架中BoundedLocalCache类的admit方法,如图

步骤逻辑

1.首先获取候选人和受害者的访问频率 victimFreq=(受害者访问频率) candidateFreq=(候选人访问频率)

2.判断候选人大于受害者则淘汰受害者,否则接着往下走

3.判断候选人访问频率小于等于5则淘汰候选人.否则往下走

4.如果都不成立则随机淘汰

Caffeine内部接口关系

Caffeine的UML类图

查看源码时,可以先找到框架的依赖包,然后通过idea查看UML类图.这样可以比较清晰的看到接口与类的关系

可以发现Caffeine源码中有两个包和两个类,具体实现逻辑都在cache包下面,通过idea打开这个包的UML类图

选择1:1的方式看得更清楚一些

对于蓝色实线类名全大写的类可以忽略,它其实是动态生成的类.着重关注的是Cache、LocalCache、AsyncLoadingCache这三个顶级接口的具体实现

Caffeine build源码

当构建Caffeine缓存时,都是通过build方法进行构建的,使用什么样的加载方式也是通过build方法进行区分的

所以我们有必要看一下build方法里面是如何实现的

首先第一步,打开Caffeine类查看build方法

从Caffeine类我们可以发现这个build是一个重载方法,有可传参数和不传参数的两种实现

我们先来看一下无参build方法里面的内容,它伴随了一个三目运算符,最后会返回具体实例化的实现类

这些实现类是干嘛用的,我们先来看Caffeine的核心类图,大致了解Caffeine内部接口关系

可以看到首先有一个叫Cache的类,然后我们通过刚刚打开的UML类图,找到Cache返回按F4进去Cache类中去

Cache

Cache是一个接口主要提供了一些方法定义

LoadingCache

然后我们接着再看LoadingCache接口

LoadingCache继承于Cache接口,扩展了三个方法,get、getAll、refresh

可以分析出来Cache接口更像是一个map用来存放k value

而LoadingCache定义了加载和更新的机制,通过build方法传入的CacheLoader来操作这里面的具体条目

LocalManualCache

接着再看LocalManualCache,他也一样继承了Cache接口

LocalManualCache接口主要有两个实现类UnboundedLocalManualCache和BoundedLocalManualCache

UnboundedLocalManualCache

如果在没有配置任何回收策略的情况下则会使用UnboundedLocalManualCache,该实现也是最低限度的提供了缓存的功能

可以通过构造函数看到,它是初始化了一个容量为16的ConcurrentHashMap,同时它也提供了基本的状态计数器,移除监听器,编写器

由于它没有任何的主动回收策略所以它本质就是对ConcurrentHashMap进行操作

BoundedLocalManualCache

它是一个有回收策略的缓存实现,对于设置了回收策略的Caffeine都会对应到一个具体实现类,具体是由LocalCacheFactory来实现

可以看到里面只有一个newBoundedLocalCache方法

这个方法针对我们配置的每种情况都拼接了一个字符串,最终得到了一个对应的实现类全名然后通过类加载器对它进行加载

重举性的写法也是因为Caffeine对每种情况都做出了优化,这也是前面提到idea生成的UML类图那些类名全大写是怎么来的

最终会返回一个BoundedLocalCache,也就是我们最终要用到的一个实现类

Caffeine缓存分类

同步到异步,手动加载到自动加载,有界到无界这三个维度大致可以分为八种缓存类型

Caffeine操作的原子性

Caffeine的load,put和invalidate操作都是具有原子性的,意思就是这三个操作是互斥的

load和put是不能同时执行的,load和invalidate也是不能同时执行的,这么做的好处是可以保证结果完全符合预期

这种方式是和Guava不同的,Guava是不阻塞的

假设先调用load再调用invalidate,invalidate很快就执行完了,这时候load还没加载进去,执行结果就不符合预期了

说概念的话可能比较抽象,下面通过例子来演示一下

Guava示例

@SneakyThrows
public static void main(String[] args) {
    LoadingCache<String, String> cache = CacheBuilder.newBuilder()
        .maximumSize(5)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build(new CacheLoader<String, String>() {
            @SneakyThrows
            @Override
            public String load(String id) {
                // 加载时,睡眠一秒
                Thread.sleep(1000);
                return id + System.currentTimeMillis();
            }
        });

    // 异步线程加载
    new Thread(() -> {
        try {
            System.out.println("执行get");
            cache.get("key");
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }).start();

    // 异步线程移除
    new Thread(() -> {
        // 睡眠,让这个线程后执行
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行invalidate");
        cache.invalidate("key");
    }).start();

    // 按程序逻辑来说,我们应该拿到的结果是空map
    Thread.sleep(1200);
    System.out.println(cache.asMap());
}
复制代码

可以看到多线程情况下即使在调用get之后再调用invalidate方法,还是没有移除key,执行结果就不符合预期了

Caffeine示例

@SneakyThrows
public static void main(String[] args) {
    LoadingCache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(5)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build(key -> {
            // 加载时,睡眠一秒
            Thread.sleep(1000);
            return key + System.currentTimeMillis();
        });

    // 异步线程加载
    new Thread(() -> {
        System.out.println("执行get");
        cache.get("key");
    }).start();

    // 异步线程移除
    new Thread(() -> {
        // 睡眠,让这个线程后执行
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行invalidate");
        cache.invalidate("key");
    }).start();

    // 按程序逻辑来说,我们应该拿到的结果是空map
    Thread.sleep(1200);
    System.out.println(cache.asMap());
}
复制代码

可以看到使用Caffeine,即使是在多线程执行的情况下,也能保证执行结果符合预期

Caffeine保证原子性的实现原理其实很简单,它的存储使用了ConcurrentHashMap

利用了ConcurrentHashMap的node节点锁,invalidate操作对应的就是remove方法

Caffeine缓存过期策略解析

Caffeine有三种过期策略,接下来我们分析一下Caffeine是怎么主动进行缓存回收的

打开BoundedLocalCache类查看afterRead和afterWrite方法

可以看到afterRead和afterWrite方法都调用了scheduleDrainBuffers方法

再来看一下scheduleDrainBuffers方法

这个方法具体的作用其实就是用来过期任务调度的,我们可以看到它的实现是有加锁操作的

加锁成功后会通过线程池去调用drainBuffersTask任务,而drainBuffersTask所属的类是PerformCleanupTask

我们可以再看看PerformCleanupTask实现的run方法

可以看到run方法里面通过cache调用了performCleanUp方法,点进去再看看performCleanUp方法的具体实现

可以看到performCleanUp方法调用了maintenance方法时进行了加锁和解锁,再点进去查看maintenance方法的具体实现

这个方法里面有很多个方法,这些方法都是用来做具体回收的

void maintenance(@Nullable Runnable task) {
    lazySetDrainStatus(PROCESSING_TO_IDLE);

    try {
      // 读缓存用尽了
      drainReadBuffer();

      // 写缓存用尽了
      drainWriteBuffer();
      if (task != null) {
        task.run();
      }
        
      // key的引用用尽了
      drainKeyReferences();
      // value的引用用尽了
      drainValueReferences();

      // 达到过期时间了
      expireEntries();
      // 达到大小的限制了
      evictEntries();

      climb();
    } finally {
      if ((drainStatus() != PROCESSING_TO_IDLE) || !casDrainStatus(PROCESSING_TO_IDLE, IDLE)) {
        lazySetDrainStatus(REQUIRED);
      }
    }
  }
复制代码
免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

数据

520

相关文章推荐

未登录头像

暂无评论