说明
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() { Cache<Integer, Integer> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .maximumSize(100) .build(); int key = 1; System.out.println(cache.getIfPresent(key)); System.out.println(cache.get(key, integer -> 123));
System.out.println(cache.getIfPresent(key));
int value = 12345; cache.put(key, value);
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
函数,如果返回值则将其插入缓存中,并且返回,这是一种同步的操作,也支持批量查找。
使用 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(new CacheLoader<Integer, Integer>() { @Nullable @Override public Integer load(@NonNull Integer key) { return getInDB(key); } });
int key = 1; Integer value = cache.get(key); System.out.println(value);
Map<Integer, Integer> dataMap = cache.getAll(Arrays.asList(1, 2, 3)); System.out.println(dataMap); }
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
|
private int getInDB(int key) { return key * 111; }
@Test public void test() throws ExecutionException, InterruptedException { AsyncCache<Integer, Integer> asyncCache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.MINUTES) .maximumSize(100) .executor(Executors.newSingleThreadExecutor()).buildAsync();
Integer key = 1; CompletableFuture<Integer> future = asyncCache.get(key, key1 -> { 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算法实现的一种缓存工具
LFU
LFU(Least Frequently Used)最近最少频率使用:其基本原理是对每个在缓存中的对象进行计数,记录其被访问的次数。当缓存满了需要淘汰某些对象时,LFU算法会优先淘汰那些被访问次数最少的对象。
W-TinyLFU
W-TinyLFU( Window Tiny Least Frequently Used):记录了近期访问记录的频率信息,不满足的记录不会进入到缓存。使用 Count-Min Sketch
算法记录访问记录的频率信息。Caffeine 使用的就是 Window TinyLfu 淘汰策略,此策略提供了一个近乎最佳的命中率。
- 优点:对于识别和处理长期和突发的热数据表现良好
- 缺点:相比于更简单的算法如 LRU,它需要更多的资源和精细的配置
Count-Min Sketch(CMS)是一种概率型数据结构,通常用于对数据流中的元素频率进行估计。它在有限的内存空间下提供了对频率估计的快速、轻量级的方法,该算法和布隆过滤器有着相似的原理。
以下是Count-Min Sketch的一些关键特点:
- 基本原理:CMS使用一个二维的数组(counters matrix)来估计元素的频率。这个数组的维度由两个参数决定,一个是深度(number of hash functions),另一个是宽度(number of counters per hash function)。
- 哈希函数:使用多个哈希函数来映射元素到二维数组的不同位置。这有助于减小哈希冲突的可能性。
- 计数更新:对于数据流中的每个元素,通过哈希函数计算出其在数组中的位置,并将相应位置的计数器递增。
- 估计频率:要估计元素的频率,需要使用相同的哈希函数计算出元素在数组中的位置,并取所有位置的计数器的最小值。这是因为多个哈希函数的使用可能导致冲突,通过取最小值可以减小估计误差。
- 误差和置信水平: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() .maximumSize(1) .build();
cache.put(1, 1); System.out.println("缓存个数:" + cache.estimatedSize() + " 缓存值:" + cache.getIfPresent(1));
cache.put(2, 2); Thread.sleep(1000); 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() .maximumWeight(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
|
@Test public void testEvictionAfterProcess() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder() .expireAfterAccess(2, TimeUnit.SECONDS) .scheduler(Scheduler.systemScheduler()) .build();
cache.put(1, 2); Thread.sleep(1500); System.out.println(cache.getIfPresent(1)); Thread.sleep(1500); System.out.println(cache.getIfPresent(1)); 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
|
@Test public void testEvictionAfterWrite() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder() .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
|
@Test public void testEvictionAfter() throws InterruptedException { Cache<Integer, Integer> cache = Caffeine.newBuilder() .expireAfter(new Expiry<Integer, Integer>() { @Override public long expireAfterCreate(@NonNull Integer key, @NonNull Integer value, long currentTime) { return TimeUnit.SECONDS.toNanos(2); }
@Override public long expireAfterUpdate(@NonNull Integer key, @NonNull Integer value, long currentTime, @NonNegative long currentDuration) { return TimeUnit.SECONDS.toNanos(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() .weakKeys() .weakValues() .build(); cache.put(1, 2); System.out.println(cache.getIfPresent(1));
System.gc();
System.out.println(cache.getIfPresent(1)); }
@Test public void testSoft() { Cache<Integer, Integer> cache = Caffeine.newBuilder() .softValues() .build(); cache.put(1, 2); System.out.println(cache.getIfPresent(1));
System.gc();
System.out.println(cache.getIfPresent(1)); }
|
手动删除
这个不算是淘汰机制,Caffeine中提供了手动进行缓存的删除,无需等待我们上面提到的被动的一些删除策略
1 2
| cache.invalidate(); cache.invalidateAll();
|
淘汰监听
移除事件 RemovalListener
是一种缓存监听事件,当key被移除的时候就会触发这个方法,可以进行一些相关联的操作(可以用作兜底机制,避免数据丢失)。
RemovalListener
可以获取到 key
、value
和 RemovalCause
(删除的原因)。另外 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
:数据被手动的removeREPLACED
:就是替换了,也就是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() .expireAfterWrite(3, TimeUnit.SECONDS) .refreshAfterWrite(2, TimeUnit.SECONDS) .build(key -> key + 1);
cache.get(1, Integer -> 1);
Thread.sleep(2500); System.out.println(cache.getIfPresent(1));
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首先查找 key
为 id
的 cache
中是否有对应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) { } }
|
在这个例子中,无论有多少线程尝试使用相同的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)
|