设有设置缓存容量,那么节点的类型是PSWMS,k、v都是强引用类型的,而且每个节点都有在写队列WriteAccessOrderQueueu的前后指针,每次load出来的k/v搞成一个node放到WriteAccessOrderQueue的末尾,如果设置了缓存容量,那么还需要有个基于LRU策略的对类,那么这个队列就是LRU队列,为什么要搞个LRU队列呢,LRU解决的就是缓存容量不足的时候基于最近使用的策略将位与队头的元素给移出掉,然后并不是真正的移出,而是转移,转移到了probation区域,这块我先不发散说,先说说expireAfter的策略,按字面意思是说写到caffeine之后计算缓存过期时间,除了节点的类型还有个管理缓存的策略,叫做SSSMS(拿次说明)继承自BoundedLocalCache,SS是k和v都是强引用,SSS是有个状态技术器,主要记录缓存命中了多少,没有命中多少,缓存加载使用了多长时间,主要用来分析缓存的设计的优越性的,而MS是记录,缓存最大容量有多少,权重有多少,window区域的大小有多少,main区域有多少,main区域分为probation和protected的,还计算出了window区域的权重还有protected的权重,还有基于爬山算法的hitSample的采样率还有missSample未命中采样率,本次爬山算法需要调整的步长,上次的采样命中率是多少,主要是和本次相比命中率是增加还是减少,还有是否超过了阈值,来决定爬山是和上次一样的方向爬还是反方向,是大步爬山还是小不调整(主要是基于阈值)。那么基于容量的驱逐策略必定需要LRU的AccessOrderQueue队列了,window区域和probation还有protected区域的队列都是采用AccessOrderQueue的循环双向链表(方便移除节点和插入节点),在window区域的淘汰策略是LRU的,那么只需要AccessOrderQueue操作链表就可以了,而main区域的淘汰就比较复杂了,主要是基于TinyLFU的,那么就需要计数了,那这个计数就需要用到了FrequenceSketch了,它是内存压缩的基于4个哈希函数的以性能和低空间占用换取准确性的,不需要知道到底多少,只需要知道热不热就好了。
缓存的每次读写面对的数据结构需要时高性能的,除了访问ConcurrentHashMap,读需要操作StripedBuffer,写操作需要操作MpscGrowableArrayQueue,而StripedBuffer是基于分段的RingBuffer,所有的offer操作都是无锁的,二分段进一步降低了cas竞争的消耗cpu的资源,基于每一个段的RingBuffer,则采用了循环数组的方式,而且计算索引都是用位操作,速度极快,写操作的MpscGrowableArrayQueue,也是一个RingBuffer环形数组队列,而且都是采用了缓存行填充的方式进一步降低了缓存行污染,带来的额外cpu资源开销,而这个RingBuffer不单单是一个数组,而是在写操作远快于读操作的时候也就是达到了扩容阈值,会在数组某一个位置协商JUMP标记 ,并且扩容的时候也是无锁的,主要是在写指针低两位写一个扩容标记,那么其他线程首先看看这个标记如果在扩容就需要重新循环重试,直到扩容好了,然后在JUMP标记处的位置指向下一个RingBuffer的指针这样读操作再碰到这个JUMP标记的时候就跳到下一个数组,那么上一个RingBuffer没有人引用就被JVM回收了。这是直接面向用户主线程的读和写的数据结构。塞到这些数据结构后还不行,还需要做后续的lru队列调整,lfu计数,过期策略的判定,以及容量驱逐判定,爬山算法等的调整window和main区域的占比,这就需要异步机制来了,我们不能将这些个这么多的操作放在用户线程去执行,那样会极大的影响用户线程的延迟性,所谓Caffeine的低延时就是这么来的,前面说的两大数据结构MpscGrowableArrayQueue和StripedBuffer事高性能的关键,那么对于Caffene来说,读操作不需要触发异步线程的,写操作是需要的,因为写操作直接关系了缓存的容量,资源占比等关键性的东西,那么会采用一个线程池执行这个异步任务,对于写入操作,会封装一个AddTask,然后在maintenance方法中去将MpscGrowableArrayQueue和StripedBuffer出队也就是drain操作,说到异步任务的执行还有个设计上的技巧就是Caffeine的状态机设计,总共分为:Idle(任务空闲),Required(任务正在准备执行),Processing_To_Idle(任务执行完准备切换成Idle),还有Processing_To_Required(在执行的任务过程中,又有新任务来了)
idle->required 代表需要有任务需要执行
required->processing_to_idle ,执行任务后需要切换成idle
processing_to_idle->idle就一个任务并且没有其他任务抢占,就可以cas成Idle了
processing_to_idle->processing_to_required 在再执行任务过程中,又有其他任务来了
processing_to_required->required,上一个个任务执行完了,准备执行本次任务。
通过这个状态机的变迁,可以很好的在同一时刻,保证只有一个线程执行maintenance那一些的操作,保证一个线程执行也是MpscGrowableArrayQueue多生产者,单消费者非常契合的,而且也是低资源消耗的。
drainReadBuffer是从StripedBuffer取出来的,主要是给维护LRU队列,将被访问的节点unlink出来,放到队列尾部,然后给FrequenceSketch计数,还有个关键操作如果节点在probation,会将它从Probation摘掉,放到protected保护起来,而这个节点不会一直呆在protected的,它会通过爬山算法再次可能放到probation队列,而window区域的扩大还可能间这节点再次移到window区域的,也就是说一个节点不是一成不变的呆在某一个区域的,而是随着访问命中率和访问次数的变化动态调整已达到最佳命中率的!而最终的缓存淘汰不是基于LRU而是基于TinyLFU的频率PK的!在哪个区域淘汰呢,就是在probation区域,会借助FrequenceSketch算法统计的频率来决定看看哪个频率最低,把最低的给淘汰掉,如果节点的过期策略是自定义的,还需要借助时间轮重新寻找一个bucket塞进去,而时间轮结构的bucket也是一个带头节点的双向循环链表,便于插入和移除。以上是afterRead后的操作流程。
而afterWrite后的操作流程主要是操作WriteAccessOrderQueue,便于expireAfter策略的缓存过期淘汰,而自定义的也还是放到时间轮执行的
紧接着是expireEntries流程,开始了缓存过期策略的淘汰,expireAfter是操作于WriteAccessOrderQueue上的,先看看队头的,因为expireAfter是基于全局过期时间来的,而每次入队是基于FIFIO的,所以队头开始检查也是有必要的,就是拿队头的节点的wirteTime(在Node内部是基于Unsafe直接内存访问的很高效,Caffeine真是无所不用其极)和当前时间做个比较,大于了writeTime+exireAfterTime,就需要evictEntry了,注意很关键的一点是:过期策略是直接移除了的,和缓存淘汰的区别又很大的不同,而且处在accessQueue的三大区域window,probation,protected的节点总共加起来都会在writeAccessOrderQueue找到,所以writeAccessOrderQueue主要是提供expireAfter缓存过期淘汰的,就真的移除了,然后将window,probation,protected的区域权重减去这个节点的权重,然后在缓存储存ConucurrentHashMap里移除,在wirteOrderQueue里面移除,在window,probation,protected三大队列移除。我的思考是,正是有了缓存容量的设定才有window,probation,protected三大AccessOrderQueue的还有FrequenceSketch,如果没有缓存容量,就直接弄个writeAccessOrderQueue就可以了或者说是基于expireAfterAccess,只弄个window,而驱逐策略就需要probation和protected的访问队列了(基于TinyLFU)。所以缓存过期淘汰和缓存驱逐还是有区别的,过期淘汰就真的删除了,而驱逐就需要一定的策略了(LRU,三大访问队列,TinyLFU,爬山算法等的优化)。
基于expireAfterAccess还是一样从window,probation,protected中找到需要删除的节点,还是从头开始peekFirst,直到发现没有过期的。
如果是基于自定义时间的过期策略,就需要搞个时间轮算法,这里是advance,Caffeine的时间轮不像Netty那样有个后台线程不断的转动时间轮,而且Caffeine是5层时间轮,秒、分钟,小时,天,还有天再加上一些时间的轮子,Caffeine的时间轮是靠每次afterWrite后异步线程调度的,首先拿到上一次advance的时刻计算出一个时间片,然后拿到当前时刻计算出一个时间片,如果两者的差值比当前层级时间轮要多,那么就需要这个轮子的每个bucket都拿出来看看任务是否过期了,不断遍历每个bucket和每个bucet中链表的任务看看是否过期了,不管是何种策略过期的时间都会写到node的writeTime中的,这也给了每次访问主动过期的机会。如果节点没有过期说明这个任务在这个时间片的开始时间之后,就需要重新计算bucket,从最低一级时间轮开始看看是否可以放的进去。
接下来就是缓存驱逐策略了,先从window驱逐,如果window区域的权重比实际的window区域最大容量要大说明满了,就需要从头开始摘下来放到probation了,然后是看看probation的,从头拿到一个节点叫做victim,然后从队尾拿到一个节点叫做candidate,如果probation的权重比最大总容量要大说明满了,那么拿到victim和candidate做对比,如果candidate的反问频率比victim大就需要淘汰victim,就直接evictEntryes了直接干掉。否则就需要一个128分之1 的概率移除candidate,但是官方说明了这么一种情况:The maximum frequency is 15 and halved to 7 after a reset to age the history. An attack exploits that a hot candidate is rejected in favor of a hot victim. The threshold of a warm candidate reduces the number of random acceptances to minimize the impact on the hit rate. 这又是啥意思呢?
最终就是爬山算法了,首先根据当期那命中率和上次的命中率做个差值,看看符号,如果为负就反方向调整(缩小window),为正就正方向(扩大window),caffeine认为命中率的上升,必然带来了window区域也就是LRU策略的热点,扩大window区域正好可以应对这么一种情况。