说明

Spring Boot 1.x版本中的默认本地缓存是Guava Cache。在 Spring5 (SpringBoot 2.x)后,Spring 官方放弃了 Guava Cache 作为缓存机制,而是使用性能更优秀的 Caffeine 作为默认缓存组件。

适用场景

因为Caffeine cache是类似于 Guava cache 的一种内存缓存,所以适合单机的数据缓存;因为存储在内存的,没有持久化,因此适合一些短期或者启动以及结果信息的短暂缓存。当涉及到多机多服务的缓存时候,属于分布式缓存的范畴,可以使用Redis、memcached等分布式的缓存组件。

使用

引入依赖

1
2
3
4
5
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.2</version>
</dependency>

缓存填充策略

手动加载

手动加载,最普通的一种缓存,无需指定加载方式,需要手动调用put()进行加载。需要注意的是,put()方法对于已存在的key将进行覆盖。

在获取缓存值时,如果想要在缓存值不存在时,原子地将值写入缓存,则可以调用get(key, k -> value)方法,该方法将避免写入竞争。

多线程情况下,当使用get(key, k -> value)时,如果有另一个线程同时调用本方法进行竞争,则后一线程会被阻塞,直到前一线程更新缓存完成;而若另一线程调用getIfPresent()方法,则会立即返回null,不会被阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class CaffeineManualTest {

@Test
public void test() {
// 初始化缓存,写入缓存5秒过期,缓存最大个数100个
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS)
.maximumSize(100)
.build();
int key = 1;
// 使用getIfPresent方法从缓存中获取值。如果缓存中不存指定的值,则方法将返回 null:
System.out.println(cache.getIfPresent(key));

// 调用get(key, k -> value)方法,在获取缓存值时,如果想要在缓存值不存在时,原子的将值写入缓存
System.out.println(cache.get(key, integer -> 123));

// 校验key对应的value是否插入缓存中
System.out.println(cache.getIfPresent(key));

// 手动put数据填充缓存中
int value = 12345;
cache.put(key, value);

// 使用getIfPresent方法从缓存中获取值。如果缓存中不存指定的值,则方法将返回 null:
System.out.println(cache.getIfPresent(key));

// 手动移除数据,让数据失效
cache.invalidate(key);
System.out.println(cache.getIfPresent(key));
}
}

测试缓存自动过期

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void test1() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS)
.build();

int key = 1;
int value = 12345;
cache.put(key, value);
System.out.println(cache.getIfPresent(key));
Thread.sleep(5001);
System.out.println(cache.getIfPresent(key));
}

同步加载

LoadingCache 是一种自动加载的缓存。其和普通缓存不同的地方在于:当缓存不存在或已过期时,会同步加载数据

所谓的同步加载数据指的是,在get不到数据时最终会调用build构造时提供的 CacheLoader 对象中的 load 函数,如果返回值则将其插入缓存中,并且返回,这是一种同步的操作,也支持批量查找。

  • 调用get()方法,则会自动调用 CacheLoader.load() 方法来构建缓存的值

  • 调用getAll()方法,默认会遍历所有的key 调用get()方法 ,除非自己手动实现了 CacheLoader.loadAll() 方法

使用 LoadingCache 时,需要指定 CacheLoader,并实现其中的 load() 方法供缓存缺失时自动加载。

多线程情况下,当两个线程同时调用get(),则后一线程将被阻塞,直至前一线程更新缓存完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Test
public void test() {
LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100)
// 同步填充,在build方法中重写load()方法,这里没有重写loadAll()方法
.build(new CacheLoader<Integer, Integer>() {
@Nullable
@Override
public Integer load(@NonNull Integer key) {
return getInDB(key);
}
});

int key = 1;
// get数据,取不到则从数据库中读取相关数据,该值也会插入缓存中:
Integer value = cache.get(key);
System.out.println(value);

