Guava Cache
1 Guava Cache 介绍
Guava 是 Google 提供的一套 JAVA 的工具包,而 Guava Cache
则是该工具包中提供的一套完善的 JVM 级别的高并发缓存框架。其实现机制类似 ConcurrentHashMap,但是进行了众多的封装与能力扩展。作为 JVM 级别的本地缓存框架,Guava Cache
具备缓存框架该有的众多基础特性。当然,Guava Cache 能从众多本地缓存类产品中脱颖而出,除了具备上述基础缓存特性外,还有众多贴心的能力增强。

1.1 支持缓存记录的过期设定
作为一个合格的缓存容器,支持缓存记录过期是一个基础能力。Guava Cache
不但支持设定过期时间,还支持选择是根据插入时间
进行过期处理(创建过期)、或者是根据最后访问时间
进行过期处理(访问过期)。
过期策略 | 具体说明 |
---|---|
创建过期 | 基于缓存记录的插入时间判断。如设定10分钟过期,则记录加入缓存后,不管有没有访问,10分钟时间到则过期。 |
访问过期 | 基于最后一次访问的时间来判断是否过期。如设定10分钟过期,若缓存记录被访问到,则以最后一次访问时间重新计时;只有连续10分钟没有被访问时才会过期,否则将一直存在缓存中不会被过期。 |
实际使用时,在创建缓存容器的时候指定过期策略即可:
① 基于创建时间过期
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterWrite(30L, TimeUnit.MINUTES)
.build();
}
② 基于访问时间过期
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterAccess(30L, TimeUnit.MINUTES)
.build();
}
1.2 支持缓存容量限制
作为内存型缓存,必须要防止出现内存溢出的风险。Guava Cache支持设定缓存容器的最大存储上限,并支持根据缓存记录条数
或者基于每条缓存记录的权重
进行判断是否达到容量阈值。
当容量触达阈值后,支持根据FIFO + LRU
策略实施具体淘汰处理以腾出位置给新的记录使用。
淘汰策略 | 具体说明 |
---|---|
FIFO | 根据缓存记录写入的顺序,先写入的先淘汰 |
LRU | 根据访问顺序,淘汰最久没有访问的记录 |
实际使用的时候,同样是在创建缓存容器的时候指定容量上限与淘汰策略。
① 限制缓存记录条数
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumSize(10000L)
.build();
}
② 限制缓存记录权重
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumWeight(10000L)
.weigher((key, value) -> (int) Math.ceil(instrumentation.getObjectSize(value) / 1024L))
.build();
}
这里需要注意:按照权重进行限制缓存容量的时候必须要指定 weighter
属性才可以生效。上面代码中我们通过计算value
对象的字节数 (byte) 来计算其权重信息,每 1kb 的字节数作为 1 个权重,整个缓存容器的总权重限制为 1w,这样就能实现将缓存内存占用控制在10000*1k≈10M
左右 (若存储的都是 1kb 的记录,则最多缓存 1w 条记录;若存储的都是 100kb 的记录,则最多缓存 100 条记录)。
为什么要有 “限制缓存记录权重” 这种方式?
一般而言,限制容器的容量的初衷,是为了防止内存占用过大导致
内存溢出
,所以本质上是限制内存的占用量。从实现层面,往往会根据总内存占用量与预估每条记录字节数进行估算,将其转换为对缓存记录条数的限制。这种做法相对简单易懂,但是对于单条缓存记录占用字节数差异较大的情况下,会导致基于条数控制的结果不够精准。为了解决这个问题,Guava Cache 中提供了一种相对精准的控制策略,即基于权重的总量控制,根据一定的规则,计算出每条 value 记录所占的权重值,然后以权重值进行总量的计算。基于
weight
权重的控制方式,比较适用于这种对容器体量控制精度有严格诉求的场景,可以在创建容器的时候指定每条记录的权重计算策略 (如基于字符串长度或者基于 bytes 数组长度进行计算权重)。
1.3 支持多种淘汰策略
为了简单描述,我们将数据从缓存容器中移除的操作统称数据淘汰。按照触发形态不同,可将数据的清理与淘汰策略分为被动淘汰与主动淘汰两种。
- 被动淘汰
- 基于过期时间:在创建容器的时候指定其
expireAfterWrite
或expireAfterAccess
- 基于数据量:在创建容器的时候指定其
maximumSize
或maximumWeight
- 基于引用:基于引用回收的策略,核心是利用
JVM
虚拟机的 GC 机制来达到数据清理的目的。按照 JVM 的 GC 原理,当一个对象不再被引用之后,便会执行一系列的标记清除逻辑,并最终将其回收释放。在构建 Cache 实例过程中,通过设置使用弱引用的键、或弱引用的值、或软引用的值,从而使 JVM在GC时顺带实现缓存的清除,不过一般不轻易使用这个特性。- CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用键的缓存用 == 而不是 equals 比较键。
- CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用值的缓存用 == 而不是 equals 比较值。
- CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(基于数据量)。使用软引用值的缓存同样用 == 而不是 equals 比较值。
- 基于过期时间:在创建容器的时候指定其
- 调用 cache 相关 api 主动淘汰
- invalidate(key):删除指定的记录
- invalidateAll(keys):批量删除给定的记录
- invalidateAll():清空整个缓存容器
1.4 支持自动回源
在前面文章中,我们有介绍过缓存的三种模型,分别是旁路型
、穿透型
、异步型
。Guava Cache 作为一个封装好的缓存框架,是一个典型的穿透型缓存。正常业务使用缓存时通常会使用旁路型缓存,即先去缓存中尝试查询获取数据,如果获取不到则会从数据库中进行查询并加入到缓存中;而为了简化业务端使用复杂度,Guava Cache支持集成数据源,业务层面调用接口查询缓存数据的时候,如果缓存数据不存在,则会自动去数据源中进行数据获取并加入缓存中。
1.4.1 实现方式
Callable 方式
通过在 cache 的 get 方法中传入 Callable
实现来指定回源获取数据:
public class CacheService {
UserDao userDao = new UserDao();
public User findUser(Cache<String, User> cache, String userId) {
try {
return cache.get(userId, () -> {
System.out.println(userId + "用户缓存不存在,尝试回源查找并回填...");
return userDao.getUser(userId);
});
} catch (ExecutionException e) {
e.printStackTrace();
}
return null;
}
public static void main(String[] args) {
Cache<String, User> cache = CacheBuilder.newBuilder().build();
CacheService cacheService = new CacheService();
System.out.println(cacheService.findUser(cache, "123"));
System.out.println(cacheService.findUser(cache, "124"));
System.out.println(cacheService.findUser(cache, "123"));
}
}
实际使用时若查询的用户不存在,则会自动去回源查找并写入缓存里,再次获取时便能从缓存直接获取。执行结果:
123用户缓存不存在,尝试回源查找并回填...
User(userId=123, userName=铁柱, department=研发部)
124用户缓存不存在,尝试回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=123, userName=铁柱, department=研发部)
CacheLoader 方式
需要在创建缓存容器的时候声明容器为 LoadingCache 类型,并且指定CacheLoader
处理逻辑:
public class CacheService {
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);
}
});
}
public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache();
try {
System.out.println(cache.get("123"));
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
} catch (Exception e) {
e.printStackTrace();
}
}
}
这样,获取不到数据时,也会自动回源查询并填充。执行结果:
123用户缓存不存在,尝试回源查找并回填...
User(userId=123, userName=铁柱, department=研发部)
124用户缓存不存在,尝试回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=123, userName=铁柱, department=研发部)
二者结合
Callable
和 CacheLoader
这两种方式都能实现回源获取数据,二者也可结合使用,这种情况下优先会执行 Callable
提供的逻辑,Callable 缺失的场景会使用 CacheLoader
提供的逻辑。
public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache();
try {
System.out.println(cache.get("123", () -> new User("xxx")));
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
} catch (Exception e) {
e.printStackTrace();
}
}
执行后,可以看出 Callable 逻辑被优先执行,而 CacheLoader 作为兜底策略存在:
User(userId=xxx, userName=null, department=null)
124用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=xxx, userName=null, department=null)
1.4.2 支持更新锁定能力
这是与上面数据源集成一起的辅助增强能力。在高并发场景下,如果某个 key 值没有命中缓存,大量的请求同步打到下游模块处理的时候,很容易造成缓存击穿问题。

