5分钟手撸一个简单、易用的缓存组件,架构师都对我刮目相看

356 阅读5分钟

@[toc]

背景

现在只要我们一提到缓存,当下的环境,肯定很多人的脑子里立马会蹦出来Redis,俨然已经成为行业标杆了,但是中小型项目中对于简单的缓存需求,还用Redis的话,感觉杀鸡用牛刀了,这时我们可以选择常用的轻量级临时缓存,例如:使用最多的应该就是HashMap或者ConcurrentHashMap了,但是呢,它没有设置最大缓存数量的设置,以及过期淘汰的策略,如果一个不小心,很容易造成系统的OOM,So,来跟着laker手撸一个小型的缓存组件吧(麻雀虽小五脏俱全,堪称缓存界瑞士军刀)。 首先先设计下缓存的核心接口。

设计缓存核心接口

这个应该很简单,一个缓存组件需要提供什么功能呢?(先思考2分钟)

无非是

  • put(key,value,timeout); 将对象加入到缓存,timeout:失效时长
  • get(key); 从缓存中获得对象
  • remove(key); 从缓存中移除对象

这3个方法基本上就够用了,其他的特性可自行扩展

我们再加上一些泛型支持,接口代码如下:

/**
 * 缓存接口
 *
 * @param <K> 键类型
 * @param <V> 值类型
 */
public interface Cache<K, V> {
    /**
     * 将对象加入到缓存
     *
     * @param key     键
     * @param value  对象
     * @param timeout 过期时间
     */
    void put(K key, V value, long timeout);
    /**
     * 从缓存中获得对象
     *
     * @param key 键
     * @return 键对应的对象
     */
    V get(K key);
    /**
     * 从缓存中删除对象
     *
     * @param key 键
     */
    void remove(K key);
}

紧接着我们继续来实现缓存的大致龙骨

缓存框架实现

1. 设置最大缓存数量

需要一个属性来存储这个容量值。

// 容量大小
private int capacity;

2. 选择合适容器来放置缓存对象

我们还用Map类型存储,新增一个Map类型属性

// 存储缓存对象
private Map<K, V> cacheMap;

3. 选择合适淘汰策略

这里我选用实现简单的LFU(least frequently used) 最少使用率策略,来说明一下大致原理,其他实现类似的。

LFU:根据使用次数来判定对象是否被持续缓存(使用率是通过访问次数计算),当缓存满时清理过期对象,清理后依旧满的情况下清除最少访问(访问计数最小)的对象并将其他对象的访问数减去这个最小访问数,以便新对象进入后可以公平计数。

这里要做到LFU,需要以下属性:

  • value 缓存对象
  • latestTime 最新访问时间 ,用于判断对象是否过期,惰性删除的时候使用。
  • count 访问次数 ,用于LFU判断。
  • ttl 对象存活时长,用于判断对象是否过期,惰性删除的时候使用。

代码如下:

public class CacheObject<V> {
   /**
     * 缓存对象
     */
    V value;
    /**
     * 最新访问时间
     */
    long latestTime;

    /**
     * 访问次数
     */
    int count;
    /**
     * 对象存活时长,0表示永久存活
     */
    private final long ttl;

    /**
     * 构造
     *
     * @param value 值
     * @param ttl   超时时长
     */
    protected CacheObject(V value, long ttl) {
        this.value = value;
        this.ttl = ttl;
        this.latestTime = System.currentTimeMillis();
    }

整体实现代码框架

我们整理下上面的思路,转化为代码龙骨

/**
 * LFU 最少使用率策略
 *
 * @param <K>
 * @param <V>
 */
public class LFUCache<K, V> implements ICache<K, V> {
    // 容量大小
    private int capacity;
    // 存储缓存对象
    private Map<K, CacheObject<V>> cacheMap;
    
    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.cacheMap = new HashMap<>(capacity, 1.0f);
    }
    @Override
    public void put(K key, V value, long timeout) {

    }
    @Override
    public V get(K key) {
        return null;
    }
    @Override
    public void remove(K key) {

    }
}

有了这个龙骨,那么下面我们就根据它来把空白的地方填满吧,继续实现丰满我们的组件吧!

丰满相关实现

1. 将对象加入到缓存put操作

相关流程如下

在这里插入图片描述

伪代码如下

    public void put(K key, V value, long timeout) {
        if (缓存满了) {
            删除所有的存活到期的对象(); 
            if(缓存满了) {
                删除一个访问次数最少的对象();
            }
        }
        cacheMap.put(key, new CacheObject<>(value, timeout));
    }

关键点

  • 存活到期对象的判断
