写点什么

JVM 级别的本地缓存框架 Guava Cache:探寻实现细节与核心机制

作者:Java你猿哥
  • 2023-03-23
    湖南
  • 本文字数:6538 字

    阅读完需:约 21 分钟

数据回源与回填策略

前面我们介绍过,Guava Cache 提供的是一种穿透型缓存模式,当缓存中没有获取到对应记录的时候,支持自动去源端获取数据并回填到缓存中。这里回源获取数据的策略有两种,即 CacheLoader 方式与 Callable 方式,两种方式适用于不同的场景,实际使用中可以按需选择。

下面一起看下这两种方式。

CacheLoader

CacheLoader 适用于数据加载方式比较固定且统一的场景,在缓存容器创建的时候就需要指定此具体的加载逻辑。常见的使用方式如下:

public LoadingCache<String, User> createUserCache() {    return CacheBuilder.newBuilder()            .build(new CacheLoader<String, User>() {                @Override                public User load(String key) throws Exception {                    System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");                    return userDao.getUser(key);                }            });    }
复制代码

上述代码中,在使用 CacheBuilder 创建缓存容器的时候,如果在 build()方法中传入一个 CacheLoader 实现类的方式,则最终创建出来的是一个 LoadingCache 具体类型的 Cache 容器:


默认情况下,我们需要继承 CacheLoader 类并实现其 load 抽象方法即可。


当然,CacheLoader 类中还有一些其它的方法,我们也可以选择性的进行覆写来实现自己的自定义诉求。比如我们需要设定 refreshAfterWrite 来支持定时刷新的时候,就推荐覆写 reload 方法,提供一个异步数据加载能力,避免数据刷新操作对业务请求造成阻塞。


另外,有一点需要注意下,如果创建缓存的时候使用 refreshAfterWrite 指定了需要定时更新缓存数据内容,则必须在创建的时候指定 CacheLoader 实例,否则执行的时候会报错。因为在执行 refresh 操作的时候,必须调用 CacheLoader 对象的 reload 方法去执行数据的回源操作。



Callable

与 CacheLoader 不同,Callable 的方式在每次数据获取请求中进行指定,可以在不同的调用场景中,分别指定并使用不同的数据获取策略,更加的灵活

public static void main(String[] args) {    try {        GuavaCacheService cacheService = new GuavaCacheService();        Cache<String, User> cache = cacheService.createCache();        String userId = "123";        // 获取userId, 获取不到的时候执行Callable进行回源        User user = cache.get(userId, () -> cacheService.queryUserFromDb(userId));        System.out.println("get对应用户信息:" + user);    } catch (Exception e) {        e.printStackTrace();    }}
复制代码

通过提供 Callable 实现函数并作为参数传递的方式,可以根据业务的需要,在不同业务调用场景下,指定使用不同的 Callable 回源策略。

不回源查询

前面介绍了基于 CacheLoader 方式自动回源,或者基于 Callable 方式显式回源的两种策略。但是实际使用缓存的时候,并非是缓存中获取不到数据时就一定需要去执行回源操作。

比如下面这个场景:

用户论坛中维护了个黑名单列表,每次用户登录的时候,需要先判断下是否在黑名单中,如果在则禁止登录。

因为论坛中黑名单用户占整体用户量的比重是极少的,也就是几乎绝大部分登录的时候去查询缓存都是无法命中黑名单缓存的。这种时候如果每次查询缓存中没有的情况下都去执行回源操作,那几乎等同于每次请求都需要去访问一次 DB 了,这显然是不合理的。

所以,为了支持这种场景的访问,Guava cache 也提供了一种不会触发回源操作的访问方式。如下:

上述两种接口,执行的时候仅会从当前内存中已有的缓存数据里面进行查询,不会触发回源的操作。

public static void main(String[] args) {    try {        GuavaCacheService cacheService = new GuavaCacheService();        Cache<String, User> cache = cacheService.createCache();        cache.put("123", new User("123", "123"));        cache.put("124", new User("124", "124"));        System.out.println(cache.getIfPresent("125"));        ImmutableMap<String, User> allPresentUsers =                cache.getAllPresent(Stream.of("123", "124", "125").collect(Collectors.toList()));        System.out.println(allPresentUsers);    } catch (Exception e) {        e.printStackTrace();    }}
复制代码

执行后,输入结果如下:

null{123=User(userName=123, userId=123), 124=User(userName=124, userId=124)}
复制代码

Guava Cache 的数据清理与加载刷新机制

在前面的 CacheBuilder 类中有提供了几种 expire 与 refresh 的方法,即 expireAfterAccess、expireAfterWrite 以及 refreshAfterWrite。


这里我们对几个方法进行一些探讨。

数据过期

对于数据有过期时效诉求的场景,我们可以通过几种方式设定缓存的过期时间:

  • expireAfterAccess

  • expireAfterWrite

现在我们思考一个问题:数据过期之后,会立即被删除吗?在前面的文章中,我们自己构建本地缓存框架的时候,有介绍过缓存数据删除的几种机制:

在 Guava Cache 中,为了最大限度的保证并发性,采用的是惰性删除的策略,而没有设计独立清理线程。所以这里我们就可以回答前面的问题,也即过期的数据,并非是立即被删除的,而是在 get 等操作访问缓存记录时触发过期数据的删除操作。


在 get 执行逻辑中进行数据过期清理以及重新回源加载的执行判断流程,可以简化为下图中的关键环节:


在执行 get 请求的时候,会先判断下当前查询的数据是否过期,如果已经过期,则会触发对当前操作的 Segment 的过期数据清理操作。

数据刷新

除了上述的 2 个过期时间设定方法,Guava Cache 还提供了个 refreshAfterWrite 方法,用于设定定时自动 refresh 操作。项目中可能会有这么个情况:

为了提升性能,将最近访问系统的用户信息缓存起来,设定有效期 30 分钟。如果用户的角色出现变更,或者用户昵称、个性签名之类的发生更改,则需要最长等待 30 分钟缓存失效重新加载后才能够生效。

这种情况下,我们就可以在设定了过期时间的基础上,再设定一个每隔 1 分钟重新 refresh 的逻辑。这样既可以保证数据在缓存中的留存时长,又可以尽可能地缩短缓存变更生效的时间。这种情况,便该 refreshAfterWrite 登场了。


与 expire 清理逻辑相同,refresh 操作依旧是采用一种被动触发的方式来实现。当 get 操作执行的时候会判断下如果创建时间已经超过了设定的刷新间隔,则会重新去执行一次数据的加载逻辑(前提是数据并没有过期)。

鉴于缓存读多写少的特点,Guava Cache 在数据 refresh 操作执行的时候,采用了一种非阻塞式的加载逻辑,尽可能的保证并发场景下对读取线程的性能影响。

看下面的代码,模拟了两个并发请求同时请求一个需要刷新的数据的场景:

public static void main(String[] args) {    try {        LoadingCache<String, User> cache =                CacheBuilder.newBuilder().refreshAfterWrite(1L, TimeUnit.SECONDS).build(new MyCacheLoader());        cache.put("123", new User("123", "ertyu"));        Thread.sleep(1100L);        Runnable task = () -> {            try {                System.out.println(Thread.currentThread().getId() + "线程开始执行查询操作");                User user = cache.get("123");                System.out.println(Thread.currentThread().getId() + "线程查询结果:" + user);            } catch (Exception e) {                e.printStackTrace();            }        };        CompletableFuture.allOf(                CompletableFuture.runAsync(task), CompletableFuture.runAsync(task)        ).thenRunAsync(task).join();    } catch (Exception e) {        e.printStackTrace();    }}
复制代码

执行后,结果如下:

14线程开始执行查询操作13线程开始执行查询操作13线程查询结果:User(userName=ertyu, userId=123)14线程执行CacheLoader.reload(),oldValue=User(userName=ertyu, userId=123)14线程执行CacheLoader.load()...14线程执行CacheLoader.load()结束...14线程查询结果:User(userName=97qx6, userId=123)15线程开始执行查询操作15线程查询结果:User(userName=97qx6, userId=123)
复制代码

从执行结果可以看出,两个并发同时请求的线程只有 1 个执行了 load 数据操作,且两个线程所获取到的结果是不一样的。具体而言,可以概括为如下几点:

  • 同一时刻仅允许一个线程执行数据重新加载操作,并阻塞等待重新加载完成之后该线程的查询请求才会返回对应的新值作为结果。

  • 当一个线程正在阻塞执行 refresh 数据刷新操作的时候,其它线程此时来执行 get 请求的时候,会判断下数据需要 refresh 操作,但是因为没有获取到 refresh 执行锁,这些其它线程的请求不会被阻塞等待 refresh 完成,而是立刻返回当前 refresh 前的旧值

  • 当执行 refresh 的线程操作完成后,此时另一个线程再去执行 get 请求的时候,会判断无需 refresh,直接返回当前内存中的当前值即可。

上述的过程,按照时间轴的维度来看,可以囊括成如下的执行过程:


数据 expire 与 refresh 关系

expire 与 refresh 在某些实现逻辑上有一定的相似度,很多的文章中介绍的时候甚至很直白的说 refresh 比 expire 更好,因为其不会阻塞业务端的请求。个人认为这种看法有点片面,从单纯的字面含义上也可以看出这两种机制不是互斥的、而是一种相互补充的存在,并不存在谁比谁更好这一说,关键要看具体是应用场景。


expire 操作就是采用的一种严苛的更新锁定机制,当一个线程执行数据重新加载的时候,其余的线程则阻塞等待。refresh 操作执行过程中不会阻塞其余线程的 get 查询操作,会直接返回旧值。这样的设计各有利弊

Guava Cache 中的 expire 与 fefresh 两种机制,给人的另一个困惑点在于:两者都是被动触发的数据加载逻辑,不管是 expire 还是 refresh,只要超过指定的时间间隔,其实都是依旧存在与内存中,等有新的请求查询的时候,都会执行自动的最新数据加载操作。那这两个实际使用而言,仅仅只需要依据是否需要阻塞加载这个维度来抉择?


并非如此。


expire 存在的意义更多的是一种数据生命周期终结的意味,超过了 expire 有效期的数据,虽然依旧会存在于内存中,但是在一些触发了 cleanUp 操作的情况下,是会被释放掉以减少内存占用的。而 refresh 则仅仅只是执行数据更新,框架无法判断是否需要从内存释放掉,会始终占据内存。


所以在具体使用时,需要根据场景综合判断:


数据需要永久存储,且不会变更,这种情况下 expire 和 refresh 都并不需要设定数据极少变更,或者对变更的感知诉求不强,且并发请求同一个 key 的竞争压力不大,直接使用 expire 即可数据无需过期,但是可能会被修改,需要及时感知并更新缓存数据,直接使用 refresh 数据需要过期(避免不再使用的数据始终留在内存中)、也需要在有效期内尽可能保证数据的更新一致性,则采用 expire 与 refresh 两者结合。对于 expire 与 refresh 结合使用的场景,两者的时间间隔设置,需要注意:


expire 时间设定要大于 refresh 时间,否则的话 refresh 将永远没有机会执行


Guava Cache 并发能力支持采用分段锁降低锁争夺前面我们提过 Guava Cache 支持多线程环境下的安全访问。我们知道锁的粒度越大,多线程请求的时候对锁的竞争压力越大,对性能的影响越大。而如果将锁的粒度拆分小一些,这样同时请求到同一把锁的概率就会降低,这样线程间争夺锁的竞争压力就会降低。


Guava Cache 中采用的也就是这种分段锁策略来降低锁的粒度,可以在创建缓存容器的时候使用 concurrencyLevel 来指定允许的最大并发线程数,使得线程安全的前提下尽可能的减少锁争夺。而 concurrencyLevel 值与分段 Segment 的数量之间也存在一定的关系,这个关系相对来说会比较复杂、且受是否限制总容量等因素的影响,源码中这部分的计算逻辑可以看下:


int segmentShift = 0;int segmentCount = 1;while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {    ++segmentShift;    segmentCount <<= 1;}
复制代码

根据上述的控制逻辑代码,可以将 segmentCount 的取值约束概括为下面几点:

  • segmentCount 是 2 的整数倍

  • segmentCount 最大可能为(concurrencyLevel -1)*2

  • 如果有按照权重设置容量,则 segmentCount 不得超过总权重值的 1/20

从源码中可以比较清晰的看出这一点,Guava Cache 在 put 写操作的时候,会首先计算出 key 对应的 hash 值,然后根据 hash 值来确定数据应该写入到哪个 Segment 中,进而对该 Segment 加锁执行写入操作。

@Overridepublic V put(K key, V value) {    // ... 省略部分逻辑    int hash = hash(key);    return segmentFor(hash).put(key, hash, value, false);}@NullableV put(K key, int hash, V value, boolean onlyIfAbsent) {  lock();    try {        // ... 省略具体逻辑    } finally {        unlock();        postWriteCleanup();    }}
复制代码

根据上述源码也可以得出一个结论,concurrencyLevel 只是一个理想状态下的最大同时并发数,也取决于同一时间的操作请求是否会平均的分散在各个不同的 Segment 中。极端情况下,如果多个线程操作的目标对象都在同一个分片中时,那么只有 1 个线程可以持锁执行,其余线程都会阻塞等待。


实际使用中,比较推荐的是将 concurrencyLevel 设置为 CPU 核数的 2 倍,以获得较优的并发性能。当然,concurrencyLevel 也不是可以随意设置的,从其源码设置里面可以看出,允许的最大值为 65536。

static final int MAX_SEGMENTS = 1 << 16; // 65536LocalCache(CacheBuilder<? super K, ? super V> builder, @Nullable CacheLoader<? super K, V> loader) {    concurrencyLevel = Math.min(builder.getConcurrencyLevel(), MAX_SEGMENTS);    // ... 省略其余逻辑}
复制代码

佛系抢锁策略

在 put 等写操作场景下,Guava Cache 采用的是上述分段锁的策略,通过优化锁的粒度,来提升并发的性能。但是加锁毕竟还是对性能有一定的影响的,为了追求更加极致的性能表现,在 get 等读操作自身并没有发现加锁操作 —— 但是 Guava Cache 的 get 等处理逻辑也并非是纯粹的只读操作,它还兼具触发数据淘汰清理操作的删除逻辑,所以只在判断需要执行清理的时候才会尝试去佛系抢锁

那么它是如何减少抢锁的几率的呢?从源码中可以看出,并非是每次请求都会去触发 cleanUp 操作,而是会尝试积攒一定次数之后再去尝试清理:

static final int DRAIN_THRESHOLD = 0x3F;void postReadCleanup() {  if ((readCount.incrementAndGet() & DRAIN_THRESHOLD) == 0) {    cleanUp();  }}
复制代码

在高并发场景下,如果查询请求量巨大的情况下,即使按照上述的情况限制每次达到一定请求数量之后才去执行清理操作,依旧可能会出现多个 get 操作线程同时去抢锁执行清理操作的情况,这样岂不是依旧会阻塞这些读取请求的处理吗?

继续看下源码:

void cleanUp() {  long now = map.ticker.read();  runLockedCleanup(now);  runUnlockedCleanup();}void runLockedCleanup(long now) {    // 尝试请求锁,请求到就处理,请求不到就放弃  if (tryLock()) {    try {      // ... 省略部分逻辑      readCount.set(0);    } finally {      unlock();    }  }}
复制代码

可以看到源码中采用的是 tryLock 方法来尝试去抢锁,如果抢到锁就继续后续的操作,如果没抢到锁就不做任何清理操作,直接返回 —— 这也是为什么前面将其形容为“佛系抢锁”的缘由。这样的小细节中也可以看出 Google 码农们还是有点内功修为的。

承前启后 —— Caffeine Cache

技术的更新迭代始终没有停歇的时候,Guava 工具包作为 Google 家族的优秀成员,在很多方面提供了非常优秀的能力支持。随着 JAVA8 的普及,Google 也基于语言的新特性,对 Guava Cache 部分进行了重新实现,形成了后来的 Caffeine Cache,并在 SpringBoot2.x 中取代了 Guava Cache。

下一篇文章中,我们将一起再聊一聊令人上瘾的 Caffeine Cache

小结回顾

好啦,关于 Guava Cache 中的典型实现机制与核心设计策略,就介绍到这里了。至此,我们完成了 Guava Cache 从简单会用到深度剖析的全过程,不知道小伙伴们是否对 Guava Cache 有了全新的认识了呢?关于 Guava Cache,你是否有自己的一些想法与见解呢?欢迎评论区一起交流下,期待和各位小伙伴们一起切磋、共同成长。

用户头像

Java你猿哥

关注

一只在编程路上渐行渐远的程序猿 2023-03-09 加入

关注我,了解更多Java、架构、Spring等知识

评论

发布
暂无评论
JVM级别的本地缓存框架Guava Cache:探寻实现细节与核心机制_Java_Java你猿哥_InfoQ写作社区