20-缓存

Posted 程序员008

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了20-缓存相关的知识,希望对你有一定的参考价值。

简介

Guava cache是一个支持高并发的线程安全的本地缓存。多线程情况下也可以安全的访问或者更新cache。
Guava Cache与ConcurrentMap很相似,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除。相对地,Guava Cache为了限制内存占用,通常都设定为自动回收元素。

 

适用场景:
1.你想用内存空间换取时间
2.你预料到某些键会被多次查询
3.你想使用部分内存而非所有且有回收机制
如果你的场景符合上述的每一条,Guava Cache就适合你。

 

依赖

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>29.0-jre</version>
        </dependency>

不需要变化,这个包已经有缓存

 

接口定义

public interface Cache<K, V> {  

  /** 
   * 通过key获取缓存中的value,若不存在直接返回null 
   */  
  V getIfPresent(Object key);  

  /** 
   * 通过key获取缓存中的value,若不存在就通过valueLoader来加载该value 
   * 整个过程为 "if cached, return; otherwise create, cache and return" 
   * 注意valueLoader要么返回非null值,要么抛出异常,绝对不能返回null 
   */  
  V get(K key, Callable<? extends V> valueLoader) throws ExecutionException;  

  /** 
   * 添加缓存,若key存在,就覆盖旧值 
   */  
  void put(K key, V value);  

  /** 
   * 删除该key关联的缓存 
   */  
  void invalidate(Object key);  

  /** 
   * 删除所有缓存 
   */  
  void invalidateAll();  

  /** 
   * 执行一些维护操作,包括清理缓存 
   */  
  void cleanUp();  
}

 

回收策略

Guava Cache提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。 基于容量的方式内部实现采用LRU算法,基于引用回收很好的利用了Java虚拟机的垃圾回收机制。
1、基于容量的回收(size-based eviction)
如果要规定缓存项的数目不超过固定值,只需使用CacheBuilder.maximumSize(long)。缓存将尝试回收最近没有使用或总体上很少使用的缓存项。
注意,在缓存项的数目达到限定值之前,缓存就可能进行回收操作,通常来说,这种情况发生在缓存项的数目逼近限定值时。
如何理解权重?
比如,我们将key的字节数作为权重,具体的计算逻辑可以通过CacheBuilder.weigher(Weigher)指定一个权重函数来表示,
然后我们需要通过CacheBuilder.maximumWeight(long)设置总权重,那么在缓存中已有的权重和逼近限定值时,将会触发内存回收。
注意,在Guava Cache中,“maximumSize”和“maximumWeight”两种控制cache量的参数,是互斥的,即同时只能设置其中一个。

2、定时回收(Timed Eviction) 常用第二种方式
CacheBuilder提供两种定时回收的方法:
expireAfterAccess(long, TimeUnit) :缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于容量回收一样。
expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。
定时回收周期性地在写操作中执行,偶尔在读操作中执行。

3、基于引用的回收(Reference-based Eviction)
通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache 可以把缓存设置为允许垃圾回收:
CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用键的缓存用==而不是 equals 比较键。
CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用值的缓存用==而不是 equals 比较值。
CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是 equals 比较值


过期与刷新的区别?
expireAfterAccess: 在指定的过期时间内没有读写,缓存数据即失效【读写】
expireAfterWrite: 在指定的过期时间内没有写入,缓存数据即失效【写】
refreshAfterWrite: 在指定的过期时间之后访问时,刷新缓存数据,在刷新任务未完成之前,其他线程返回旧值【读写】
expireAfterAccess和expireAfterWrite在缓存过期后,由一个请求去执行后端查询,其他请求都在阻塞等待结果返回,如果同时有大量的请求阻塞,那么可能会产生大影响。
refreshAfterWrite返回旧值的处理方式解决了大量线程阻塞等待的问题,但返回的是旧值。

 

最佳实践

实践一:expireAfterWrite和refreshAfterWrite结合使用,expire的时间要比refresh的长
这样的组合,既避免了大量线程阻塞等待数据更新的问题,也可以保证数据的有效期在合理的范围内,不会出现过期很久的数据。
expire的时间如果比refresh的短,可以想象,旧值在expire到达的时间就失效移除了,refresh根本就不会触发,因为没有旧值可以返回,所有的线程就都要阻塞等待新值到来。我们可以考虑3:1到5:1这样的时间分配比例,比如说expire时间为5分钟,refresh时间设为1分钟,这是一个经验值。

实践二:使用reload来执行异步刷新数据
按照refreshAfterWrite的功能说明,在数据超过refresh设置的期限后,并发请求中,有一个线程要去执行数据刷新任务,其他线程可以返回旧值。但是还是会阻塞一个线程。实际上,刷新数据的任务可以交给后台线程去做,请求线程都可以马上返回。在expireAfterWrite和refreshAfterWrite的组合情况下,可以达到非阻塞的效果,这意味着请求都只需要访问本地缓存,无需IO等待。当然,如果缓存中不存在旧值,这时候是肯定需要等待第一次加载数据的。这样做可以保证热点key的访问一直处于非阻塞状态。