// 支持直接get一组值,支持批量查找
Map<Integer, Integer> dataMap
= cache.getAll(Arrays.asList(1, 2, 3));
System.out.println(dataMap);
}

/**
* 模拟从数据库中读取key
*
* @param key
* @return
*/
private int getInDB(int key) {
System.out.println("从数据库中取值" + key);
return key * 111;
}

异步加载

AsyncCache是Cache的一个变体,其响应结果均为 CompletableFuture

通过这种方式,AsyncCache对异步编程进行了适配。默认情况下,缓存计算使用ForkJoinPool.commonPool()作为线程池,如果想要指定线程池,则可以覆盖并实现Caffeine.executor(Executor)方法。

多线程情况下,当两个线程同时调用get(key, k -> value),则会返回「同一个CompletableFuture对象」。由于返回结果本身不进行阻塞,可以根据业务设计自行选择阻塞等待或者非阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 模拟从数据库中读取key
*
* @param key
* @return
*/
private int getInDB(int key) {
return key * 111;
}

@Test
public void test() throws ExecutionException, InterruptedException {
// 使用executor设置线程池
AsyncCache<Integer, Integer> asyncCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100)
.executor(Executors.newSingleThreadExecutor()).buildAsync();

Integer key = 1;
// get返回的是CompletableFuture
CompletableFuture<Integer> future = asyncCache.get(key, key1 -> {
// 执行所在的线程不在是main,而是ForkJoinPool线程池提供的线程
System.out.println("当前线程:" + Thread.currentThread().getName());
int value = getInDB(key1);
return value;
});

int value = future.get();
System.out.println("当前所在线程:" + Thread.currentThread().getName());
System.out.println(value);
}

淘汰算法

Caffeine 与 ConcurrentHashMap 相比,最明显的一点便是提供了一套完整的淘汰机制,我们首先要理解缓存淘汰算法。一个优秀的淘汰算法能使效率大幅提升,因为缓存过程总会伴随着数据的淘汰。

FIFO

FIFO(First In First Out)先进先出:以时序为基准,最先进入缓存的数据会最先被淘汰。当缓存满时,把最早放入缓存的数据淘汰掉。

  • 优点:实现简单,对于某些不常重复请求的应用效果较好

  • 缺点:并未考虑到数据项的访问频率和访问时间,可能淘汰的是最近和频繁访问的数据

LRU

LRU(Least Recently Used)最近最久未使用:此算法根据数据的历史访问记录来进行决策,最久未被访问的数据将被淘汰。LRU通过维护一个所有缓存项的链表,最近访问的数据会被插入到链表的头部,如果缓存满了,就会从链表尾部开始移除数据。Guava cache 就是基于LRU算法实现的一种缓存工具

  • 优点:LRU考虑了最近的数据访问模式,对于局部性原理的表现优秀,简单实用

  • 缺点:如果是短暂持续性冷数据流量,会导致热数据的淘汰

LFU

LFU(Least Frequently Used)最近最少频率使用:其基本原理是对每个在缓存中的对象进行计数,记录其被访问的次数。当缓存满了需要淘汰某些对象时,LFU算法会优先淘汰那些被访问次数最少的对象。

  • 优点:LFU能够较好地处理长期访问稳定、频率较高的情况,因为这样可以确保频繁访问的对象不容易被淘汰

  • 缺点:对于一些暂时高频访问但之后不再访问的对象,LFU无法有效处理。因为这些对象的访问次数已经非常高,之后即使不再访问,也不容易被淘汰,可能造成缓存空间的浪费。并且LFU需要维护所有对象的访问计数,这可能会消耗比较多的存储空间和计算资源

W-TinyLFU

W-TinyLFU( Window Tiny Least Frequently Used):记录了近期访问记录的频率信息,不满足的记录不会进入到缓存。使用 Count-Min Sketch 算法记录访问记录的频率信息。Caffeine 使用的就是 Window TinyLfu 淘汰策略,此策略提供了一个近乎最佳的命中率。

  • 优点:对于识别和处理长期和突发的热数据表现良好
  • 缺点:相比于更简单的算法如 LRU,它需要更多的资源和精细的配置

