实现一个线程安全可以控制最大容量的带有过期时间的本地缓存

好久没有写博客了,最近在公司优化一个接口的时候打算使用一个key-value结构的本地缓存。需要实现的功能非常简单,可以控制本地缓存的最大对象数量(必须线程安全,防止发生OOM),同时支持设置单个对象的过期时间。

面对这个需求,我的选择很多,有很多框架都做的非常好,但很多框架对我来说都太重量级了,我希望一个简单高效的实现,所以我开发了一个简单的小工具,在这里可以分享下实现思路和开发当中遇到的问题以及解决办法。

首先是key-value的结构,我底层封装了一个Map来保存数据。然后要解决线程安全问题,所以我使用了
ConcurrentHashMap这个Map的实现,关于ConcurrentHashMap这个类网上有很多介绍,我在这里就不多说了。

接下来就是需要控制最多存储的对象数量,防止本地缓存太多对象(而且对象一直都被引用,还无法被GC)造成OOM,一开始我只是简单的使用比较size和最大值来判断是否还能添加对象,但是在后来的测试发现并发量非常高的时候会多存几倍的对象,为了保证性能我还不希望加锁或使用synchronized关键字,所以我选择了AtomicInteger这个原子类巧妙的处理添加方法。这个问题的解决我会在代码里详细解释。

对于过期时间实现,我参考了Redis底层对于过期部分的实现,它分为主动和被动过期,前者的性能更好后者更节约空间,为此我兼容了两者的优势,采取了主动+被动的方式,在查询时判断是否过期,如果过期清除对象同时返回null(被动)。在添加元素时判断是否还有空间,如果有正常添加,如果没有触发全量过期,之后再判断是否有空间,有就添加,没有就返回添加失败(主动)。

具体代码如下

package com.xufree.learning.java.cache;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 本地缓存
 * 采用懒过期模式 在查询时才判断是否过期
 * 在缓存满了的时候触发主动过期过期
 *
 * @author zhangmingxu ON 17:52 2019-05-20
 **/
public class LocalCache {
    private static final Logger logger = LoggerFactory.getLogger(LocalCache.class);
    private static final int DEFAULT_MAX_NUMBER = 100; //默认最大缓存对象数

    private final Map<String, Value> cache; //真正存储数据的Map,使用ConcurrentHashMap
    private final int maxNumber; //最大对象数
    //并发控制器,很重要,防止高并发下本地缓存对象个数超过maxNumber
    private final AtomicInteger curNum = new AtomicInteger(0);

    /**
     * 使用默认最大对象数100
     */
    public LocalCache() {
        this(DEFAULT_MAX_NUMBER);
    }

    public LocalCache(int maxNumber) {
        this.maxNumber = maxNumber;
        this.cache = new ConcurrentHashMap<>(maxNumber);
    }

    /**
     * 添加
     * 判断是否超过最大限制 如果超过触发一次全量过期
     * 如果全量过期后还不够返回false
     *  由于1 2 不是原子的所以需要使用单独的AtomicInteger来控制
     *
     * @param key    对应的key
     * @param value  值
     * @param expire 过期时间 单位毫秒
     */
    public boolean put(String key, Object value, long expire) {
        if (StringUtils.isBlank(key) || value == null || expire < 0) {
            logger.error("本地缓存put参数异常");
            return false;
        }
        //这里需要控制并发 否则最大容量控制不住 多线程下会存大于MAX_NUMBER的对象 有OOM风险
        curNum.incrementAndGet(); // //先把个数增加上去 如果添加失败再减下来 相当于加锁了 避免使用同步或者直接锁提高性能
        if (isOver()) { // 1 判断是否需要过期 
            expireAll(); //触发一次全量过期
            if (isOver()) { //二次检查
                logger.error("本地缓存put时全量过期后还没有空间");
                curNum.decrementAndGet(); //添加失败再减下来
                return false;
            }
        }
        putValue(key, value, expire); // 2 
        return true;
    }

    /**
     * 获取时判断过期时间
     * 在这里实现懒过期
     */
    public Object get(String key) {
        Value v = cache.get(key);
        if (v == null) {
            return null;
        }
        if (isExpired(v)) { //如果已经过期
            logger.info("本地缓存key={}已经过期", key);
            removeValue(key);
            return null;
        }
        return v.value;
    }

    /**
     * 判断是否过期,实现很简单
     */
    private boolean isExpired(Value v) {
        long current = System.currentTimeMillis();
        return current - v.updateTime > v.expire;
    }

    /**
     * 扫描所有的对象对需要过期的过期
     */
    private void expireAll() {
        logger.info("开始过期本地缓存");
        for (Map.Entry<String, Value> entry : cache.entrySet()) {
            if (isExpired(entry.getValue())) {
                removeValue(entry.getKey());
            }
        }
    }

    private void putValue(String key, Object value, long expire) {
        Value v = new Value(System.currentTimeMillis(), expire, value);
        cache.put(key, v);
    }

    private void removeValue(String key) {
        cache.remove(key);
        curNum.decrementAndGet();
    }

    /**
     * 这里很重要,原来我使用的是cache.size() >= maxNumber;
     * 但是如果使用map本身的size方法会存在获取size和putValue方法不是原子的,
     * 可能多个线程同时都判断那时候还没执行putValue方法,线程都认为还没有满,大家都执行了putValue方法造成数据太多
     */
    private boolean isOver() {
        return curNum.get() > maxNumber;
    }

    private static class Value {
        private long updateTime; //更新时间
        private long expire; //有效期
        private Object value; //真正的对象

        private Value(long updateTime, long expire, Object value) {
            this.updateTime = updateTime;
            this.expire = expire;
            this.value = value;
        }
    }
}

这里面最关键的就是AtomicInteger curNum这个对象,它在put方法参数校验通过之后就加1(虽然当时还没有putValue),使用这个操作让其他线程在后面的isOver方法中马上感知到数量变化,不会添加过多的对象。

源码地址:GitHub

上一篇:检查阿里云ssl证书到期情况


下一篇:删除mysql日志文件