实践三:服务启动时预热缓存
从上面的示例可以看到,刚开始的时候,由于缓存中没有任何数据,所有线程都需要阻塞等待初始化。造成的结果就是刚启动的服务,看起来是卡顿的,过了一段时间之后,服务稳定状态的表现就要好得多。
我们可以通过预热缓存来解决这个问题,将可能的热点数据先尝试加载,完成之后,再将服务暴露出去。

 

 

 

 

 

参考代码

使用JDK线程池完成缓存的异步刷新

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;

import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CacheDemo01 {
    // 定义原生线程池
    final static ExecutorService threadPool = Executors.newFixedThreadPool(10);

    private static Cache<String, Map<Long, Object>> cache = null;

    static {
        cache = CacheBuilder.newBuilder()
                .maximumSize(50000)
                .expireAfterWrite(5 * 60, TimeUnit.SECONDS)
                .refreshAfterWrite(60, TimeUnit.SECONDS)
                .build(new CacheLoader<String, Map<Long, Object>>() {
                    //情况一:当获取的缓存值不存在或已过期时,则会调用此load方法,进行缓存值的计算,只会有一个线程进入load方法,而其他线程则等待。
                    //情况二:当刷新时间到的时候,更新线程调用load方法更新该缓存只会阻塞这一个线程,其他请求线程返回该缓存的旧值。
                    // 这里的定时并不是真正意义上的定时。Guava cache的刷新需要依靠用户请求线程,让该线程去进行load方法的调用,
                    // 所以如果一直没有用户尝试获取该缓存值,则该缓存也并不会刷新。
                    @Override
                    public Map<Long, Object> load(String key) {
                        String[] split = key.split("@");
                        Integer key1 = Integer.parseInt(split[0]);
                        String key2 = split[1];
                        // 查询数据库得到需要缓存的对象
                        return Maps.newConcurrentMap();
                    }

                    // 当缓存的key很多时,高并发条件下大量线程同时获取不同key对应的缓存,此时依然会造成大量线程阻塞,并且给数据库带来很大压力。
                    // 将刷新缓存值的任务交给后台线程,所有的用户请求线程均返回旧的缓存值,这样就不会有用户线程被阻塞了。
                    @Override
                    public ListenableFuture<Map<Long, Object>> reload(String key, Map<Long, Object> oldValue) {
                        ListenableFutureTask<Map<Long, Object>> task
                                = ListenableFutureTask.create(() -> {
                            String[] split = key.split("@");
                            Integer key1 = Integer.parseInt(split[0]);
                            String key2 = split[1];
                            // 查询数据库得到需要缓存的对象
                            return Maps.newConcurrentMap();
                        });
                        threadPool.execute(task);
                        return task;
                    }
                });
    }

}

 

使用guava包装后的线程池,我觉得这种方式要更加优雅和易懂

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;

import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CacheDemo01 {
    // 使用guava包装后的线程池
    final static ListeningExecutorService threadPool =
            MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));

    private static Cache<String, Map<Long, Object>> cache = null;

    static {
        cache = CacheBuilder.newBuilder()
                .maximumSize(50000)
                .expireAfterWrite(5 * 60, TimeUnit.SECONDS)
                .refreshAfterWrite(60, TimeUnit.SECONDS)
                .build(new CacheLoader<String, Map<Long, Object>>() {
                    //情况一:当获取的缓存值不存在或已过期时,则会调用此load方法,进行缓存值的计算,只会有一个线程进入load方法,而其他线程则等待。
                    //情况二:当刷新时间到的时候,更新线程调用load方法更新该缓存只会阻塞这一个线程,其他请求线程返回该缓存的旧值。
                    // 这里的定时并不是真正意义上的定时。Guava cache的刷新需要依靠用户请求线程,让该线程去进行load方法的调用,
                    // 所以如果一直没有用户尝试获取该缓存值,则该缓存也并不会刷新。
                    @Override
                    public Map<Long, Object> load(String key) {
                        String[] split = key.split("@");
                        Integer key1 = Integer.parseInt(split[0]);
                        String key2 = split[1];
                        // 查询数据库得到需要缓存的对象
                        return Maps.newConcurrentMap();
                    }

                    // 当缓存的key很多时,高并发条件下大量线程同时获取不同key对应的缓存,此时依然会造成大量线程阻塞,并且给数据库带来很大压力。
                    // 将刷新缓存值的任务交给后台线程,所有的用户请求线程均返回旧的缓存值,这样就不会有用户线程被阻塞了。
                    @Override
                    public ListenableFuture<Map<Long, Object>> reload(String key, Map<Long, Object> oldValue) {
                        ListenableFuture<Map<Long, Object>> submit = threadPool.submit(() -> {
                            String[] split = key.split("@");
                            Integer key1 = Integer.parseInt(split[0]);
                            String key2 = split[1];
                            // 查询数据库得到需要缓存的对象
                            return Maps.newConcurrentMap();
                        });
                        return submit;
                    }
                });
    }
}

 

以上是关于20-缓存的主要内容,如果未能解决你的问题,请参考以下文章

Swift新async/await并发中利用Task防止指定代码片段执行的数据竞争(Data Race)问题

如何缓存片段视图

phalcon: 缓存片段,文件缓存,memcache缓存

20个简洁的 JS 代码片段

Firebase Facebook 登录即使在卸载应用程序后清除缓存和注销

20个简洁的 JS 代码片段