CacheObject.latestTime + CacheObject.ttl < System.currentTimeMillis()
  • 删除访问次数最少的对象时,将其他对象的访问数减去这个最小访问数,否则老的数据越来越大,对新进入对象不公平,随着时间的增长,还有可能溢出

2. 从缓存中获得对象get操作

相关流程如下:

在这里插入图片描述

大致代码如下

    public V get(K key) {
        CacheObject<V> cacheObject = cacheMap.get(key);
        if (cacheObject == null) {
            return null;
        }
        cacheObject.latestTime = System.currentTimeMillis();
        cacheObject.count++;
        return cacheObject.value;
    }

关键点

  • 每次访问到的对象,要更新最新访问时间访问次数

3. 从缓存中删除对象remove操作

这里简单的调用Map删除接口即可

 public void remove(K key) {
        cacheMap.remove(key);
   }

好了,写到这儿,我们的缓存组件就基本成型了。

为什么说基本成型?

如果你真的是用心在看的话,估计会提问了卧槽,你这个压根线程非安全啊?

是的,那么我们来给每个方法加个synchronized即可,jdk1.6之后synchronized性能很强劲的。

整体代码如下

public class LFUCache<K, V> implements ICache<K, V> {
    // 容量大小
    private int capacity;
    // 存储缓存对象
    private Map<K, CacheObject<V>> cacheMap;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.cacheMap = new HashMap<>(capacity, 1.0f);
    }

    @Override
    public synchronized void put(K key, V value, long timeout) {
        if (isFull()) {
            pruneCache();
        }
        cacheMap.put(key, new CacheObject<>(value, timeout));
    }

    @Override
    public synchronized V get(K key) {
        CacheObject<V> cacheObject = cacheMap.get(key);
        if (cacheObject == null) {
            return null;
        }
        cacheObject.latestTime = System.currentTimeMillis();
        cacheObject.count++;
        return cacheObject.value;
    }

    @Override
    public synchronized void remove(K key) {
        cacheMap.remove(key);
    }

    public boolean isFull() {
        return (capacity > 0) && (cacheMap.size() >= capacity);
    }

    protected int pruneCache() {
        int count = 0;
        CacheObject<V> comin = null;

        // 清理过期对象并找出访问最少的对象
        Iterator<CacheObject<V>> values = cacheMap.values().iterator();
        CacheObject<V> co;
        while (values.hasNext()) {
            co = values.next();
            if (co.isExpired() == true) {
                values.remove();
                count++;
                continue;
            }

            //找出访问最少的对象
            if (comin == null || co.count < comin.count) {
                comin = co;
            }
        }

        // 减少所有对象访问量,并清除减少后为0的访问对象
        if (isFull() && comin != null) {
            long minAccessCount = comin.count;

            values = cacheMap.values().iterator();
            CacheObject<V> co1;
            while (values.hasNext()) {
                co1 = values.next();
                co1.count -= minAccessCount;
                if (co1.count <= 0) {
                    values.remove();
                    count++;
                }
            }
        }

        return count;
    }
}

验证

测试代码如下:

        //通过实例化对象创建
        LFUCache<String, String> lfuCache = new LFUCache<String, String>(3);

        lfuCache.put("key1", "value1", DateUnit.SECOND.getMillis() * 3);
        lfuCache.get("key1");//使用次数+1
        lfuCache.put("key2", "value2", DateUnit.SECOND.getMillis() * 3);
        lfuCache.put("key3", "value3", DateUnit.SECOND.getMillis() * 3);
        lfuCache.put("key4", "value4", DateUnit.SECOND.getMillis() * 3);

        //由于缓存容量只有3,当加入第四个元素的时候,根据LRU规则,最少使用的将被移除(2,3被移除)
        String value1 = lfuCache.get("key1");
        System.out.println(value1);//key1
        String value2 = lfuCache.get("key2");
        System.out.println(value2);//null
        String value3 = lfuCache.get("key3");
        System.out.println(value3);//null
        String value4 = lfuCache.get("key4");
        System.out.println(value4);//key4

预期:

由于缓存容量只有3,当加入第四个元素的时候,根据LRU规则最少使用的将被移除2,3被移除)

测试结果如下:

value1
null
null
value4

总结

此文只为抛砖引玉,后面还可以实现各种缓存策略的方案,例如FIFOLRU等,但是缓存的这个骨架、原理基本一致一通百通

参考: www.hutool.cn/


QQ群【837324215】 关注我的公众号【Java大厂面试官】,回复:架构资源等关键词(更多关键词,关注后注意提示信息)获取更多免费资料。

公众号也会持续输出高质量文章,和大家共同进步。