简约而不简单的本地缓存解决方案

Jodd导览

1. 导言

  • 缓存解决方案一般都具备几个核心功能:存值,取值,设置过期时间,值淘汰策略,命中率统计等。
  • 说到本地缓存解决方案,目前较为有名的该数Google的guava框架中的缓存了,不可否认,Google的缓存解决方案首先是金字塔顶端的程序员开发,且经过了Google数年项目实战。但是guava框架晦涩难懂的编码规范和大量使用设计模式,给想一探究竟的同学们带来了极大的困难。
  • Jodd Cache是开源项目Jodd中核心模块core中的一个子模块,运用Java中的常用数据结构、线程锁和设计模式构建了一套轻巧且功能强大的缓存框架,其源码相比于guava更容易阅读。下面,就跟着笔者一探究竟吧。

2. 缓存体系中常用的淘汰策略

参考:https://www.cnblogs.com/s-b-b/p/6047954.html

2.1. FIFO

First In First Out 先进先出策略,典型的队列特性。

  • 采用双向链表存储数据
  • 新加入的数据存放在队尾
  • 当队列满载时,将队首的元素剔除来释放空间

2.2. LRU

Least Recently Used 最近最少使用策略,该算法的核心思想是“如果数据最近被访问,那么将来被访问的概率也很大”。

  • 新加入数据存放在队尾位置
  • 每当某条缓存命中后,将该条数据移动到队尾位置
  • 当缓存满载后,删除队首数据释放空间

2.3. LFU

Least Frequently Used 使用频次策略,该算法的核心思想是“如果数据的历史访问次数高,那么将来被访问的概率也大”。

  • 新加入数据存放在队尾,此时初始命中次数为1
  • 当数据被访问命中时,命中次数加1,且触发队列元素重排序,队首命中次数最高
  • 当队列满载时,直接清除队尾元素释放空间

3. 实战

3.1. FIFOCache

  • 存、取和过期测试:

    @Test
    public void cacheTest() throws InterruptedException {
    // 设置容量为1,默认过期时长为2秒的FIFOCache
    FIFOCache<String, String> cache = new FIFOCache<>(1, 2000); ①

    cache.put("name", "zhangsan");// 默认2秒过期时间
    LOGGER.info("1. name = {}", cache.get("name"));// 未过期
    TimeUnit.SECONDS.sleep(1); // ②
    LOGGER.info("2. name = {}", cache.get("name"));// 未过期
    TimeUnit.SECONDS.sleep(3); // ③
    LOGGER.info("3. name = {}", cache.get("name"));// 已过期

    cache.put("age", "3", 5000);// ④指定过期时间5秒
    LOGGER.info("4. age = {}", cache.get("age"));// 未过期
    TimeUnit.SECONDS.sleep(4);// ⑤
    LOGGER.info("5. age = {}", cache.get("age"));// 未过期
    TimeUnit.SECONDS.sleep(6);// ⑥
    LOGGER.info("6. age = {}", cache.get("age"));// 已过期
    }

    代码解析:

    • 代码①处创建了一个容量为1,默认过期时间是2000毫秒的FIFOCache缓存数据结构
    • 代码②处,由于缓存"name"是采用默认过期时间,所以睡眠1秒后数据未过期
    • 代码③处,只要被get命中一次,过期时间被重置,所以睡眠3秒后缓存过期
    • 代码处新增缓存"age",且手动指定过期时间是5000毫秒
    • 代码⑤处睡眠4秒,数据未过期
    • 代码⑥处睡眠6秒,数据已过期

    输出:

    code.xxxxxxx.com.jodd.JoddTest - 1. name = zhangsan
    code.xxxxxxx.com.jodd.JoddTest - 2. name = zhangsan
    code.xxxxxxx.com.jodd.JoddTest - 3. name = null
    code.xxxxxxx.com.jodd.JoddTest - 4. age = 3
    code.xxxxxxx.com.jodd.JoddTest - 5. age = 3
    code.xxxxxxx.com.jodd.JoddTest - 6. age = null

  • 淘汰策略测试

    @Test
    public void pruneCacheTest(){
    FIFOCache<String, String> cache = new FIFOCache<>(1);// ①

    cache.put("name", "zhangsan");// ②
    LOGGER.info("1. name = {}", cache.get("name"));

    cache.put("age", "3");// ③
    LOGGER.info("2. name = {}", cache.get("name"));// ④
    LOGGER.info("3. age = {}", cache.get("age"));
    }

    代码解析:

    • 代码①处,创建容量为1,且不会过期的FIFOCache缓存数据结构
    • 代码②处将"name"放入缓存
    • 代码③处将"age"放入缓存
    • 代码④处,由于容量为1,name被淘汰,所以为null,从而也验证FIFO的淘汰策略

    输出:

    code.xxxxxxx.com.jodd.JoddTest - 1. name = zhangsan
    code.xxxxxxx.com.jodd.JoddTest - 2. name = null
    code.xxxxxxx.com.jodd.JoddTest - 3. age = 3

