缓存中间件Caffeine超详细源码解读

读源码是一件非常复杂、困难、枯燥的过程,这个复杂过程我给大家踩了,各位看官躺平看就行啦

初始化入口:

//典型的工厂模式,初始化一个caffeine对象
Caffeine.newBuilder();


@CheckReturnValue
public static Caffeine<Object, Object> newBuilder() {
  return new Caffeine<>(); //这里new 的是一个无参构造,内部一些属性是预先设置的
}


static final int UNSET_INT = -1;
//默认的扩容大小
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认过期时间,不过期
static final int DEFAULT_EXPIRATION_NANOS = 0;
//默认刷新时间,不刷新
static final int DEFAULT_REFRESH_NANOS = 0;
//这个参数主要用来表示是否使用原生配置,如果是false那么需要传入CaffeineSpec对象
//CaffeineSpec对象作用是支持对配置进行自定义解析然后用来初始化caffeine,多个配置用,分隔,配置的key和value用=号
boolean strictParsing = true;
//最大装载量,默认-1 无限大
long maximumSize = UNSET_INT;
long maximumWeight = UNSET_INT;
int initialCapacity = UNSET_INT;
long expireAfterWriteNanos = UNSET_INT;
long expireAfterAccessNanos = UNSET_INT;
long refreshAfterWriteNanos = UNSET_INT;
Cache<Long, Long> TAG_CIRCLE_CONFIG_CACHE = Caffeine.newBuilder()
        .maximumSize( 3000 ) //设置容量大小
        .expireAfterWrite( 1L, TimeUnit.HOURS ) //过期策略是最后一次写入后的一小时
        .softValues() //map的值软引用
        .removalListener( (o, o2, removalCause) -> log.info( " is {},val is {},caseMessage is {}", o, o2, removalCause )) //数据失效时的监听回调事件
        .build();

疑问点:

  1. 超过容量大小后是抛出异常还是进行数据淘汰?
  1. 如果10点写入了一个key=5的数据,10.30又写入一个key=5的数据,那么过期时间会被刷新嘛?
疑问点源码分析:

先将代码执行流程图画出来,这样再看源码会更加直观点

2个疑问点主要看下put方法的实现即可,下面是put方法的入口

/*
put会进入com.github.benmanes.caffeine.cache.BoundedLocalCache这个类中
这里可以看出来当有key相同时会执行覆盖操作
*/
@Override
public @Nullable V put(K key, V value) {
  return put(key, value, expiry(), /* onlyIfAbsent */ false);
}
Node类分析:
/*
整个node类是一个抽象类,里面大量的抽象方法,这个类实现了访问顺序和写入顺序接口
下面列举一些核心抽象方法,不全部展示了,实在太多
Node这个抽象类的实现类是非常多的,见下面的截图,主要核心是需要子类实现不同的put、get、以及过期方法,这里用到的就是策略模式
*/
@Nullable
public abstract K getKey();
public abstract Object getKeyReference();
@Nullable
public abstract V getValue();
Node实现类解析:

这个类初看还是比较难读懂的,因为用到了很多底层方法,平时写业务代码的时候不太使用

/*
这个类是其中一种的策略实现
*/
class FD<K, V> extends Node<K, V> implements NodeFactory<K, V> {
    //这个变量是一个静态final变量,初始化完成后不会改变,key的偏移量
    protected static final long KEY_OFFSET = UnsafeAccess.objectFieldOffset(FD.class, "key");
    //这个变量是一个静态final变量,初始化完成后不会改变,value的偏移量
    protected static final long VALUE_OFFSET = UnsafeAccess.objectFieldOffset(FD.class, "value");
    volatile WeakKeyReference<K> key;
    volatile SoftValueReference<V> value;
    /*
    上面提到的两种偏移量作用是为了通过UNSAFE类来设置对象的字段值和获取对象的字段值,那么为什么要用UNSAFE类来操作呢,我认为主要原因是性能和越权。
    性能:
        1.这个类是java最底层的类如果使用java包装过的对象来操作可能性能有损失
        2.可以更加灵活的控制是否需要内存屏障和指令重排
    越权:
        这个类反射后对于反射对象的操作是可以越权的,无论字段是不是private,但是必须要知道操作字段的偏移量,这时候上面两个在对象初始化时就固定下来的偏移量就有用了
    每次put都是新new Node(),这样可以确保不同Node内部固定的偏移量是不同的
    */
    
