深刻解密来自将来的缓存-Caffeine

1.前言

读这篇文章以前但愿你能好好的阅读: 你应该知道的缓存进化史如何优雅的设计和使用缓存? 。这两篇文章主要从一些实战上面去介绍如何去使用缓存。在这两篇文章中我都比较推荐Caffeine这款本地缓存去代替你的Guava Cache。本篇文章我将介绍Caffeine缓存的具体有哪些功能,以及内部的实现原理,让你们知其然,也要知其因此然。有人会问:我不使用Caffeine这篇文章应该对我没啥用了,别着急,在Caffeine中的知识必定会对你在其余代码设计方面有很大的帮助。固然在介绍以前仍是要贴一下他和其余缓存的一些比较图:web

 

能够看见Caffeine基本从各个维度都是相比于其余缓存都高,废话很少说,首先仍是先看看如何使用吧。

 

1.1如何使用

Caffeine使用比较简单,API和Guava Cache一致:算法

public static void main(String[] args) {
        Cache<String, String> 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也会使用额外空间记录每个数据访问的频率,即便数据没有在缓存中也须要记录,因此须要维护的额外空间很大。api

能够试想咱们对这个维护空间创建一个hashMap,每一个数据项都会存在这个hashMap中,当数据量特别大的时候,这个hashMap也会特别大。数组

再回到LRU,咱们的LRU也不是那么一无可取,LRU能够很好的应对突发流量的状况,由于他不须要累计数据频率。缓存

因此W-TinyLFU结合了LRU和LFU,以及其余的算法的一些特色。bash

2.1.1频率记录

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

若是须要记录一个值,那咱们须要经过多种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。

 

 

 

 

 

这里和之前的作个对比,简单的举个例子:若是一个hashMap来记录这个频率,若是我有100个数据,那这个HashMap就得存储100个这个数据的访问频率。哪怕我这个缓存的容量是1,由于Lfu的规则我必须所有记录这个100个数据的访问频率。若是有更多的数据我就有记录更多的。app

在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来举例。

 

  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。

 

 

这个时候有人会质疑频率最大为15的这个是否过小?不要紧在这个算法中,好比size等于100,若是他全局提高了size*10也就是1000次就会全局除以2衰减,衰减以后也能够继续增长,这个算法再W-TinyLFU的论文中证实了其能够较好的适应时间段的访问频率。

2.2读写性能

在guava cache中咱们说过其读写操做中夹杂着过时时间的处理,也就是你在一次Put操做中有可能还会作淘汰操做,因此其读写性能会受到必定影响,能够看上面的图中,caffeine的确在读写操做上面完爆guava cache。主要是由于在caffeine,对这些事件的操做是经过异步操做,他将事件提交至队列,这里的队列的数据结构是RingBuffer,不清楚的能够看看这篇文章,你应该知道的高性能无锁队列Disruptor。而后会经过默认的ForkJoinPool.commonPool(),或者本身配置线程池,进行取队列操做,而后在进行后续的淘汰,过时操做。

固然读写也是有不一样的队列,在caffeine中认为缓存读比写多不少,因此对于写操做是全部线程共享一个Ringbuffer。

 

 

对于读操做比写操做更加频繁,进一步减小竞争,其为每一个线程配备了一个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。

这三个队列关系以下:

 

 

  1. 全部的新数据都会进入Eden。
  2. Eden满了,淘汰进入Probation。
  3. 若是在Probation中访问了其中某个数据,则这个数据升级为Protected。
  4. 若是Protected满了又会继续降级为Probation。

对于发生数据淘汰的时候,会从Probation中进行淘汰。会把这个队列中的数据队头称为受害者,这个队头确定是最先进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,可是在这里只能叫他受害者,这个队列是缓刑队列,表明立刻要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者皇城PK决出咱们应该被淘汰的。

 

经过咱们的Count-Min Sketch中的记录的频率数据有如下几个判断:

 

  • 若是攻击者大于受害者,那么受害者就直接被淘汰。
  • 若是攻击者<=5,那么直接淘汰攻击者。这个逻辑在他的注释中有解释: 他认为设置一个预热的门槛会让总体命中率更高。
  • 其余状况,随机淘汰。

3.Caffeine功能剖析

在Caffeine中功能比较多,下面来剖析一下,这些API究竟是如何生效的呢?

3.1 百花齐放-Cache工厂

在Caffeine中有个LocalCacheFactory类,他会根据你的配置进行具体Cache的建立。

 

能够看见他会根据你是否配置了过时时间,remove监听器等参数,来进行字符串的拼装,最后会根据字符串来生成具体的Cache,这里的Cache太多了,做者的源码并无直接写这部分代码,而是经过Java Poet进行代码的生成:

 

 

 

3.2 转瞬即逝-过时策略

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

  • expireAfterWrite:表明着写了以后多久过时。
  • expireAfterAccess: 表明着最后一次访问了以后多久过时。
  • expireAfter:在expireAfter中须要本身实现Expiry接口,这个接口支持create,update,以及access了以后多久过时。注意这个API和前面两个API是互斥的。这里和前面两个API不一样的是,须要你告诉缓存框架,他应该在具体的某个时间过时,也就是经过前面的重写create,update,以及access的方法,获取具体的过时时间。

在Caffeine中有个scheduleDrainBuffers方法,用来进行咱们的过时任务的调度,在咱们读写以后都会对其进行调用:

 

 

首先他会进行加锁,若是锁失败说明有人已经在执行调度了。他会使用默认的线程池ForkJoinPool或者自定义线程池,这里的drainBuffersTask实际上是Caffeine中PerformCleanupTask。

 

 

 

在performCleanUp方法中再次进行加锁,防止其余线程进行清理操做。而后咱们进入到maintenance方法中:

 

 

 

能够看见里面有挺多方法的,其余方法稍后再讨论,这里咱们重点关注expireEntries(),也就是用来过时的方法:

 

 

  • 首先获取当前时间。
  • 第二步,进行expireAfterAccess的过时:

 

 

 

这里根据咱们的配置evicts()方法为true,因此会从三个队列都进行过时淘汰,上面已经说过了这三个队列都是LRU队列,因此咱们的expireAfterAccessEntries方法,只须要把各个队列的头结点进行判断是否访问过时而后进行剔除便可。

 

  • 第三步,是expireAfterWrite:

 

能够看见这里依赖了一个队列writeQrderDeque,这个队列的数据是何时填充的呢?固然也是使用异步,具体方法在咱们上面的draninWriteBuffer中,他会将咱们以前放进RingBuffer的Task拿出来执行,其中也包括添加writeQrderDeque。过时的策略很简单,直接循环弹出第一个判断其是否过时便可。

 

  • 第四步,进行expireVariableEntries过时:

 

在上面的方法中咱们能够看见,是利用时间轮,来进行过时处理的,时间轮是什么呢?想必熟悉一些定时任务系统对其并不陌生,他是一个高效的处理定时任务的结构,能够简单的将其看作是一个多维数组。在Caffeine中是一个二层时间轮,也就是二维数组,其一维的数据表示较大的时间维度好比,秒,分,时,天等,其二维的数据表示该时间维度较小的时间维度,好比秒内的某个区间段。当定位到一个TimeWhile[i][j]以后,其数据结构实际上是一个链表,记录着咱们的Node。在Caffeine利用时间轮记录咱们在某个时间过时的数据,而后去处理。

 

 

 

在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引用的
drainKeyReferences();
//处理value引用
drainValueReferences();
复制代码

具体的处理的代码有:

 

由于咱们的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<String, String> cache = Caffeine.newBuilder()
                .removalListener(((key, value, cause) -> {
                    System.out.println(cause);
                }))
                .build();
复制代码

在Caffeine中被淘汰的缘由有不少种:

  • EXPLICIT: 这个缘由是,用户形成的,经过调用remove方法从而进行删除。
  • REPLACED: 更新的时候,其实至关于把老的value给删了。
  • COLLECTED: 用于咱们的垃圾收集器,也就是咱们上面减小的软引用,弱引用。
  • EXPIRED: 过时淘汰。
  • SIZE: 大小淘汰,当超过最大的时候就会进行淘汰。

当咱们进行淘汰的时候就会进行回调,咱们能够打印出日志,对数据淘汰进行实时监控。

做者:公众号_咖啡拿铁 连接:https://juejin.im/post/5b8df63c6fb9a019e04ebaf4 来源:掘金 著做权归做者全部。商业转载请联系做者得到受权,非商业转载请注明出处。
相关文章
相关标签/搜索