为了防止缓存击穿问题,可以通过加锁的方式来规避。当缓存不可用时,仅持锁的线程
负责从数据库中查询数据并写入缓存中,其余请求重试时先尝试从缓存中获取数据,避免所有的并发请求全部同时打到数据库上。
作为穿透型缓存的保护策略之一,Guava Cache 自带了并发锁定
机制,同一时刻仅允许一个请求去回源获取数据并回填到缓存中,而其余请求则阻塞等待,不会造成数据源的压力过大。
1.5 数据清理与刷新机制
1.5.1 数据过期
对于数据有过期失效诉求的场景,Guava cache 可通过 expireAfterWrite
或expireAfterAccess
设定缓存的过期时间。但数据过期后,会立即被删除吗?
缓存数据删除有几种机制:
删除机制 | 具体说明 |
---|---|
主动删除 | 搞个定时线程不停的去扫描并清理所有已经过期的数据。 |
惰性删除 | 在数据访问的时候进行判断,如果过期则删除此数据。 |
两者结合 | 采用惰性删除为主,低频定时主动删除为兜底,兼顾处理性能与内存占用。 |
在Guava Cache 中,为了最大限度的保证并发性,采用的是惰性删除的策略,而没有设计独立清理线程。所以这里我们就可以回答前面的问题,也即过期的数据,并非是立即被删除的,而是在get
等操作访问缓存记录时触发过期数据的删除操作。
// LocalCache 源码
class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V> {
@Nullable
public V get(@Nullable Object key) {
if (key == null) {
return null;
} else {
int hash = this.hash(key);
return this.segmentFor(hash).get(key, hash); // 最终只会触发这一个分片内的数据清理操作
}
}
static class Segment<K, V> extends ReentrantLock {
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
Preconditions.checkNotNull(key);
Preconditions.checkNotNull(loader);
try {
if (this.count != 0) {
ReferenceEntry<K, V> e = this.getEntry(key, hash);
if (e != null) {
long now = this.map.ticker.read();
V value = this.getLiveValue(e, now);
if (value != null) {
this.recordRead(e, now);
this.statsCounter.recordHits(1);
Object var18 = this.scheduleRefresh(e, key, hash, value, now, loader);
return var18;
}
ValueReference<K, V> valueReference = e.getValueReference();
if (valueReference.isLoading()) {
Object var9 = this.waitForLoadingValue(e, key, valueReference);
return var9;
}
}
}
Object var16 = this.lockedGetOrLoad(key, hash, loader);
return var16;
} catch (ExecutionException var13) {
ExecutionException ee = var13;
Throwable cause = ee.getCause();
if (cause instanceof Error) {
throw new ExecutionError((Error)cause);
} else if (cause instanceof RuntimeException) {
throw new UncheckedExecutionException(cause);
} else {
throw ee;
}
} finally {
this.postReadCleanup(); // 触发可能的清理操作
}
}
void postReadCleanup() {
if ((this.readCount.incrementAndGet() & 63) == 0) { // 并非每次请求都会触发 cleanUp, 会尝试积攒一定次数后再清理
this.cleanUp();
}
}
}
}
在 get 执行逻辑中进行数据过期清理以及重新回源加载的执行判断流程,可以简化为下图中的关键环节:

在执行 get 请求的时候,会先判断下当前查询的数据是否过期,如果已经过期,则会触发对当前操作的Segment
的过期数据清理操作。
为了实现高效的多线程并发控制,Guava Cache 采用了类似 ConcurrentHashMap 一样的
分段锁
机制,数据被分为了不同分片,每个分片同一时间只允许有一个线程执行写操作,这样降低并发锁争夺的竞争压力。而上面代码中也可以看出,执行清理的时候,仅针对当前查询的记录所在的Segment
分片执行清理操作,而其余的分片的过期数据并不会触发清理逻辑。在创建缓存容器的时候将
concurrencyLevel
设置为允许并发数为1,就强制所有的数据都存放在同一个分片中。(concurrencyLevel 值与分段 Segment 的数量关系见 1.6)
1.5.2 数据刷新
除了上述的 2 个过期时间设定方法,Guava Cache 还提供了 refreshAfterWrite
方法,用于设定定时自动 refresh
操作。这能在设定过期时间的基础上,再设定一个每隔1分钟重新 refresh
的逻辑。这样既可以保证数据在缓存中的留存时长,又可以尽可能的缩短缓存变更生效的时间。
若要使用 refreshAfterWrite
方法,创建缓存容器时必须指定 CacheLoader 实例,并覆写 reload
方法,提供一个异步数据加载能力,避免数据刷新操作对业务请求造成阻塞。
// CacheLoader 源码
public abstract class CacheLoader<K, V> {
@GwtIncompatible
public ListenableFuture<V> reload(K key, V oldValue) throws Exception {
Preconditions.checkNotNull(key);
Preconditions.checkNotNull(oldValue);
return Futures.immediateFuture(this.load(key));
}
}