3.2. LRUCache

  • 存、取和过期测试:

    此测试流程与FIFOCache一致,不再做演示

  • 淘汰策略测试

    @Test
    public void pruneCacheTest(){
    LRUCache cache = new LRUCache(3);// 创建容量为3,永不过期的缓存
    cache.put("a", "value-a"); // ①
    cache.put("b", "value-b");
    cache.put("c", "value-c");

    cache.get("a");// a命中 // ②
    cache.get("b");// b命中

    LOGGER.info("------{}------", "c被淘汰");
    cache.put("d", "value-d");// ③
    showCache(cache);// 显示缓存列表,c被淘汰

    LOGGER.info("------{}------", "a被淘汰");
    cache.put("e", "value-e");// ④
    showCache(cache);// 显示缓存列表,a被淘汰
    }

    /**
    * 遍历并打印缓存数据
    */
    protected <K, V> void showCache(Cache<K, V> cache){
    showCache(cache, null);
    }

    protected <K, V> void showCache(Cache<K, V> cache, String no){
    Map<K, V> snapshot = cache.snapshot();
    if(MapUtils.isEmpty(snapshot)){
    LOGGER.info("showTime: {} ==> {}", "null", "null");
    return;
    }
    snapshot.forEach((K, V) -> {
    if(StringUtils.isBlank(no)){
    LOGGER.info("showTime: {} ==> {}", K, V);
    }else {
    LOGGER.info("showTime: [{}] {} ==> {}", no, K, V);
    }
    });
    }

    代码解析:

    • 首先创建了个容量为3,且无过期时间的LRU策略的缓存数据结构
    • 代码①处赋值a,b,c
    • 代码②处a,b查询命中,会被依次放到队尾位置
    • 代码③处新增数据d,根据LRU淘汰策略,c会被清除以释放空间,然后d被放置到队尾位置,所以缓存中剩下a,b,d
    • 代码④处新增数据e,根据LRU淘汰策略,a会被清除以释放空间,然后e被放置到队尾位置,所以缓存中剩下b,d,e
    • Jodd的LRUCache之所以能实现根据最近使用情况排序的功能,底层是通过LinkedHashMap的accessOrder=true实现的,下一章笔者将带领大家解读源码,一览究竟

    输出:

    com.code.xxxxxxx.jodd.JoddTest.pruneCacheTest:36 --------c被淘汰--------
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: d ==> value-d
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: a ==> value-a
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: b ==> value-b
    com.code.xxxxxxx.jodd.JoddTest.pruneCacheTest:40 --------a被淘汰--------
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: d ==> value-d
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: e ==> value-e
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: b ==> value-b

3.2. LFU

  • 存、取和过期测试:

    此测试流程与FIFOCache一致,不再做演示

  • 淘汰策略测试

    @Test
    public void pruneCacheTest(){
    LFUCache<String, String> cache = new LFUCache<>(3);// ①

    cache.put("a", "value-a");// ②
    cache.put("b", "value-b");
    cache.put("c", "value-c");

    cache.get("a");// ③
    cache.get("a");// 命中a两次
    cache.get("c");// 命中c一次

    cache.put("d", "value-d");// ④
    showCache(cache);
    }

    代码解析:

    • 代码①处,创建容量为3的LFUCache,且不设置默认过期时间
    • 代码②处,连续赋3个值a,b,c
    • 代码③处,连续命中a两次,和命中c一次
    • 代码④处,新增数据d到缓存,由于容量为3,所以触发淘汰策略,理论上会淘汰掉未被引用(命中次数最少)的b

    输出:

    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: d ==> value-d
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: a ==> value-a
    com.code.xxxxxxx.jodd.JoddTest.lambda$showCache$0:23 -showTime: c ==> value-c

4. 总结

JoddCache是一款简单易用的本地缓存解决方案,简单易学好上手,与Jodd号称小而精巧的瑞士军刀相匹配,值得我们去学习和使用。

下一章笔者将带领大家阅读JoddCache,解开简单易用背后的面纱,敬请期待。