Count-Min Sketch(CMS)是一种概率型数据结构,通常用于对数据流中的元素频率进行估计。它在有限的内存空间下提供了对频率估计的快速、轻量级的方法,该算法和布隆过滤器有着相似的原理。

以下是Count-Min Sketch的一些关键特点:

  1. 基本原理:CMS使用一个二维的数组(counters matrix)来估计元素的频率。这个数组的维度由两个参数决定,一个是深度(number of hash functions),另一个是宽度(number of counters per hash function)。
  2. 哈希函数:使用多个哈希函数来映射元素到二维数组的不同位置。这有助于减小哈希冲突的可能性。
  3. 计数更新:对于数据流中的每个元素,通过哈希函数计算出其在数组中的位置,并将相应位置的计数器递增。
  4. 估计频率:要估计元素的频率,需要使用相同的哈希函数计算出元素在数组中的位置,并取所有位置的计数器的最小值。这是因为多个哈希函数的使用可能导致冲突,通过取最小值可以减小估计误差。
  5. 误差和置信水平:CMS提供了在一定误差范围内对频率的估计,误差和置信水平可以通过调整深度和宽度来控制。

淘汰机制(过期策略)

基于大小

基于大小的回收策略有两种方式:一种是基于缓存大小,一种是基于权重。

基于缓存大小淘汰,设置方式:maximumSize 。当缓存超出后,使用 W-TinyLFU 算法进行缓存淘汰处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void test() throws InterruptedException {

Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 初始化缓存,缓存最大个数为1
.maximumSize(1)
.build();

cache.put(1, 1);
// 打印缓存个数,结果为1
System.out.println("缓存个数:" + cache.estimatedSize() + " 缓存值:" + cache.getIfPresent(1));

cache.put(2, 2);
// 淘汰数据是一个异步的过程,休眠一秒等异步的回收结束
Thread.sleep(1000);
// 打印缓存个数,结果为1
System.out.println("缓存个数:" + cache.estimatedSize()
+ " key1的为缓存值:" + cache.getIfPresent(1)
+ "\nkey2的为缓存值:" + cache.getIfPresent(2));
}

基于权重过期:权重大小不会决定缓存满时清楚的优先级

weigher() 方法可以指定缓存所占比重,maximumWeight() 方法指定最大的权重阈值,当添加缓存超过规定权重后,进行数据淘汰

1
2
3
4
5
6
7
Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置最大权重为2
.maximumWeight(1)
// 权重的计算方式
// 这里是将每个元素的权重都被设置为1
.weigher((key, value) -> 1)
.build();

基于时间过期

  • expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。
  • expireAfterWrite(long, TimeUnit):在最后一次写入缓存后开始计时,在指定的时间后过期。
  • expireAfter(Expiry):自定义策略,过期时间由Expiry实现独自计算。

访问后到期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 访问后到期
*
* @throws InterruptedException
*/
@Test
public void testEvictionAfterProcess() throws InterruptedException {

Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置访问2秒后数据到期
.expireAfterAccess(2, TimeUnit.SECONDS)
// 使用了自定义调度器,没有用默认
.scheduler(Scheduler.systemScheduler())
.build();

cache.put(1, 2);
// 等1.5s
Thread.sleep(1500);
System.out.println(cache.getIfPresent(1));
// 等1.5s
Thread.sleep(1500);
System.out.println(cache.getIfPresent(1));
// 等2.1s
Thread.sleep(2100);
System.out.println(cache.getIfPresent(1));
}

写入后到期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 写入后到期
*
* @throws InterruptedException
*/
@Test
public void testEvictionAfterWrite() throws InterruptedException {

Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置写入2秒后数据到期
.expireAfterWrite(2, TimeUnit.SECONDS)
.scheduler(Scheduler.systemScheduler())
.build();

cache.put(1, 2);
System.out.println(cache.getIfPresent(1));

Thread.sleep(3000);

System.out.println(cache.getIfPresent(1));
}