与 expire 清理逻辑相同,refresh 操作依旧是采用一种被动触发的方式来实现。当 get 操作执行的时候会判断下如果创建时间已经超过了设定的刷新间隔,则会重新去执行一次数据的加载逻辑 (前提是数据并没有过期)。
鉴于缓存读多写少的特点,Guava Cache 在数据 refresh 操作执行时,采用了一种非阻塞式的加载逻辑,尽可能的保证并发场景下对读取线程的性能影响。
public class CacheService {
private static class MyCacheLoader extends CacheLoader<String, User> {
@Override
public User load(String s) throws Exception {
System.out.println(Thread.currentThread().getId() + "线程执行CacheLoader.load()...");
Thread.sleep(500L);
System.out.println(Thread.currentThread().getId() + "线程执行CacheLoader.load()结束...");
return new User(s, RandomUtil.randomString(5));
}
@Override
public ListenableFuture<User> reload(String key, User oldValue) throws Exception {
System.out.println(Thread.currentThread().getId() + "线程执行CacheLoader.reload(),oldValue=" + oldValue);
return super.reload(key, oldValue);
}
}
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,直接返回当前内存中的当前值即可。

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

expire
操作就是采用的一种严苛的更新锁定机制,当一个线程执行数据重新加载的时候,其余的线程则阻塞等待。refresh
操作执行过程中不会阻塞其余线程的 get 查询操作,会直接返回旧值。这样的设计各有利弊:
操作 | 优势 | 弊端 |
---|---|---|
expire | 有效防止缓存击穿问题,且阻塞等待的方式可以保证业务层面获取到的缓存数据的强一致性。 | 高并发场景下,如果回源的耗时较长,会导致多个读线程被阻塞等待,影响整体的并发效率。 |
refresh | 可以最大限度的保证查询操作的执行效率,避免过多的线程被阻塞等待。 | 多个线程并发请求同一个 key 对应的缓存值拿到的结果可能不一致,在对于一致性要求特别严苛的业务场景下可能会引发问题。 |
Guava Cache 中的 expire 与 refresh 两种机制,给人的另一个困惑点在于:两者都是被动触发的数据加载逻辑,不管是 expire 还是 refresh,只要超过指定的时间间隔,其实都是依旧存在与内存中,等有新的请求查询的时候,都会执行自动的最新数据加载操作。那这两个实际使用而言,仅仅只需要依据是否需要阻塞加载这个维度来抉择?—— 并非如此。
expire 存在的意义更多的是一种数据生命周期终结的意味,超过了 expire 有效期的数据,虽然依旧会存在于内存中,但是在一些触发了 cleanUp
操作的情况下,是会被释放掉以减少内存占用的。而 refresh 则仅仅只是执行数据更新,框架无法判断是否需要从内存释放掉,会始终占据内存。
所以在具体使用时,需要根据场景综合判断:
- 数据需要永久存储,且不会变更,这种情况下
expire
和refresh
都并不需要设定 - 数据极少变更,或者对变更的感知诉求不强,且并发请求同一个 key 的竞争压力不大,直接使用
expire
即可 - 数据无需过期,但是可能会被修改,需要及时感知并更新缓存数据,直接使用
refresh
- 数据需要过期(避免不再使用的数据始终留在内存中)、也需要在有效期内尽可能保证数据的更新一致性,则采用
expire
与refresh
两者结合。
对于expire 与 refresh 结合使用的场景,两者的时间间隔设置,需要注意:
expire 时间设定要大于 refresh 时间,否则 refresh 将永远没有机会执行
1.6 并发能力支持
1.6.1 分段锁降低锁竞争
前面我们提过 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 加锁执行写入操作。
@Override
public V put(K key, V value) {
// ... 省略部分逻辑
int hash = hash(key);
return segmentFor(hash).put(key, hash, value, false);
}
@Nullable
V 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; // 65536
LocalCache(CacheBuilder<? super K, ? super V> builder, @Nullable CacheLoader<? super K, V> loader) {
concurrencyLevel = Math.min(builder.getConcurrencyLevel(), MAX_SEGMENTS);
// ... 省略其余逻辑
}
1.6.2 try-lock 策略
在 put 等写操作场景下,Guava Cache 采用的是上述分段锁的策略,通过优化锁的粒度,来提升并发的性能。但是加锁毕竟还是对性能有一定的影响的,为了追求更加极致的性能表现,在 get 等读操作自身并没有发现加锁操作 —— 但是 Guava Cache 的 get 等处理逻辑也并非是纯粹的只读操作,它还兼具触发数据淘汰清理操作的删除逻辑,所以只在判断需要执行清理的时候才会尝试去获取锁。
执行清理时,也会减少争抢锁的几率 —— 并非是每次请求都会去触发cleanUp
操作,而是会尝试积攒一定次数后再清理:
static class Segment<K, V> extends ReentrantLock {
void postReadCleanup() {
if ((this.readCount.incrementAndGet() & 63) == 0) {
this.cleanUp();
}
}
}
在高并发场景下,如果查询请求量巨大的情况下,即使按照上述的情况限制每次达到一定请求数量之后才去执行清理操作,依旧可能会出现多个 get 操作线程同时去抢锁执行清理操作的情况,此时为防止清理过程抢不到锁导致读请求阻塞,只采用 try-lock 尝试获取锁,若获取到就执行清理,获取不到就算了。
static class Segment<K, V> extends ReentrantLock {
void cleanUp() {
long now = this.map.ticker.read();
this.runLockedCleanup(now);
this.runUnlockedCleanup();
}
void runLockedCleanup(long now) {
if (this.tryLock()) { // 尝试请求锁,请求到就处理,请求不到就放弃
try {
this.drainReferenceQueues();
this.expireEntries(now);
this.readCount.set(0);
} finally {
this.unlock();
}
}
}
void runUnlockedCleanup() {
if (!this.isHeldByCurrentThread()) {
this.map.processPendingNotifications();
}
}
}
1.7 提供缓存相关的监控统计
引入缓存的一个初衷是希望缓存能够提升系统的处理性能,而有限缓存容量中仅存储部分数据的时候,我们会希望存储的有限数据可以尽可能的覆盖并抗住大部分的请求流量,所以对缓存的命中率会非常关注。
Guava Cache 提供了 stat
统计日志,支持查看缓存数据的加载或命中情况统计。我们可以基于命中情况,不断的去优化代码中缓存的数据策略,以发挥出缓存的最大价值。
Guava Cache 的统计信息封装为 CacheStats
对象进行承载,主要包含一下几个关键指标项:
指标 | 含义说明 |
---|---|
hitCount | 命中缓存次数 |
missCount | 没有命中缓存次数(查询时内存中没有) |
loadSuccessCount | 回源加载的时候加载成功次数 |
loadExceptionCount | 回源加载但是加载失败的次数 |
totalLoadTime | 回源加载操作总耗时 |
evictionCount | 删除记录的次数 |
缓存容器创建时,可通过 recordStats()
开启缓存行为的统计记录:
public static void main(String[] args) {
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.recordStats()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");
User user = userDao.getUser(key);
if (user == null) {
System.out.println(key + "用户不存在");
}
return user;
}
});
try {
System.out.println(cache.get("123");
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
System.out.println(cache.get("126"));
} catch (Exception e) {
} finally {
CacheStats stats = cache.stats();
System.out.println(stats);
}
}
上述代码执行之后结果输出如下:
123用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=123, userName=铁柱, department=研发部)
124用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=123, userName=铁柱, department=研发部)
126用户缓存不存在,尝试CacheLoader回源查找并回填...
126用户不存在
CacheStats{hitCount=1, missCount=3, loadSuccessCount=2, loadExceptionCount=1, totalLoadTime=1972799, evictionCount=0}
可以看出,一共执行了4次请求,其中1次命中,3次回源处理,2次回源加载成功,1次回源没找到数据,与打印出来的 CacheStats
统计结果完全吻合。
1.8 适用场景
在本系列专栏的第一篇文章《聊一聊作为高并发系统基石之一的缓存,会用很简单,用好才是技术活》中,我们在缓存的一步步演进介绍中提过本地缓存与集中式缓存的区别,也聊了各自的优缺点。
作为一款纯粹的本地缓存框架,Guava Cache 具备本地缓存该有的优势,也无可避免的存在着本地缓存的弊端。
维度 | 简要概述 |
---|---|
优势 | 基于空间换时间的策略,利用内存的高速处理效率,提升机器的处理性能,减少大量对外的IO请求交互,比如读取DB、请求外部网络、读取本地磁盘数据等等操作。 |
弊端 | 整体容量受限,可能对本机内存造成压力。此外,对于分布式多节点集群部署的场景,缓存更新场景会出现缓存漂移问题,导致各个节点之间的缓存数据不一致。 |
鉴于上述优劣综合判断,可以大致圈定Guava Cache
的实际适用场合:
1️⃣ 数据读多写少且对一致性要求不高的场景
这类场景中,会将数据缓存到本地内存中,采用定时触发(或者事件推送)的策略重新加载到内存中。这样业务处理逻辑直接从内存读取需要的数据,修改系统配置项之后,需要等待一定的时间后方可生效。
很多的配置中心采用的都是这个缓存策略。统一配置中心中管理配置数据,然后各个业务节点会从统一配置中心拉取配置并存储在自己本地的内存中然后使用本地内存中的数据。这样可以有效规避配置中心的单点故障问题,降低了配置中心的请求压力,也提升了业务节点自身的业务处理性能(减少了与配置中心之间的网络交互请求)。
2️⃣ 对性能要求极其严苛的场景
对于分布式系统而言,集中式缓存是一个常规场景中很好的选项。但是对于一些超大并发量且读性能要求严苛的系统而言,一个请求流程中需要频繁的去与Redis交互,其网络开销也是不可忍受的。所以可以采用将数据本机内存缓存的方式,分散redis的压力,降低对外请求交互的次数,提升接口响应速度。
简单的本地数据缓存,作为 HashMap/ConcurrentHashMap
的替代品
这种场景也很常见,我们在项目中经常会遇到一些数据的需要临时缓存一下,为了方便很多时候直接使用的 HashMap
或 ConcurrentHashMap
来实现。而 Guava Cache 聚焦缓存场景做了很多额外的功能增强(比如数据过期能力支持、容量上限约束等),可以完美替换 HashMap/ConcurrentHashMap,更适合缓存场景使用。
[!info] 承前启后:Caffeine Cache
技术的更新迭代始终没有停歇的时候,Guava 工具包作为 Google 家族的优秀成员,在很多方面提供了非常优秀的能力支持。随着 JAVA8 的普及,Google 也基于语言的新特性,对 Guava Cache 部分进行了重新实现,形成了后来的
Caffeine Cache
,并在 SpringBoot2.x 中取代了 Guava Cache。
1 Guava Cache 使用
1.1 引入依赖
使用Guava Cache,首先需要引入对应的依赖包。对于 Maven 项目,可以在pom.xml
中添加对应的依赖声明即可:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
1.2 创建容器
具体使用前首先面临的就是如何创建 Guava Cache 实例。可以借助CacheBuilder
以一种优雅的方式来构建出合乎我们诉求的 Cache 实例。
对 CacheBuilder 中常见的属性方法,归纳说明如下:
方法 | 含义说明 |
---|---|
newBuilder | 构造出一个Builder实例类 |
initialCapacity | 待创建的缓存容器的初始容量大小(记录条数) |
maximumSize | 指定此缓存容器的最大容量(最大缓存记录条数) |
maximumWeight | 指定此缓存容器的最大容量(最大比重值),需结合weighter 方可体现出效果 |
weighter | 入参为一个函数式接口,用于指定每条存入的缓存数据的权重占比情况。这个需要与maximumWeight 结合使用 |
expireAfterWrite | 设定过期策略,按照数据写入时间进行计算 |
expireAfterAccess | 设定过期策略,按照数据最后访问时间来计算 |
refreshAfterWrite | 设定更新策略,数据写入后多久刷新 (异步刷新) |
concurrencyLevel | 用于控制缓存的并发处理能力,同时支持多少个线程并发写入操作 |
recordStats | 设定开启此容器的数据加载与缓存命中情况统计 |
基于CacheBuilder
及其提供的各种方法,我们可以轻松的进行缓存容器的构建、并指定容器的各种约束条件。
public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.initialCapacity(1000) // 初始初始容量
.maximumSize(10000L) // 设定最大容量
.expireAfterWrite(30L, TimeUnit.MINUTES) // 设定写入过期时间
.concurrencyLevel(8) // 设置最大并发写操作线程数
.refreshAfterWrite(1L, TimeUnit.MINUTES) // 设定自动刷新数据时间
.recordStats() // 开启缓存执行情况统计
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return userDao.getUser(key);
}
});
}
1.3 业务层使用
Guava Cache 容器对象创建完成后,可以基于其提供的对外接口完成相关缓存的具体操作。首先可以了解下 Cache 提供的对外操作接口:

对关键接口的含义梳理归纳如下:
接口名称 | 具体说明 |
---|---|
get | 查询指定 key 对应的 value 值,如果缓存中没匹配,则基于给定的Callable 逻辑去获取数据回填缓存中并返回 |
getIfPresent | 如果缓存中存在指定的 key 值,则返回对应的 value 值,否则返回 null(此方法不会触发自动回源与回填操作) |
getAllPresent | 针对传入的key列表,返回缓存中存在的对应 value 值列表(不会触发自动回源与回填操作) |
put | 往缓存中添加 key-value 键值对 |
putAll | 批量往缓存中添加 key-value 键值对 |
invalidate | 从缓存中删除指定的记录 |
invalidateAll | 从缓存中批量删除指定记录,如果无参数,则清空所有缓存 |
size | 获取缓存容器中的总记录数 |
stats | 获取缓存容器当前的统计数据 |
asMap | 将缓存中的数据转换为ConcurrentHashMap 格式返回 |
cleanUp | 清理所有的已过期的数据 |
在项目中,可以基于上述接口,实现各种缓存操作功能。
public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache6();
cache.put("122", new User("122"));
cache.put("122", new User("122"));
System.out.println("put操作后查询:" + cache.getIfPresent("122"));
cache.invalidate("122");
System.out.println("invalidate操作后查询:" + cache.getIfPresent("122"));
System.out.println(cache.stats());
}
执行后,结果如下:
put操作后查询:User(userId=122, userName=null, department=null)
invalidate操作后查询:null
CacheStats{hitCount=1, missCount=1, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0}
当然,上述示例代码中这种使用方式有个明显的弊端就是业务层面对 Guava Cache 的私有API
依赖过深,后续如果需要替换 Cache 组件的时候会比较痛苦,需要对业务调用的地方进行大改。所以真正项目里面,最好还是对其适当封装,以实现业务层面的解耦。如果你的项目是使用 Spring 框架,也可以基于Spring Cache
统一规范来集成并使用 Guava Cache,降低对业务逻辑的侵入。
参考链接
重新认识下JVM级别的本地缓存框架Guava Cache——优秀从何而来
重新认识下JVM级别的本地缓存框架Guava Cache(2)——深入解读其容量限制与数据淘汰策略
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)