1. 导言

上一篇文章中我们对Jodd Cache的基础功能进行了实践,接下来就以源码的形式讲解底层原理。

2. 类结构解析

JoddCache的相关类只有10个左右,可见跟官方宣传的小巧相符合
JoddCacheGuide

每个类之间的继承实现关系
JoddHierarchy

  • 顶层接口Cache定义了Jodd Cache的所有功能接口
  • NoCacheCache接口的适配器,方便第三方扩展Cache使用的
  • AbstractCacheMap是一个抽象模板类,缓存模块的大部分核心功能均在该类中被定义,只定义少许抽象方法留给子类实现
  • LRUCacheFIFOCacheLFUCacheTimedCache便是根据不同的淘汰策略方法的具体实现类,都继承AbstractCacheMap

3. 详细讲解

3.1. Cache

Cache接口是Jodd Cache的顶层接口,其中定义了所有向外暴露的方法

JoddCache-Cache

3.2. AbstractCacheMap

AbstractCacheMap抽象类是最核心的类,JoddCache采用模板类设计模式,将核心方法同一封装在AbstractCacheMap类中,只定义抽象方法pruneCache()供子类实现。

3.2.1. CacheObject

CacheObject是AbstractCacheMap的内部类,该模型用于封装每个键值对,里面定义了描述键值对属性的诸多字段,比如最近访问时间、剩余过期时间和命中次数等。

JoddCache-CacheObject

  • 方法getObject()在返回原始值之前,重置最近访问时间,且命中数加1

3.2.2. put

存值方法

JoddCache-AbstractCacheMap-put

  • StampedLock是一种采用乐观锁的读写锁。在早前Jodd版本中,这里的锁采用悲观锁的读写锁ReentrantReadWriteLock,后来线上出现了写锁阻塞导致大量读请求积压的bug才升级。StampedLock是JDK8新增特性,不属于本片图文讨论的范畴,有兴趣的同学可以自行学习。
  • 将key-value和过期时间封装进CacheObject
  • 当缓存满载后,调用pruneCache()方法按照某种淘汰策略(根据具体实现类的实现)清理以释放空间。

3.2.3. get

取值方法

# JoddCache-AbstractCacheMap-get

  • 代码1处,缓存命中和未命中的数量统计
  • 代码2处,判断被访问缓存是否过期
  • 代码3处,若缓存已过期,清理掉
  • 代码4处,缓存被清理后的事件通知,在AbsstractCacheMap中的onRemove()是一个空方法体,第三方可以继承自行扩展

上面重点讲了CacheObject,putget三个方法,其余方法相对较简单,可以自行查阅。

3.3. FIFOCache

基于FIFO先进先出淘汰策略的缓存实现,继承自AbstractCacheMap

3.3.1. 构造器

FIFOCache-custructor

  • 如果不指定timeout,则使用默认值0,即不过期
  • 代码1处,FIFOCache的底层存储结构是LinkedHashMap,容量之所以设置成缓存容量+1,跟后面的因子1.0f有关,防止缓存到达cacheSizeLinkedHashMap进行扩容,所以加1使其大于阈值,抑制扩容。
  • 代码2处。LinkedHashMap的因子默认值是0.75f,即当数据量大到总容量的75%时便开始扩容防止满载后再进行扩容在性能上的不足。这里之所以要设置成1.0f,因为当JoddCache缓存到达cacheSize时,底层存储结构LinkedHashMap没有达到扩容阈值(cacheSize+1)*1.0,所以不会扩容,从而提高性能。
  • 代码3处,LinkedHashMap中属性accessOrder属性能够影响每次get后的元素重排序,这里设置成false就是为了抑制重排序,从而严格满足队列FIFO的特性。

这里简单讲解下LinkedHashMapaccessOrder属性

  • LinkedHashMapget方法
    LinkedHashMap-get

3.3.2. pruneCache方法

缓存元素淘汰方法,核心思想是“首先淘汰已过期的缓存,如果还是满载,再淘汰队首元素”。

FIFOCache-pruneCache

  • 代码1处,首先清理已过期元素
  • 代码2处,选出队首元素
  • 代码3处,如果经过清理过去元素后依然满载,则将队首元素清理掉

3.4. LRUCache

基于最近使用淘汰策略的缓存实现,继承自AbstractCacheMap

3.4.1. 构造器

LRUCache-custructor

  • 代码1处,设置LinkedHashMapaccessOrder属性为true,从3.3.1讲解得知,在访问LinkedHashMapget方法时,若accessOrder=true则触发缓存数据重排序,具体操作是将被命中元素放置到队尾,中间元素依次向队首移动一个位置。
    LinkedHashMap-afterNodeAccess

    所以,LRUCache在触发淘汰逻辑之前要先将元素根据最近使用情况排序的功能转交给了LinkedHashMap来实现,很巧妙的设计,值得学习。

  • 代码2处,是淘汰逻辑中的一环,后面讲解。