自定义过期时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 自定义过期时间
*
* @throws InterruptedException
*/
@Test
public void testEvictionAfter() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfter(new Expiry<Integer, Integer>() {
// 创建2秒后过期,Caffeine库要求返回的时间单位是纳秒
@Override
public long expireAfterCreate(@NonNull Integer key, @NonNull Integer value, long currentTime) {
return TimeUnit.SECONDS.toNanos(2);
}

// 更新2秒后过期,可以看到这里必须要转换为纳秒
@Override
public long expireAfterUpdate(@NonNull Integer key, @NonNull Integer value, long currentTime, @NonNegative long currentDuration) {
return TimeUnit.SECONDS.toNanos(2);
}

// 读2秒后过期,可以看到这里必须要转换为纳秒
@Override
public long expireAfterRead(@NonNull Integer key, @NonNull Integer value, long currentTime, @NonNegative long currentDuration) {
return TimeUnit.SECONDS.toNanos(3);
}
}).scheduler(Scheduler.systemScheduler())
.build();

// 创建
cache.get(1, integer -> 2);

System.out.println(cache.getIfPresent(1));

Thread.sleep(3000);

System.out.println(cache.getIfPresent(1));
}

基于引用过期

Java中四种引用类型

引用类型被垃圾回收时间用途生存时间
强引用(Strong Reference)从来不会被回收对象的一般状态JVM停止运行时终止
软引用(Soft Reference)内存不足时回收缓存对象内存不足时终止
弱引用(Weak Reference)下一次GC时回收缓存对象gc运行后终止
虚引用(Phantom Reference)随时回收可以用虚引用来跟踪对象被垃圾回收器回收的活动
当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知
随时回收

AsyncLoadingCache不支持弱引用和软引用,并且Caffeine.weakValues()和Caffeine.softValues()不可以一起使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Test
public void testWeak() {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置Key为弱引用,生命周期是下次gc的时候
.weakKeys()
// 设置value为弱引用,生命周期是下次gc的时候
.weakValues()
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));

// 以通过System.gc()方法请求进行垃圾回收,但是执不执行要看JVM
System.gc();

System.out.println(cache.getIfPresent(1));
}

@Test
public void testSoft() {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
// 设置value为软引用,生命周期是GC时并且堆内存不够时触发清除
.softValues()
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));

// 强行调用一次GC
System.gc();

System.out.println(cache.getIfPresent(1));
}

手动删除

这个不算是淘汰机制,Caffeine中提供了手动进行缓存的删除,无需等待我们上面提到的被动的一些删除策略

1
2
cache.invalidate();
cache.invalidateAll();

淘汰监听

移除事件 RemovalListener 是一种缓存监听事件,当key被移除的时候就会触发这个方法,可以进行一些相关联的操作(可以用作兜底机制,避免数据丢失)。

RemovalListener 可以获取到 keyvalueRemovalCause(删除的原因)。另外 RemovalListener 中操作是线程池异步执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 移除监听器
*/
@Test
public void removeTest() {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.maximumSize(1)
.removalListener((o, o2, removalCause) -> System.out.println(o + " is " + "remove" + " reason is " + removalCause.name()))
.build();

cache.get(1, integer -> 2);
cache.invalidate(1);

}

目前数据被淘汰的原因

  • EXPLICIT:数据被手动的remove
  • REPLACED:就是替换了,也就是put数据的时候旧的数据被覆盖导致的移除
  • COLLECTED:这个有歧义点,其实就是收集,也就是垃圾回收导致的,一般是用弱引用或者软引用会导致这个情况
  • EXPIRED:数据过期,无需解释的原因
  • SIZE:个数超过限制导致的移除

刷新机制