    /*
    下面是两种类的初始化方式,主要区别在于K的类型到底是普通键还是引用对象
    普通键:
        key会进行hashcode计算(利用java底层计算方法System.identityHashCode),并且初始化一个弱引用对象,里面有个hashcode属性值等于刚刚计算出来的code值
    引用对象键:
        利用UNSAFE.putObject 方法将对象添加到内存地址中,偏移量是预先固定计算好的
    */
    FD(K key, ReferenceQueue<K> keyReferenceQueue, V value, ReferenceQueue<V> valueReferenceQueue, int weight, long now) {
        this(new WeakKeyReference(key, keyReferenceQueue), value, valueReferenceQueue, weight, now);
    }
    FD(Object keyReference, V value, ReferenceQueue<V> valueReferenceQueue, int weight, long now) {
        UnsafeAccess.UNSAFE.putObject(this, KEY_OFFSET, keyReference);
        UnsafeAccess.UNSAFE.putObject(this, VALUE_OFFSET, new SoftValueReference(keyReference, value, valueReferenceQueue));
    }

    /*
    下面几个方法都是通过内存偏移量获取对象的key和值
    */
    public final K getKey() {
        return ((Reference)UnsafeAccess.UNSAFE.getObject(this, KEY_OFFSET)).get();
    }
    public final Object getKeyReference() {
        return UnsafeAccess.UNSAFE.getObject(this, KEY_OFFSET);
    }
    public final V getValue() {
        return ((Reference)UnsafeAccess.UNSAFE.getObject(this, VALUE_OFFSET)).get();
    }
    public final Object getValueReference() {
        return UnsafeAccess.UNSAFE.getObject(this, VALUE_OFFSET);
    } 
}
put方法:

搞明白Node对象后,开始吃正菜,由于put方法很长涉及方法也很多,所以我们一部分一部分来解析

/*
大家看到这2行代码有没有发出新的疑问,上面Node方法中的getKey/getValue 用的是@Nullable注解,为什么在put的时候又判空呢,并且如果是null的话会直接抛出空指针异常,这是为什么呢?

其实个人认为是内存屏障的原因,首先上面说过Node这个抽象类实现的策略非常多,不同策略对于getKey和getValue实现逻辑也不相同,有些实现读取getKey和getValue的时候是工作内存读取、有些实现读取getKey和getValue是主内存读取,就可能出现null的情况,所以读取时可以出现null,但是put的时候不能
*/
requireNonNull(key);
requireNonNull(value);

Node<K, V> node = null;
/*
乍一看这是一个很简单的代码容易忽略,但是其实非常关键的一个变量,用来初始化当前时间的纳秒数,Ticker对象可以理解为一个初始时钟,这个时钟又包含了相当多的实现类,不一定都是采用java系统方式获取纳秒所以有很多实现,这个now变量就是后面过期策略生效时的核心参考指标
*/
long now = expirationTicker().read();

int newWeight = weigher.weigh(key, value);

put中难啃的代码逻辑