3.4.2. pruneCache

淘汰数据逻辑

LRUCache-pruneCache

从上面的代码中看出LRUCachepruneCache方法只是清理了过期元素,如果所有元素均没过期,且缓存满载了该怎么办呢?接下来讲解3.4.1中代码2处的功能,也是该问题的答案。

3.4.3. removeEldestEntry

当缓存当前数量大于cacheSize时,触发清理队首(最久)元素

LRUCache-removeEldestEntry

接下来讲解3.4.2的问题。

  • 在3.4.1讲解构造器章节中,在创建LinkedHashMap时重写了方法removeEldestEntry
  • removeEldestEntry方法在LinkedHashMap中被afterNodeInsertion方法引用。
    LinkedHashMap-afterNodeInsertion
    • 代码1处,当evict=true,且队首元首不为空,且removeEldestEntry返回true时,执行代码2删除的逻辑。
  • LinkedHashMap.afterNodeInsertion方法是重写了父类HashMapafterNodeInsertion方法,在HashMapputVal方法中调用了afterNodeInsertion方法。
    HashMap.putVal
  • 要想真正执行清理队首元素的动作,必须要满足上诉3个条件:
    • evict=trueHashMapput方法中默认evict属性为true,即开启驱逐模式。
    • 队首元首不为空。满载情况下队首元素确实不为空。
    • removeEldestEntry返回true。载回过头看LRUCache中重写的removeEldestEntry方法,假设cacheSize=10,那么LinkedHashMapcapacity=11,扩容阈值是11*1.0f=11。当向缓存中插入第11个元素时,首先在LRUCacheput方法中会调用pruneCache方法清理一次,根据前面讲解得知,清理逻辑知识清理过期元素,假设满载的所有元素均没过期。然后由LinkedHashMap结构put值,由于第11个元素并未超过阈值11,所以不会扩容,继续调用afterNodeInsertion,再调用removeEldestEntry,由于当前(size()=11) > (cacheSize=10),所以removeEldestEntry方法返回true,从而在LinkedHashMap.afterNodeInsertion方法中调用清除队首元素的代码,从而动态实现了“最近使用”策略的淘汰逻辑。

LRUCache的淘汰逻辑不是全部放在pruneCache方法中的,而是充分利用了JDK自带数据结构的相关特性加以实现,构思相当巧妙,特别是在创建LinkedHashMap是的capacity=cacheSize+1loadFactor=1.0f,读者需要反复阅读源码细细研读。

3.5. LFUCache

基于历史命中次数淘汰策略的缓存实现,继承自AbstractCacheMap

3.5.1. 构造器

LFUCache-constructor

  • 代码1处,LFUCaache底层数据结构是HashMap,且capacity=cacheSize+1

3.5.2. pruneCache

缓存满载时的淘汰数据逻辑。核心思想“首先删除过期元素,如果还是满载,根据历史命中次数删除次数最少的元素”。

LFUCache-pruneCache

  • 代码1处,清理已过期元素
  • 代码2处,筛选出历史命中次数最少的元素
  • 判断是否已有空余空间,如果有,直接返回
  • 清理掉历史命中次数最少的元素(在高并发情况下有可能清理到多个,看代码)

3.6. TimedCache

从命名就可以看出,TimedCache的淘汰策略与定时器相关。核心思想“启动一个定时器定时清理过期元素”。

3.6.1. 构造器

TimedCache-constructor

采用HashMap作为底层存储结构,且没指定capacity。所以会存在扩容的可能,所以TimedCache的性能相比前几种不高。

3.6.2. pruneCache

TimedCache.pruneCache

仅仅是清理过期元素

3.6.3. Timer

在使用TimedCache时,创建完成后,还需要手动启动定时器

TimedCache-Timer

  • 代码1处,调用方法schedulePrune启动定时清理任务
  • 代码2处,关闭定时任务

Jodd Cache之所以不把定时器的启动封装在代码里,而是暴露给用户调用,用意在于提供给用户两种选择。如果不启动定时器,则没有淘汰策略,数据一直生效,即便设置了timeout参数。这样灵活性更高。

4. 其他缓存实现

我们看到,Jodd Cache还有针对文件的缓存实现,分别是FileCache, FileLRUCacheFileLFUCache,相比于前面讲解的,只是存储对象是文件byte数组,其余都一模一样,读者可以自行阅读。

5. 总结

经过笔者详细的解读,相信大家已经对Jodd Cache有了深入的理解。特别是运用JDK自带API进行的深入扩展,很值得大家去研究和学习。最后,首尾呼应,Jodd Cache 简约而不简单的本地缓存解决方案。