refreshAfterWrite 提供了更精细的控制,允许在写入后的一定时间内自动刷新,而不仅仅是在访问后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
public void test() throws InterruptedException {

LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
// 写入后3秒后数据过期
.expireAfterWrite(3, TimeUnit.SECONDS)
// 2秒后如果有数据访问则刷新数据
.refreshAfterWrite(2, TimeUnit.SECONDS)
// 构建缓存方式
.build(key -> key + 1);

cache.get(1, Integer -> 1);

// 休眠2.5秒,后取值
Thread.sleep(2500);
System.out.println(cache.getIfPresent(1));

// 休眠1.5秒,后取值
Thread.sleep(1500);
System.out.println(cache.getIfPresent(1));
}

统计

Caffeine内置了数据收集功能,通过Caffeine.recordStats()方法,可以打开数据收集。这样Cache.stats()方法将会返回当前缓存的一些统计指标,例如:

  • hitRate:查询缓存的命中率。
  • evictionCount:被驱逐的缓存数量。
  • averageLoadPenalty:新值被载入的平均耗时。
1
2
Cache<String, String> cache = Caffeine.newBuilder().recordStats().build();
cache.stats(); // 获取统计指标

与Spring Boot集成

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.2</version>
</dependency>

创建一个配置类,并创建一个CacheManager Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
@EnableCaching
public class CachingConfig {

@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(caffeineCacheBuilder());
return cacheManager;
}

Caffeine<Object, Object> caffeineCacheBuilder() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
.expireAfterAccess(10, TimeUnit.MINUTES)
.weakKeys()
.recordStats();
}
}

配置完毕之后,就可以直接配合注解进行使用了

在这个例子中,我们在 getById() 方法上添加了 @Cacheable 注解

每次调用该方法时,Spring首先查找 keyidcache 中是否有对应id的条目。如果有,就返回缓存的值,否则调用方法并将结果存入cache。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class SomeService {

@Cacheable("id")
public Integer getById(int id) {
return findById(id);
}

private Integer findById(int id) {
return id;
}
}

@Cacheable 注解常用的属性如下:

  • cacheNames/value:缓存组件的名字,即cacheManager中缓存的名称。
  • key:缓存数据时使用的key。默认使用方法参数值,也可以使用SpEL表达式进行编写。
  • keyGenerator:和key二选一使用。
  • cacheManager:指定使用的缓存管理器。
  • condition:在方法执行开始前检查,在符合condition的情况下,进行缓存。
  • unless:在方法执行完成后检查,在符合unless的情况下,不进行缓存。
  • sync:是否使用同步模式。若使用同步模式,在多个线程同时对一个key进行load时,其他线程将被阻塞。

@Cacheable默认的行为模式是不同步的。这意味着如果你在多线程环境中使用它,并且有两个或更多的线程同时请求相同的数据,那么可能会出现缓存击穿的情况。也就是说,所有请求都会达到数据库,因为在第一个请求填充缓存之前,其他所有请求都不会发现缓存项。

Spring 4.1引入了一个新属性sync来解决这个问题。如果设置@Cacheable(sync=true),则只有一个线程将执行该方法并将结果添加到缓存,其他线程将等待。

1
2
3
4
5
6
7
8
@Service
public class BookService {

@Cacheable(cacheNames="books", sync=true)
public Book findBook(ISBN isbn) {
// 这里是一些查找书籍的慢速方法,如数据库查询,API调用等
}
}

在这个例子中,无论有多少线程尝试使用相同的ISBN查找相同的书,只有一个线程会实际执行findBook方法并将结果存储在名为books 的缓存中。其他线程将等待,然后从缓存中获取结果,而不需要执行findBook方法。

Spring Cache还支持 Spring Expression Language (SpEL) 表达式。可以通过 SpEL 在缓存名称或键中插入动态值

#root.args[0] 指的是方法调用的第一个参数,也就是 isbn

1
2
@Cacheable(value = "books", key = "#root.args[0]")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)