前言

最近在一个项目中需要用到本地缓存,在网上调研后,发现谷歌的Guva提供的cache模块非常的不错。简单易上手的api;灵活强大的功能,再加上谷歌这块金字招牌,让我毫不犹豫的选择了它。仅以此博客记录我在使用过程中的点滴。

什么是本地缓存

在我们的应用中,大部分的计算是昂贵的,而且是可复用的,并且计算结果不会经常发生改变。这时候我们就可以将这些昂贵的计算结果缓存到内存中,下次使用的时候直接取出即可,而不用重新计算。这样可以节省大量的cpu和内存资源,提高系统的吞吐量。

本地缓存作用就是提高系统的运行速度,是一种空间换时间的取舍。它实质上是一个做key-value查询的字典,但是相对于我们常用HashMap它又有以下特点:

  1. 并发性;由于目前的应用大都是多线程的,所以缓存需要支持并发的写入。
  2. 过期策略;在某些场景中,我们可能会希望缓存的数据有一定“保质期”,过期策略可以固定时间,例如缓存写入10分钟后过期。也可以是相对时间,例如10分钟内未访问则使缓存过期(类似于servlet中的session)。在java中甚至可以使用软引用,弱引用的过期策略。
  3. 淘汰策略;由于本地缓存是存放在内存中,我们往往需要设置一个容量上限和淘汰策略来防止出现内存溢出的情况。

 

这是我们的项目中用到的:



private DriverInfoServiceImpl(DriverClient driverClient, DriverRedisClient driverRedisClient) {
        this.driverClient = driverClient;
        this.driverCacheLoader = new DriverCacheLoader(driverClient);
        this.driverRedisClient = driverRedisClient;
        loadingCache = CacheBuilder
                .newBuilder()
                .concurrencyLevel(5) //同事允许5个线程修改缓存
                .recordStats() //打开缓存记录器
                .expireAfterWrite(1, TimeUnit.MINUTES)//过期时间
                .build(driverCacheLoader);
    }



 



/**
     * 获取queryDriverCache接口查询到的司机缓存
     *
     * @param driverId
     * @return
     */
    private DriverSnapshoot getDriverCache(String driverId) {
        try {
            if (MccConfig.getENABLE_DRIVER_DATA_GUAVA_CACHE() == 1) {
                Optional<DriverSnapshoot> optional = loadingCache.get(driverId);
                if (MccConfig.getENABLE_GUAVA_STATS_LOGS() == Constants.SWITCH_OPEN) {
                    log.info("guava 缓存命中率={}", loadingCache.stats().hitRate());
                }
                if (optional.isPresent()) {
                    return optional.get();
                }
                log.warn("loadingCache.getDriverCache 为空");
                return null;
            } else {
                //清空全部缓存
                if (loadingCache.size() > 0) {
                    loadingCache.invalidateAll();
                }
                Optional<DriverSnapshoot> optional = driverCacheLoader.load(driverId);
                if (optional.isPresent()) {
                    return optional.get();
                }
                log.warn("driverCacheLoader.load 为空");
                return null;
            }

        } catch (Exception e) {
            throw QcsServiceErrorEnum.DATA_MANAGE_PART_FAIL.formException(String.format("queryDriverCache失败,driverId:%s", driverId), e);
        }
    }



 

缓存的最大容量与淘汰策略

由于本地缓存是将计算结果缓存到内存中,所以我们往往需要设置一个最大容量来防止出现内存溢出的情况。这个容量可以是缓存对象的数量,也可以是一个具体的内存大小。在Guva中仅支持设置缓存对象的数量。