/*
为什么这里采用无限循环呢,命名是put一次为啥需要无限循环,其实原因是因为下面的代码用到的异步线程,通过无限循环判断异步线程是否都完成再来return出无限循环。
那么我又有新的疑问了:为什么不用java原生的fork-join 方式来判断子线程是否都完成呢,这样代码不是更加优美么?让我们带着这个疑问继续看下去先
*/
for (;;) {
  /*
  这里的data是java的ConcurrentHashMap<Object, Node<K, V>> data,在类初始化的时候这个map也完成了初始化,data = new ConcurrentHashMap<>(builder.getInitialCapacity());
  nodeFactory.newLookupKey(key) 是获取当前key的hashcode值,有3中策略实现,但是源码看了下三种策略实现的逻辑是一样的,都是调用System.identityHashCode获取code值
  */
  Node<K, V> prior = data.get(nodeFactory.newLookupKey(key));
  if (prior == null) {
    if (node == null) {
      //走到这步代表当前put的key之前不存在,那么需要初始化一次Node
      node = nodeFactory.newNode(key, keyReferenceQueue(),
          value, valueReferenceQueue(), newWeight, now);
      /*
      下面这行代码主要是设置数据淘汰的基准时间,如果初始化的是BoundedLocalCache那么这行代码不生效,因为默认是false,如果是其他Cache实现类,那么这行代码就会初始化一个时间轮TimeWheel,并且计算expireAfterCreate首次写入时间之后多少时间失效,但是这个写入时间不一定是数据put时间,前面已经说过有个参数是Long now代表起始时间是可以自定义的
      看下expireAfterCreate的代码:
long expireAfterCreate(@Nullable K key, @Nullable V value, Expiry<K, V> expiry, long now) {
  if (expiresVariable() && (key != null) && (value != null)) {
    long duration = expiry.expireAfterCreate(key, value, now);
    return isAsync ? (now + duration) : (now + Math.min(duration, MAXIMUM_EXPIRY));
  }
  return 0L;
}
      expireAfterCreate 计算逻辑:
          1.一共存在了7种实现类,大多数实现类long duration = 当前纳秒数
          2.duration并不是最终的时效时间,最终的时效时间需要再次判断isAsync
      这段代码可以总结我们之前的疑问了,在put时如果我们没有进行任何特殊参数去控制put的逻辑,默认对同一个key进行再次续期,如果我们设定了每次put的now都是固定原始值,那么这个时候key会覆盖但是过期时间不在刷新
      */
      setVariableTime(node, expireAfterCreate(key, value, expiry, now));
    }
    /*
    这行代码开始看的时候会容易看不明白,为什么要put一个key的引用,不直接put一个值呢?其实仔细看上面源码分析说明的话可以看到node.getKeyReference()获取的是key的node对象,那么这行代码的意义在于先看下key的Node对象是否还存在,为什么需要看呢?
    原因:
        1.key的node对象初始化的时候用的是弱引用
        2.val的node对象初始化的时候用的是软引用
    所以有可能已经被回收了,那么需要再次确认
    */
    prior = data.putIfAbsent(node.getKeyReference(), node);
    if (prior == null) {
      /*
      prior=null的原因:
          key第一次put不存在 OR 已经被回收了,new AddTask(node, newWeight)会添加一个runnable,这个runnable主要作用是添加一个异步的while(true)循环来判断如果node存活,那么会不断调整几个双端队列的存储这个node的顺序,这个顺序对不同过期失效策略有不同作用
      prior!= null的原因:
          这个key之前已经存在过了 AND 生命周期还存在
      */
      afterWrite(new AddTask(node, newWeight));
      return null;
    } else if (onlyIfAbsent) {
      /*
      如果onlyIfAbsent=true,这个时候会获取之前的值进行访问计数,但是不做热点计数,这个时候会拿之前key的值返回并且退出这个无限循环
      */
      V currentValue = prior.getValue();
      if ((currentValue != null) && !hasExpired(prior, now)) {
        if (!isComputingAsync(prior)) {
          tryExpireAfterRead(prior, key, currentValue, expiry(), now);
          setAccessTime(prior, now);
        }
        afterRead(prior, now, /* recordHit */ false);
        return currentValue;
      }
    }
  } else if (onlyIfAbsent) {
    //这个处理逻辑和上面一样,只是对应的if判断不一样
    V currentValue = prior.getValue();
    if ((currentValue != null) && !hasExpired(prior, now)) {
      if (!isComputingAsync(prior)) {
        tryExpireAfterRead(prior, key, currentValue, expiry(), now);
        setAccessTime(prior, now);
      }
      afterRead(prior, now, /* recordHit */ false);
      return currentValue;
    }
  } else {
    /*
    走到这里代表之前的key已经存在,并且需要进行覆盖操作,onlyIfAbsent=false
    这个方法内部主要是删除当前key的线程池数据,也就代表删除当前key异步任务
    @Nullable volatile ConcurrentMap<Object, CompletableFuture<?>> refreshes;
    void discardRefresh(Object keyReference) {
      var pending = refreshes;
      if (pending != null) {
        pending.remove(keyReference);
      }
    }
    */
    discardRefresh(prior.getKeyReference());
  }
  //走到这里代表这个key已经存在,并且onlyIfAbsent=false,那么下面代码做的事情就是拿新的值替换老的值,但是由于存在并发影响,所以替换值的过程也是相对复杂的
  V oldValue;
  long varTime;
  int oldWeight;
  boolean expired = false;
  boolean mayUpdate = true;
  boolean exceedsTolerance = false;
  //先锁定原始Node对象
  synchronized (prior) {
    /*
    如果node已经不在存活,那么继续下一次循环,node为什么会不存活了呢?
    原因是可能失效时间到了,那么继续下一次循环的时候就不会走到这里了,而是在上面就会直接put结束
    */
    if (!prior.isAlive()) {
      continue;
    }
    //取出原始值,后面需要进行CAS
    oldValue = prior.getValue();
    oldWeight = prior.getWeight();
    //如果value失效 OR 软引用回收,那么重新计算有效时间
    if (oldValue == null) {
      varTime = expireAfterCreate(key, value, expiry, now);
      //初始化RemovalCause.COLLECTED,初始化新的收集器
      notifyEviction(key, null, RemovalCause.COLLECTED);
    } else if (hasExpired(prior, now)) {
      expired = true;
      varTime = expireAfterCreate(key, value, expiry, now);
      //将老的value有效期重新设置成无效,避免在CAS期间老的val突然失效node不存在了
      notifyEviction(key, oldValue, RemovalCause.EXPIRED);
    } else if (onlyIfAbsent) {
      mayUpdate = false;
      varTime = expireAfterRead(prior, key, value, expiry, now);
    } else {
      //更新下key的更新有效期时间
      varTime = expireAfterUpdate(prior, key, value, expiry, now);
    }
}

经过上面Put代码的分析,疑问点已经排除:

  1. 不做任何特殊初始化的话,同一个key在put的时候有效期也会刷新

  1. 超过设置的最大容量时,如果没有可失效key,此时会直接抛出异常

key过期的核心实现:

guava的loading cache是使用lru的淘汰策略, 但是很多场景最近的数据不一定热,反而容易把稍旧的热数据挤出去,所以最好还是能统计访问次数得到数据的热度。

基本原理:对一个key会取他的hash值,找到对应位置,然后累加得到访问次数。

问题1:hash会冲突

解决:如果用hashmap的方式,相同的下标变成链表,这种方式会占用很大的内存,而且速度也不是很快。 其实一个hash函数会冲突是比较低的,我多搞4个hash函数,4个都冲突的概率就微乎其微了。取这4个hash函数对应值的最小的那个,基本就是访问次数了。

问题2:用4个hash函数会存访问次数,那空间就是4倍了。怎么优化呢

解决:访问次数超过15次其实是很热的数据了,没必要存太大的数字。所以用4位就可以存到15了。一个long有64位,可以存16个4位。而且hash冲突的概念和数组的大小也正相关,一个long 是64位,除以4个hash,在除以4位,一个long对应的数组大小其实是容量的4倍了。进一步降低了冲突的概率。

public void increment(E e) {
  if (isNotInitialized()) {
    return;
  }

  int hash = spread(e.hashCode());
  int start = (hash & 3) << 2;

  //对同一个key的四个hash都增加次数,然后再取最小的那个做输出值
  int index0 = indexOf(hash, 0);
  int index1 = indexOf(hash, 1);
  int index2 = indexOf(hash, 2);
  int index3 = indexOf(hash, 3);

  boolean added = incrementAt(index0, start);
  added |= incrementAt(index1, start + 1);
  added |= incrementAt(index2, start + 2);
  added |= incrementAt(index3, start + 3);

  if (added && (++size == sampleSize)) {
    reset();
  }
}

这个思路真的很巧妙,既避免为了记录访问次数而进行很大的空间开销,也解决了性能的查询问题

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菠萝-琪琪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值