@cacheable 是否缓存成功_线程安全的可控制最大容量且带有过期时间的本地缓存...

486692077cb9afc4c474f4284a3dc496.png

作者|buren

编辑|包包

最近在公司优化一个接口的时候打算使用一个key-value结构的本地缓存。

需要实现的功能非常简单:

1、可以控制本地缓存的最大对象数量。

2、线程安全,防止发生OOM。

3、同时支持设置单个对象的过期时间。

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

首先是key-value的结构,我底层封装了一个Map来保存数据。然后要解决线程安全问题,所以我使用了

ConcurrentHashMap这个Map的实现,关于ConcurrentHashMap这个类网上有很多介绍,我在这里就不多说了。

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

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

具体代码如下

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 cache; //真正存储数据的Map,使用ConcurrentHashMap    private final int maxNumber; //最大对象数    //并发控制器,很重要,防止高并发下本地缓存对象个数超过maxNumber    private final AtomicInteger cur = 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;        }        if (!incr()) { //如果CAS增加失败直接返回添加失败            return false;        }        if (isOver()) { //判断是否需要过期            expireAll(); //触发一次全量过期            if (isOver()) { //二次检查                logger.error("本地缓存put时全量过期后还没有空间");                decr();                return false;            }        }        putValue(key, value, expire);        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 entry : cache.entrySet()) {            if (isExpired(entry.getValue())) {                removeValue(entry.getKey());            }        }    }  /**   * 为了保证cur和Map的size时刻保持一致这里我查询了put的注释及ConcurrentHashMap底层关于put的实现。   * 发现如果put方法返回的不是null说明存在覆盖操作,如果是覆盖那么Map的size其实没有变,因为我们添加之前把cur的值增加   * 上去了所以要在这里减下来。   */    private void putValue(String key, Object value, long expire) {        Value v = new Value(System.currentTimeMillis(), expire, value);        if (cache.put(key, v) != null) {//存在覆盖 使得cur和map的size统一            decr();        }    }  /**   * 这里也是为了保证cur和Map的size时刻保持一致只有在remove方法返回的不是null时才证明真正有对象被删除了,才需要把   * cur减下来。这里出现remove返回为null是因为可能存在并发删除,两个线程删除同一个对象只能有一个删除成功(返回不是   * null),另一个(返回null)如果也减小了cur的值,会造成cur和Map的size不一致。    private void removeValue(String key) {        if (cache.remove(key) != null) { //真正删除成功了  使得cur和map的size统一            decr();        }    }    /**     * 这里很重要,原来我使用的是cache.size() >= maxNumber;     * 但是如果使用map本身的size方法会存在获取size和putValue方法不是原子的,     * 可能多个线程同时都判断那时候还没执行putValue方法,线程都认为还没有满,大家都执行了putValue方法造成数据太多     */    private boolean isOver() {        return cur.get() > maxNumber;    }    private boolean incr() {        int c = cur.get();        return cur.compareAndSet(c, ++c);    }      /**   * 因为CAS不一定是一定成功的   * 所以这里通过循环保证成功   */    private void decr() {        for (; ; ) {            int c = cur.get();            if (c == 0) {                logger.error("LocalCache decr cur is 0");                return;            }            if (cur.compareAndSet(c, --c)) {                return;            }        }    }    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 cur这个对象,它在put方法参数校验通过之后就加1(虽然当时还没有putValue),使用这个操作让其他线程在后面的isOver方法中马上感知到数量变化,不会添加过多的对象。

保证cur的值和Map的Size时刻一致也很重要,并不是只要putValue了就加一(覆盖时虽然put进去了对象但是size不变),remove了就减一(并发删除同一个对象只能有一个成功,可能多减了),平常我们在使用Map的put和remove方法时往往忽略了它们的返回值,所以我建议大家仔细阅读源代码,加深理解。

并发测试代码如下:

  public static void main(String[] args) throws InterruptedException {        long start = System.currentTimeMillis();        LocalCache localCache = new LocalCache();        int n = 500; //线程数        int m = 100000; //每个线程put个数        CountDownLatch count = new CountDownLatch(n);        for (int i = 0; i < n; i++) {            new Thread(() -> {                for (int j = 0; j < m; j++) {                    localCache.put(j + "", new Object(), 10);                }                count.countDown();            }).start();        }        count.await();        System.out.println("size:" + localCache.cache.size());        System.out.println("cur:" + localCache.cur);        System.out.println("耗时  " + (System.currentTimeMillis() - start));    }
—————END—————

喜欢本文的朋友,欢迎关注公众号 并发编程网,收看更多精彩内容

56c09c5d411e3c0ee0bcdc47fece46bb.png

@Cacheable注解是Spring框架提供的缓存注解,用于标记方法的返回结果可被缓存。它可以应用在方法级别或类级别。当方法被调用时,Spring会首先从缓存中查找方法的返回结果,如果缓存中存在,则直接返回缓存值,不再执行方法体内的逻辑。如果缓存中不存在,则执行方法体内的逻辑,并将返回结果存入缓存中。 @Cacheable注解默认是使用方法的参数作为缓存的key,所以相同参数调用的方法返回结果会被缓存起来。但是默认情况下,如果在缓存中找不到对应的结果,Spring会执行方法体内的逻辑,并将返回结果存入缓存中。这样会导致并发调用时出现缓存穿透问题,即多个线程同时请求同一个参数值,导致每个线程都执行了方法体内的逻辑,没有从缓存中获取到结果。 为了解决缓存穿透问题,可以使用热加载机制。热加载是指在缓存失效期间,只有一个线程去执行方法体内的逻辑,其他线程等待该线程执行完毕后直接从缓存中获取结果。 实现热加载可以通过在@Cacheable注解中设置sync属性为true。这样在缓存失效期间,只有一个线程去执行方法体内的逻辑,其他线程等待该线程执行完毕后直接从缓存中获取结果。示例代码如下: ```java @Cacheable(value = "myCache", key = "#param", sync = true) public String getData(String param) { // 执行业务逻辑 } ``` 需要注意的是,设置sync属性为true会导致性能损失,因为其他线程在等待期间无法直接从缓存中获取结果。因此,只有在必要的情况下才应该使用热加载机制。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值