当缓存数量逼近或大于我们所设置的最大容量时,为了将缓存数量控制在我们所设定的阈值内,就需要丢弃掉一些数据。由于缓存的最大容量恒定,为了提高缓存的命中率,我们需要尽量丢弃那些我们之后不再经常访问的数据,保留那些即将被访问的数据。为了达到以上目的,我们往往会制定一些缓存淘汰策略,常用的缓存淘汰策略有以下几种:

  1. FIFO:First In First Out,先进先出。
    一般采用队列的方式实现。这种淘汰策略仅仅是保证了缓存数量不超过我们所设置的阈值,而完全没有考虑缓存的命中率。所以在这种策略极少被使用。
  2. LRU:Least Recently Used,最近最少使用;
    该算法其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
    所以该算法是淘汰最后一次使用时间离当前最久的缓存数据,保留最近访问的数据。所以该种算法非常适合缓存“热点数据”。
    但是该算法在缓存周期性数据时,就会出现缓存污染,也就是淘汰了即将访问的数据,反而把不常用的数据读取到缓存中。
    为了解决这个问题,后续也出现了如LRU-K,Two queues,Multi Queue等进阶算法。
  3. LFU:Least Frequently Used,最不经常使用。
    该算法的核心思想是“如果数据在以前被访问的次数最多,那么将来被访问的几率就会更高”。所以该算法淘汰的是历史访问次数最少的数据。
    一般情况下,LFU效率要优于LRU,且能够避免周期性或者偶发性的操作导致缓存命中率下降的问题。但LFU需要记录数据的历史访问记录,一旦数据访问模式改变,LFU需要更长时间来适用新的访问模式,即:LFU存在历史数据影响将来数据的“缓存污染”效用。
    后续出现LFU*,LFU-Aging,Window-LFU等改进算法。

合理的使用淘汰算法能够很明显的提升缓存命中率,但是也不应该一味的追求命中率,而是应在命中率和资源消耗中找到一个平衡。

在guava中默认使用LRU淘汰算法,而且在不修改源码的情况下也不支持自定义淘汰算法,这算是一种小小的遗憾吧。

Guva和它的cache

Guva是google开源的一个公共java库,类似于Apache Commons,它提供了集合,反射,缓存,科学计算,xml,io等一些工具类库。

cache只是其中的一个模块。使用Guva cache能够方便快速的构建本地缓存。

使用Guava构建第一个缓存

首先需要在maven项目中加入guava依赖

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

然后便可以通过Guava创建一个缓存,例如:

// 通过CacheBuilder构建一个缓存实例
Cache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100) // 设置缓存的最大容量
                .expireAfterWrite(1, TimeUnit.MINUTES) // 设置缓存在写入一分钟后失效 .concurrencyLevel(10) // 设置并发级别为10 .recordStats() // 开启缓存统计 .build(); // 放入缓存 cache.put("key", "value"); // 获取缓存 String value = cache.getIfPresent("key");

Guava的缓存有许多配置选项,所以为了简化缓的创建过程,使用了Builder设计模式;Builder使用的是链式编程的思想,也就是每次调用方法后返回的是对象本生,这样可以极大的简化配置过程。

上面的代码演示了使用Guava创建了一个基于内存的本地缓存,并指定了一些缓存的参数,如缓存容量、缓存过期时间、并发级别等,随后通过put方法放入一个缓存并使用getIfPresent来获取它。

Cache与LoadingCache

使用CacheBuilder我们能构建出两种类型的cache,他们分别是Cache与LoadingCache。

Cache

Cache是通过CacheBuilder的build()方法构建,它是Gauva提供的最基本的缓存接口,并且它提供了一些常用的缓存api:

Cache<Object, Object> cache = CacheBuilder.newBuilder().build();
// 放入/覆盖一个缓存
cache.put("k1", "v1");
// 获取一个缓存,如果该缓存不存在则返回一个null值
Object value = cache.getIfPresent("k1"); // 获取缓存,当缓存不存在时,则通Callable进行加载并返回。该操作是原子 Object getValue = cache.get("k1", new Callable<Object>() { @Override public Object call() throws Exception { return null; } });

LoadingCache

LoadingCache继承自Cache,在构建LoadingCache时,需要通过CacheBuilder的build(CacheLoader<? super K1, V1> loader)方法构建:

CacheBuilder.newBuilder()
		.build(new CacheLoader<String, String>() {
			@Override
			public String load(String key) throws Exception { // 缓存加载逻辑 ... } });

LoadingCache,顾名思义,它能够通过CacheLoader自发的加载缓存:

LoadingCache<Object, Object> loadingCache = CacheBuilder.newBuilder().build(new CacheLoader<Object, Object>() {
            @Override
            public Object load(Object key) throws Exception { return null; } }); // 获取缓存,当缓存不存在时,会通过CacheLoader自动加载,该方法会抛出ExecutionException异常 loadingCache.get("k1"); // 以不安全的方式获取缓存,当缓存不存在时,会通过CacheLoader自动加载,该方法不会抛出异常 loadingCache.getUnchecked("k1");

缓存的并发级别

Guava提供了设置并发级别的api,使得缓存支持并发的写入和读取。同ConcurrentHashMap类似Guava cache的并发也是通过分离锁实现。在一般情况下,将并发级别设置为服务器cpu核心数是一个比较不错的选择。

CacheBuilder.newBuilder()
		// 设置并发级别为cpu核心数
		.concurrencyLevel(Runtime.getRuntime().availableProcessors()) 
		.build();

缓存的初始容量

我们在构建缓存时可以为缓存设置一个合理大小初始容量,由于Guava的缓存使用了分离锁的机制,扩容的代价非常昂贵。所以合理的初始容量能够减少缓存容器的扩容次数。

CacheBuilder.newBuilder()
		// 设置初始容量为100
		.initialCapacity(100)
		.build();

缓存的回收

在前文提到过,在构建本地缓存时,我们应该指定一个最大容量来防止出现内存溢出的情况。在guava中除了提供基于数量,和基于内存容量两种回收策略外,还提供了基于引用的回收。

基于数量/容量的回收

基于最大数量的回收策略非常简单,我们只需指定缓存的最大数量maximumSize即可:

CacheBuilder.newBuilder()
		.maximumSize(100) // 缓存数量上限为100
		.build();

使用基于最大容量的的回收策略时,我们需要设置2个必要参数:

  • maximumWeigh;用于指定最大容量。
  • Weigher;在加载缓存时用于计算缓存容量大小。

这里我们例举一个key和value都是String类型缓存:

CacheBuilder.newBuilder()
		.maximumWeight(1024 * 1024 * 1024) // 设置最大容量为 1M
        // 设置用来计算缓存容量的Weigher .weigher(new Weigher<String, String>() { @Override public int weigh(String key, String value) { return key.getBytes().length + value.getBytes().length; } }).build();

当缓存的最大数量/容量逼近或超过我们所设置的最大值时,Guava就会使用LRU算法对之前的缓存进行回收。

基于软/弱引用的回收

基于引用的回收策略,是java中独有的。在java中有对象自动回收机制,依据程序员创建对象的方式不同,将对象由强到弱分为强引用、软引用、弱引用、虚引用。对于这几种引用他们有以下区别:

强引用

强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。

Object o=new Object();   //  强引用

当内存空间不足,垃圾回收器不会自动回收一个被引用的强引用对象,而是会直接抛出OutOfMemoryError错误,使程序异常终止。

软引用

相对于强引用,软引用是一种不稳定的引用方式,如果一个对象具有软引用,当内存充足时,GC不会主动回收软引用对象,而当内存不足时软引用对象就会被回收。

SoftReference<Object> softRef=new SoftReference<Object>(new Object()); // 软引用
Object object = softRef.get(); // 获取软引用

使用软引用能防止内存泄露,增强程序的健壮性。但是一定要做好null检测。

弱引用

弱引用是一种比软引用更不稳定的引用方式,因为无论内存是否充足,弱引用对象都有可能被回收。

WeakReference<Object> weakRef = new WeakReference<Object>(new Object()); // 弱引用
Object obj = weakRef.get(); // 获取弱引用

虚引用

而虚引用这种引用方式就是形同虚设,因为如果一个对象仅持有虚引用,那么它就和没有任何引用一样。在实践中也几乎没有使用。

在Guava cache中支持,软/弱引用的缓存回收方式。使用这种方式能够极大的提高内存的利用率,并且不会出现内存溢出的异常。

CacheBuilder.newBuilder()
		.weakKeys() // 使用弱引用存储键。当键没有其它(强或软)引用时,该缓存可能会被回收。
		.weakValues() // 使用弱引用存储值。当值没有其它(强或软)引用时,该缓存可能会被回收。
		.softValues() // 使用软引用存储值。当内存不足并且该值其它强引用引用时,该缓存就会被回收
		.build();

通过软/弱引用的回收方式,相当于将缓存回收任务交给了GC,使得缓存的命中率变得十分的不稳定,在非必要的情况下,还是推荐基于数量和容量的回收。

显式回收

在缓存构建完毕后,我们可以通过Cache提供的接口,显式的对缓存进行回收,例如:

// 构建一个缓存
Cache<String, String> cache = CacheBuilder.newBuilder().build();
// 回收key为k1的缓存
cache.invalidate("k1");
// 批量回收key为k1、k2的缓存
List<String> needInvalidateKeys = new ArrayList<>(); needInvalidateKeys.add("k1"); needInvalidateKeys.add("k2"); cache.invalidateAll(needInvalidateKeys); // 回收所有缓存 cache.invalidateAll();

缓存的过期策略与刷新

Guava也提供了缓存的过期策略和刷新策略。

缓存过期策略

缓存的过期策略分为固定时间和相对时间。

固定时间一般是指写入后多长时间过期,例如我们构建一个写入10分钟后过期的缓存:

CacheBuilder.newBuilder()
		.expireAfterWrite(10, TimeUnit.MINUTES) // 写入10分钟后过期
		.build();

// java8后可以使用Duration设置
CacheBuilder.newBuilder()
		.expireAfterWrite(Duration.ofMinutes(10))
		.build();

相对时间一般是相对于访问时间,也就是每次访问后,会重新刷新该缓存的过期时间,这有点类似于servlet中的session过期时间,例如构建一个在10分钟内未访问则过期的缓存:

CacheBuilder.newBuilder()
		.expireAfterAccess(10, TimeUnit.MINUTES) //在10分钟内未访问则过期
		.build();

// java8后可以使用Duration设置
CacheBuilder.newBuilder()
		.expireAfterAccess(Duration.ofMinutes(10))
		.build();

缓存刷新

在Guava cache中支持定时刷新和显式刷新两种方式,其中只有LoadingCache能够进行定时刷新。

定时刷新

在进行缓存定时刷新时,我们需要指定缓存的刷新间隔,和一个用来加载缓存的CacheLoader,当达到刷新时间间隔后,下一次获取缓存时,会调用CacheLoader的load方法刷新缓存。例如构建个刷新频率为10分钟的缓存:

CacheBuilder.newBuilder()
		// 设置缓存在写入10分钟后,通过CacheLoader的load方法进行刷新
		.refreshAfterWrite(10, TimeUnit.SECONDS)
		// jdk8以后可以使用 Duration
		// .refreshAfterWrite(Duration.ofMinutes(10))
		.build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // 缓存加载逻辑 ... } });

显式刷新

在缓存构建完毕后,我们可以通过Cache提供的一些借口方法,显式的对缓存进行刷新覆盖,例如:

// 构建一个缓存
Cache<String, String> cache = CacheBuilder.newBuilder().build();
// 使用put进行覆盖刷新
cache.put("k1", "v1");
// 使用Map的put方法进行覆盖刷新 cache.asMap().put("k1", "v1"); // 使用Map的putAll方法进行批量覆盖刷新 Map<String,String> needRefreshs = new HashMap<>(); needRefreshs.put("k1", "v1"); cache.asMap().putAll(needRefreshs); // 使用ConcurrentMap的replace方法进行覆盖刷新 cache.asMap().replace("k1", "v1");

对于LoadingCache,由于它能够自动的加载缓存,所以在进行刷新时,不需要显式的传入缓存的值:

LoadingCache<String, String> loadingCache = CacheBuilder
			.newBuilder()
			.build(new CacheLoader<String, String>() {
				@Override
				public String load(String key) throws Exception { // 缓存加载逻辑 return null; } }); // loadingCache 在进行刷新时无需显式的传入 value loadingCache.refresh("k1");