本篇文章我将介绍Caffeine缓存的具体有哪些功能,以及内部的实现原理,让大家知其然,也要知其所以然。有人会问:我不使用Caffeine这篇文章应该对我没啥用了,别着急,在Caffeine中的知识一定会对你在其他代码设计方面有很大的帮助。当然在介绍之前还是要贴一下他和其他缓存的一些比较图:
可以看见Caffeine基本从各个维度都是相比于其他缓存都高,废话不多说,首先还是先看看如何使用吧。
1.1如何使用
Caffeine使用比较简单,API和Guava Cache一致:
`public static void main(String[] args) {
Cache cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.expireAfterAccess(1,TimeUnit.SECONDS)
.maximumSize(10)
.build();
cache.put("hello","hello");
}`
2.Caffeine原理简介
2.1W-TinyLFU
传统的LFU受时间周期的影响比较大。所以各种LFU的变种出现了,基于时间周期进行衰减,或者在最近某个时间段内的频率。同样的LFU也会使用额外空间记录每一个数据访问的频率,即使数据没有在缓存中也需要记录,所以需要维护的额外空间很大。
可以试想我们对这个维护空间建立一个hashMap,每个数据项都会存在这个hashMap中,当数据量特别大的时候,这个hashMap也会特别大。
再回到LRU,我们的LRU也不是那么一无是处,LRU可以很好的应对突发流量的情况,因为他不需要累计数据频率。
所以W-TinyLFU结合了LRU和LFU,以及其他的算法的一些特点。
2.1.1频率记录
首先要说到的就是频率记录的问题,我们要实现的目标是利用有限的空间可以记录随时间变化的访问频率。在W-TinyLFU中使用Count-Min Sketch记录我们的访问频率,而这个也是布隆过滤器的一种变种。如下图所示:
这里和以前的做个对比,简单的举个例子:如果一个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的结构如下:
我们的4个段分为A,B,C,D,在后面我也会这么叫它们。而每个段里面的四个算法我叫他s1,s2,s3,s4。下面举个例子如果要添加一个访问50的数字频率应该怎么做?我们这里用size=100来举例。
- 首先确定50这个hash是在哪个段里面,通过hash & 3(3的二进制是11)必定能获得小于4的数字,假设hash & 3=0,那就在A段。
- 对50的hash再用其他hash算法再做一次hash,得到long数组的位置,也就是在长度128数组中的位置。假设用s1算法得到1,s2算法得到3,s3算法得到4,s4算法得到0。
- 因为S1算法得到的是1,所以在long[1]的A段里面的s1位置进行+1,简称1As1加1,然后在3As2加1,在4As3加1,在0As4加1。
这个时候有人会质疑频率最大为15的这个是否太小?没关系在这个算法中,比如size等于100,如果他全局提升了size*10也就是1000次就会全局除以2衰减,衰减之后也可以继续增加,这个算法再W-TinyLFU的论文中证明了其可以较好的适应时间段的访问频率。
2.2读写性能
在guava cache中我们说过其读写操作中夹杂着过期时间的处理,也就是你在一次Put操作中有可能还会做淘汰操作,所以其读写性能会受到一定影响,可以看上面的图中,caffeine的确在读写操作上面完爆guava cache。主要是因为在caffeine,对这些事件的操作是通过异步操作,他将事件提交至队列,这里的队列的数据结构是RingBuffer,不清楚的可以看看这篇文章,你应该知道的高性能无锁队列Disruptor。然后会通过默认的ForkJoinPool.commonPool(),或者自己配置线程池,进行取队列操作,然后在进行后续的淘汰,过期操作。
当然读写也是有不同的队列,在caffeine中认为缓存读比写多很多,所以对于写操作是所有线程共享一个Ringbuffer。
2.3数据淘汰策略
在caffeine所有的数据都在ConcurrentHashMap中,这个和guava cache不同,guava cache是自己实现了个类似ConcurrentHashMap的结构。在caffeine中有三个记录引用的LRU队列:
- Eden队列:在caffeine中规定只能为缓存容量的%1,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。比如有一部新剧上线,在最开始其实是没有访问频率的,防止上线之后被其他缓存淘汰出去,而加入这个区域。伊甸区,最舒服最安逸的区域,在这里很难被其他数据淘汰。
- Probation队列:叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为size减去eden减去protected。
- Protected队列:在这个队列中,可以稍微放心一下了,你暂时不会被淘汰,但是别急,如果Probation队列没有数据了或者Protected数据满了,你也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要把Probation访问一次之后,就会提升为Protected队列。这个有效大小为(size减去eden) X 80% 如果size =100,就会是79。
- 所有的新数据都会进入Eden。
- Eden满了,淘汰进入Probation。
- 如果在Probation中访问了其中某个数据,则这个数据升级为Protected。
- 如果Protected满了又会继续降级为Probation。
对于发生数据淘汰的时候,会从Probation中进行淘汰。会把这个队列中的数据队头称为受害者,这个队头肯定是最早进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,但是在这里只能叫他受害者,这个队列是缓刑队列,代表马上要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者皇城PK决出我们应该被淘汰的。
通过我们的Count-Min Sketch中的记录的频率数据有以下几个判断:
- 如果攻击者大于受害者,那么受害者就直接被淘汰。
- 如果攻击者<=5,那么直接淘汰攻击者。这个逻辑在他的注释中有解释:
- 他认为设置一个预热的门槛会让整体命中率更高。
- 其他情况,随机淘汰。
3.Caffeine功能剖析
在Caffeine中功能比较多,下面来剖析一下,这些API到底是如何生效的呢?
3.1 百花齐放-Cache工厂
在Caffeine中有个LocalCacheFactory类,他会根据你的配置进行具体Cache的创建。
首先他会进行加锁,如果锁失败说明有人已经在执行调度了。他会使用默认的线程池ForkJoinPool或者自定义线程池,这里的drainBuffersTask其实是Caffeine中PerformCleanupTask。3.2 转瞬即逝-过期策略
在Caffeine中分为两种缓存,一个是有界缓存,一个是无界缓存,无界缓存不需要过期并且没有界限。在有界缓存中提供了三个过期API:
- expireAfterWrite:代表着写了之后多久过期。
- expireAfterAccess: 代表着最后一次访问了之后多久过期。
- expireAfter:在expireAfter中需要自己实现Expiry接口,这个接口支持create,update,以及access了之后多久过期。注意这个API和前面两个API是互斥的。这里和前面两个API不同的是,需要你告诉缓存框架,他应该在具体的某个时间过期,也就是通过前面的重写create,update,以及access的方法,获取具体的过期时间。
在Caffeine中有个scheduleDrainBuffers方法,用来进行我们的过期任务的调度,在我们读写之后都会对其进行调用:
- 首先获取当前时间。
- 第二步,进行expireAfterAccess的过期:
这里根据我们的配置evicts()方法为true,所以会从三个队列都进行过期淘汰,上面已经说过了这三个队列都是LRU队列,所以我们的expireAfterAccessEntries方法,只需要把各个队列的头结点进行判断是否访问过期然后进行剔除即可。
- 第三步,是expireAfterWrite:
可以看见这里依赖了一个队列writeQrderDeque,这个队列的数据是什么时候填充的呢?当然也是使用异步,具体方法在我们上面的draninWriteBuffer中,他会将我们之前放进RingBuffer的Task拿出来执行,其中也包括添加writeQrderDeque。过期的策略很简单,直接循环弹出第一个判断其是否过期即可。
- 第四步,进行expireVariableEntries过期:
在Caffeine中的时间轮如上面所示。在我们插入数据的时候,根据我们重写的方法计算出他应该过期的时间,比如他应该在1536046571142时间过期,上一次处理过期时间是1536046571100,对其相减则得到42ms,然后将其放入时间轮,由于其小于1.07s,所以直接放入1.07s的位置,以及第二层的某个位置(需要经过一定的算法算出),使用尾插法插入链表。
处理过期时间的时候会算出上一次处理的时间和当前处理的时间的差值,需要将其这个时间范围之内的所有时间轮的时间都进行处理,如果某个Node其实没有过期,那么就需要将其重新插入进时间轮。
3.3.除旧布新-更新策略
Caffeine提供了refreshAfterWrite()方法来让我们进行写后多久更新策略:
上面的代码我们需要建立一个CacheLodaer来进行刷新,这里是同步进行的,可以通过buildAsync方法进行异步构建。在实际业务中这里可以把我们代码中的mapper传入进去,进行数据源的刷新。
注意这里的刷新并不是到期就刷新,而是对这个数据再次访问之后,才会刷新。举个例子:有个key:'咖啡',value:'拿铁' 的数据,我们设置1s刷新,我们在添加数据之后,等待1分钟,按理说下次访问时他会刷新,获取新的值,可惜并没有,访问的时候还是返回'拿铁'。但是继续访问的话就会发现,他已经进行了刷新了。
我们来看看自动刷新他是怎么做的呢?自动刷新只存在读操作之后,也就是我们afterRead()这个方法,其中有个方法叫refreshIfNeeded,他会根据你是同步还是异步然后进行刷新处理。
3.4 虚虚实实-软引用和弱引用
在Java中有四种引用类型:强引用(StrongReference)、软引用(SoftReference)、弱引用(WeakReference)、虚引用(PhantomReference)。
- 强引用:在我们代码中直接声明一个对象就是强引用。
- 软引用:如果一个对象只具有软引用,如果内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
- 弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
- 虚引用:如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。
3.4.1弱引用的淘汰策略
在Caffeine中支持弱引用的淘汰策略,其中有两个api: weakKeys()和weakValues(),用来设置key是弱引用还是value是弱引用。具体原理是在put的时候将key和value用虚引用进行包装并绑定至引用队列:
具体回收的时候,在我们前面介绍的maintenance方法中,有两个方法:
具体的处理的代码有:
因为我们的key已经被回收了,然后他会进入引用队列,通过这个引用队列,一直弹出到他为空为止。我们能根据这个队列中的运用获取到Node,然后对其进行驱逐。
注意:很多同学以为在缓存中内部是存储的Key-Value的形式,其实存储的是KeyReference - Node(Node中包含Value)的形式。
3.4.2 软引用的淘汰策略
在Caffeine中还支持软引用的淘汰策略,其api是softValues(),软引用只支持Value不支持Key。我们可以看见在Value的回收策略中有:
和key引用回收相似,但是要说明的是这里的引用队列,有可能是软引用队列,也有可能是弱引用队列。
3.5知己知彼-打点监控
在Caffeine中提供了一些的打点监控策略,通过recordStats()Api进行开启,默认是使用Caffeine自带的,也可以自己进行实现。 在StatsCounter接口中,定义了需要打点的方法目前来说有如下几个:
- recordHits:记录缓存命中
- recordMisses:记录缓存未命中
- recordLoadSuccess:记录加载成功(指的是CacheLoader加载成功)
- recordLoadFailure:记录加载失败
- recordEviction:记录淘汰数据
通过上面的监听,我们可以实时监控缓存当前的状态,以评估缓存的健康程度以及缓存命中率等,方便后续调整参数。
3.6有始有终-淘汰监听
有很多时候我们需要知道Caffeine中的缓存为什么被淘汰了呢,从而进行一些优化?这个时候我们就需要一个监听器,代码如下所示:
Cache
.removalListener(((key, value, cause) -> {
System.out.println(cause);
}))
.build();
在Caffeine中被淘汰的原因有很多种:
- EXPLICIT: 这个原因是,用户造成的,通过调用remove方法从而进行删除。
- REPLACED: 更新的时候,其实相当于把老的value给删了。
- COLLECTED: 用于我们的垃圾收集器,也就是我们上面减少的软引用,弱引用。
- EXPIRED: 过期淘汰。
- SIZE: 大小淘汰,当超过最大的时候就会进行淘汰。
当我们进行淘汰的时候就会进行回调,我们可以打印出日志,对数据淘汰进行实时监控。
4.最后
本文介绍了Caffeine的全部功能原理,其中的知识点涉及到:LFU,LRU,时间轮,Java的四种引用等等。如果你对Caffeine不感兴趣也没有关系,通过这些知识的介绍相信你也收获了不少。
最后打个广告,关于缓存系列基本也告一段落,如果还想了解更多,可以加入我的Java新手学习交流群:3907814 。