Guava 教程

Guava 三大件

Guava 缓存 cache 使用、原理、实践深入解析


Google 的 Guava 提供了强大的缓存组件,它将数据缓存的 jvm 内存中,提供了线程安全、多种缓存回收策略等高级封装。

Guava 缓存特点

Guava Cache 的架构设计灵感来自于 jdk 的 ConcurrentHashMap,封装了很多高级功能,使得线上使用更加便利,具体如下特点:

  1. 线程安全的缓存,与 ConcurrentMap 相似,但前者增加了更多的失效策略;
  2. Guava Cache 提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。定时回收有两种:按照写入时间,最早写入的最先回收;按照访问时间,最早访问的最早回收;
  3. 监控缓存加载、命中率、异常率等情况。

Guava Cache 线上示例

Guava Cache 在实际使用中,需要很多注意的地方,如高并发、回收策略等,这里先把实际生产环境中的示例贴出来:

package org.ykdemo.ikdemo;

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

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

public class GuavaCacheDemo {

    ExecutorService executorService = Executors.newFixedThreadPool(100);
    private Cache<String, Object> cache = CacheBuilder.newBuilder()
            //  设置并发级别,并发级别是指可以同时写缓存的线程数,这里设置并发级别为 cpu 核心数
            .concurrencyLevel(Runtime.getRuntime().availableProcessors())
            //  设置缓存容器的初始容量为 10000,直接设置最大值,减少缓存容器的扩容
            .initialCapacity(10000)
            //  设置缓存最大容量为 10000,超过 10000 之后就会按照 LRU(最近虽少使用算法)来移除缓存项
            .maximumSize(10000)
            //  是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
//            .recordStats()
            //  设置写缓存后多少时间过期
            .expireAfterWrite(30, TimeUnit.MINUTES)
            //  设置读写缓存后多少时间过期,实际很少用到,类似于 expireAfterWrite
//            .expireAfterAccess(30, TimeUnit.MINUTES)
            //  只阻塞当前数据加载线程,其他线程返回旧值
//            .refreshAfterWrite(30, TimeUnit.MINUTES)
            //  build 方法中指定 CacheLoader,在缓存不存在时通过 CacheLoader 的实现自动加载缓存
            .build(
                    new CacheLoader<String, Object>() {

                        //  load 是第一次加载,加载之前 cache 中没有值。load 永远都是同步的,不管是否使用异步进行包装
                        @Override
                        public Object load(String key) {
                            //  自定义实现的读 db 等的数据操作
                            return getFromDB(key);
                        }

                        //  reload 如果是被异步包装过的,那么就会是异步操作的,否则和 load 一样也是同步的,默认是调用 load 方法
                        @Override
                        public ListenableFuture<Object> reload(String key, Object oldValue) {
                            ListenableFutureTask<Object> task =
                                    new ListenableFutureTask<Object>(new Callable<Object>() {
                                        @Override
                                        public Object call() {
                                            //  自定义实现的读 db 等的数据操作
                                            return getFromDB(key);
                                        }
                                    });
                            //  使用 executorService 线程池去异步的刷新缓存值
                            executorService.submit(task);
                            return task;
                        }
                    }
            );

}

本地缓存主要使用 LoadingCache 接口,它是 Cache 的子接口,缓存获取及管理都封装在 LoadingCache 接口的实现类中。

Guava Cache 的使用原理如下:

在调用 CacheBuilder 的 build 方法构造 LoadingCache 实现类时,必须传递一个 CacheLoader 类型的参数,CacheLoader 的 load 方法需要我们提供实现。当调用 LoadingCache 的 get 方法时,如果缓存不存在,对应 key 的记录,则 CacheLoader 中的 load 方法会被自动调用从外存加载数据,load 方法的返回值会作为 key 对应的 value 存储到 LoadingCache 中,并从 get 方法返回。

CacheBuilder 也提供了不参数的 build 方法,它采用默认的策略来进行缓存管理。

可选配置

CacheBuilder 类从名字可以看出,采用 builder 设计模式,它的每个方法都返回 CacheBuilder 本身,直到 build 方法被调用。该类中提供了很多的参数设置选项,你可以设置 cache 的默认大小,并发数,存活时间,过期回收策略等等。

缓存并发级别

Guava Cache 提供了设置缓存的并发级别的选项,使得缓存支持并发的写入和读取。Guava 缓存数据存储在继承 jdk ConcurrentHashMap 的再封装实现里,其并发也继承之 ConcurrentHashMap 的分离锁实现。在一般情况下,将并发级别设置为服务器 cpu 核心数是常用的选择。

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

此外需要补充的是,当并发级别 concurrencyLevel 大于 1 时,后面要提到的缓存容量最大值 maximumSize 实际是每个段(segment,ConcurrentHashMap 的概念)的容量变成 maximumSize/concurrencyLevel。

缓存容量设置

缓存的容量主要是两个设置,一是初始容量设置,二是设置最大存储。

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

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

Guava Cache 可以在构建缓存对象时指定缓存所能够存储的最大记录数量。当 Cache 中的记录数量达到最大值后再调用 put 方法向其中添加对象,Guava 会先从当前缓存的对象记录中选择一条删除掉,腾出空间后再将新的对象存储到 Cache 中。

CacheBuilder.newBuilder()
		//  设置最大容量为 10000
		.maximumSize(10000)
		.build();

当达到上限时,腾出缓存数据的策略主要有如下两个:

  1. 基于容量的清除:通过 CacheBuilder.maximumSize(long) 方法可以设置 Cache 的最大容量数,当缓存数量达到或接近该最大值时,Cache 将清除掉那些最近最少使用的缓存;
  2. 基于权重的清除:使用 CacheBuilder.weigher(Weigher) 指定一个权重函数,并且用 CacheBuilder.maximumWeight(long) 指定最大总重。比如每一项缓存所占据的内存空间大小都不一样,可以看作它们有不同的“权重”(weights)。

缓存清除策略

Guava Cache 主要有 4 大类缓存清除机制(或者说策略) :

  1. 基于存活时间的清除(定时刷新、定时过期)
  2. 基于容量的清除
  3. 显性清除(或者叫手动清除)
  4. 基于引用的清除(利用 java 语言的弱引用、软引用)

一、基于存活时间的清除策略:

主要分为两类,一是定时过期,二是定时刷新,前者是被动的,后者是主动的,相关方法涉及如下:

  • expireAfterWrite:写缓存后多久过期;
  • expireAfterAccess:读写缓存后多久过期;
  • refreshAfterWrite:写入数据后多久过期,只阻塞当前数据加载线程,其他线程返回旧值。

这几个策略时间可以单独设置,也可以组合配置。

二、基于容量的清除策略:

这是被动的清除机制,如上面描述的一样,当达到最大容量时被动触发,分为两种不同方式,一是清除掉那些最近最少使用的缓存,二是基于数据所占据的内存空间大小和权重函数清除缓存。

三、显性清除

通过一些条件开发者硬编码显性进行清除缓存操作,涉及如下方法:

  • invalidate(key):个别清除;
  • invalidateAll(keys):批量清除;
  • invalidateAll():清除所有缓存。

四、基于引用的清除策略:

这是利用的 java 特有的弱引用、软引用机制,通过设置使用弱引用的键、或弱引用的值、或软引用的值,从而使 JVM 在 GC 时,顺带实现缓存的清除,不过一般不轻易使用这个特性。具体如下:

  • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用键的缓存用而不是 equals 比较键;
  • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用值的缓存用而不是equals比较值;
  • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定。使用软引用值的缓存同样用==而不是 equals 比较值。

清理什么时候发生?

假设设置的存活时间为一分钟,难道不是一分钟后这个 key 就会立即清除掉吗?答案是不一定,我们来分析一下如果要实现这个功能,那么 Cache 中就必须存在线程来进行周期性地检查、清除等工作,很多 cache 如 redis、ehcache 都是这样实现的。

使用 CacheBuilder 构建的缓存不会”自动”执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做——如果写操作实在太少的话。

这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样 CacheBuilder 就不可用了。

统计信息

Guava Cache 也给提供了统计缓存相关命中率、命中数、过期数、当前 key 数等的统计信息,需要在构建 Cache 对象时,通过 CacheBuilder 的 recordStats 方法开启统计信息的开关。开关开启后,Cache 会自动对缓存的各种操作进行统计,调用 Cache 的 stats 方法可以查看统计后的信息。

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;


public class GuavaCacheService {


    public static void main(String[] args) {
        Cache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(3)
                .recordStats() //开启统计信息开关
                .build();
        cache.put("1", "v1");
        cache.put("2", "v2");
        cache.put("3", "v3");
        cache.put("4", "v4");

        cache.getIfPresent("1");
        cache.getIfPresent("2");
        cache.getIfPresent("3");
        cache.getIfPresent("4");
        cache.getIfPresent("5");
        cache.getIfPresent("6");

        System.out.println(cache.stats()); //获取统计信息
    }

}

输出:
CacheStats{hitCount=3, missCount=3, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=1}

该参数一般在调试一些参数设置时,短暂 debug 设置,长时间应避免在生产环境设置,它对性能有一定的损耗。

支持高并发的缓存

定时过期

expireAfterWrite 让已写入的缓存过期,这种方式存在一个问题:当高并发同时 get 同一个 key,而此时该缓存过期,就会有一个线程进入 load 方法,而其他线程则阻塞等待,直到缓存值被生成。虽然这样避免了缓存击穿的危险,但还是有大量线程阻塞等待生成缓存值。

定时刷新

refreshAfterWrite 当到达刷新时间时,会有一个用户线程去刷新缓存值,其他线程仍获取旧值。虽然这样每个 key 只会阻塞一个用户线程,但高并发请求不同 key 时,仍然会造成大量线程阻塞,并对数据库压力过大。

异步刷新

Guava Cache 的 CacheLoader 接口提供了 load 和 reload 方法,它们有不同的用处,使用时需要了解其内部机制。

load 方法:

该方法是同步的,它是在 cache 中没有缓存值时进行加载(如第一次加载),load 方法永远都是同步的,不管是否使用异步进行包装

reload 方法:

reload 方法是之前 cache 中有值,需要刷新该值,比如设置了过期时间后,到了缓存过期需要更新的时间,会触发 scheduleRefresh 去做刷新。手动调用 refresh 方法的时候,也会触发 reload。

如果没有覆写该方法,默认其内部也是调用 load 方法,所以 reload 如果是被异步包装过的,那么就会是异步操作的,否则和 load 一样也是同步的

异步刷新的实战示例,可以参照文章开头列出的线上用法。