高性能本地缓存库 Caffeine
很久没写博客了,就总结一下之前项目用过的一些组件 – 本地缓存 Caffeine
Caffeine是基于Java的一个高性能本地缓存库,由Guava改进而来;
本文介绍了如何在Java中使用Caffeine缓存,以及如何在SpringBoot中集成Caffeine缓存;
源代码:
Java高性能缓存库Caffeine
Caffeine简介
Caffeine是一个Java高性能的本地缓存库。其官方说明指出,其缓存命中率已经接近最优值。
实际上,Caffeine这样的本地缓存和ConcurrentMap很像:支持并发,并且支持O(1)时间复杂度的数据存取。二者的主要区别在于:
- ConcurrentMap将存储所有存入的数据,直到你显式将其移除;
- Caffeine将通过给定的配置,自动移除“不常用”的数据,以保持内存的合理占用。
因此,一种更好的理解方式是:
Cache是一种带有存储和移除策略的Map
Caffeine提供如下的一些功能:
1 |
|
Caffeine基本使用
在项目中添加依赖:
1 |
|
本文基于 2.9.3 版本;
缓存类型
Caffeine提供了四种类型的Cache,对应着四种加载策略:
- Cache;
- LoadingCache;
- AsyncCache;
- AsyncLoadingCache;
下面分别来看;
Cache
最普通的一种缓存,无需指定加载方式,需要手动调用put()
进行加载;
需要注意的是:put()
方法对于已存在的key将进行覆盖,这点和Map的表现是一致的;
在获取缓存值时,如果想要在缓存值不存在时,原子地将值写入缓存,则可以调用get(key, k -> value)
方法,该方法将避免写入竞争;
调用invalidate()
方法,将手动移除缓存;
多线程情况下,当使用get(key, k -> value)
时,如果有另一个线程同时调用本方法进行竞争,则后一线程会被阻塞,直到前一线程更新缓存完成;
而若另一线程调用getIfPresent()
方法,则会立即返回null,不会被阻塞;
cache/caffeine/basic/src/main/java/io/github/jasonkayzk/type/CacheDemo.java
1 |
|
LoadingCache
LoadingCache是一种自动加载的缓存;
和普通缓存不同的地方在于:当缓存不存在/缓存已过期时,若调用get()
方法,则会自动调用CacheLoader.load()
方法加载最新值;
调用getAll()
方法将遍历所有的key调用get()
,除非实现了CacheLoader.loadAll()
方法。
使用LoadingCache时,需要指定CacheLoader,并实现其中的load()
方法供缓存缺失时自动加载。
多线程情况下,当两个线程同时调用get()
,则后一线程将被阻塞,直至前一线程更新缓存完成。
cache/caffeine/basic/src/main/java/io/github/jasonkayzk/type/LoadingCacheDemo.java
1 |
|
AsyncCache
AsyncCache是Cache的一个变体,其响应结果均为CompletableFuture,通过这种方式,AsyncCache对异步编程模式进行了适配;
默认情况下,缓存计算使用ForkJoinPool.commonPool()
作为线程池,如果想要指定线程池,则可以覆盖并实现Caffeine.executor(Executor)
方法。
synchronous()
提供了阻塞直到异步缓存生成完毕的能力,它将以Cache进行返回。
多线程情况下,当两个线程同时调用get(key, k -> value)
,则会返回同一个CompletableFuture对象。由于返回结果本身不进行阻塞,可以根据业务设计自行选择阻塞等待或者非阻塞。
cache/caffeine/basic/src/main/java/io/github/jasonkayzk/type/AsyncCacheDemo.java
1 |
|
AsyncLoadingCache
显然这是Loading Cache和Async Cache的功能组合。AsyncLoadingCache支持以异步的方式,对缓存进行自动加载。
类似LoadingCache,同样需要指定CacheLoader,并实现其中的load()
方法供缓存缺失是自动加载,该方法将自动在ForkJoinPool.commonPool()
线程池中提交。如果想要指定Executor,则可以实现AsyncCacheLoader().asyncLoad()
方法。
cache/caffeine/basic/src/main/java/io/github/jasonkayzk/type/AsyncLoadingCacheDemo.java
1 |
|
驱逐策略
驱逐策略在创建缓存的时候进行指定;
常用的有:基于容量的驱逐和基于时间的驱逐;
- 基于容量的驱逐:需要指定缓存容量的最大值;当缓存容量达到最大时,Caffeine将使用LRU策略对缓存进行淘汰;
- 基于时间的驱逐:可以设置在最后访问/写入一个缓存经过指定时间后,自动进行淘汰;
驱逐策略可以组合使用,任意驱逐策略生效后,该缓存条目即被驱逐;
1 |
|
刷新机制
试想这样一种情况:当缓存运行过程中,有些缓存值我们需要定期进行刷新,以确保信息可以正确被同步到缓存中来;
我们当然可以使用基于时间的驱逐策略expireAfterWrite()
,但带来的问题是:一旦缓存过期,下次重新加载缓存时将使得调用线程处于阻塞状态;
而使用刷新机制refreshAfterWrite()
,Caffeine将在key允许刷新后的首次访问时,立即返回旧值,同时异步地对缓存值进行刷新,这使得调用方不至于因为缓存驱逐而被阻塞;
需要注意的是:刷新机制只支持LoadingCache和AsyncLoadingCache;
通过覆写CacheLoader.reload()
方法,将在刷新时使得旧缓存值参与其中;
1 |
|
统计
Caffeine内置了数据收集功能,通过Caffeine.recordStats()
方法,可以打开数据收集;
这样Cache.stats()
方法将会返回当前缓存的一些统计指标,例如:
-
hitRate
:查询缓存的命中率 -
evictionCount
:被驱逐的缓存数量 -
averageLoadPenalty
:新值被载入的平均耗时
1 |
|
输出:
1 |
|
SpringBoot中集成Caffeine
SpringBoot缓存管理器
Spring从3.1开始就引入了对Cache的支持。定义了org.springframework.cache.Cache
和org.springframework.cache.CacheManager
接口,来统一不同的缓存技术,并支持使用JCache(JSR-107)
注解来简化开发。
- Cache接口包括了缓存的各种操作集合,实际操作缓存时,即通过这些接口进行操作。
- Cache接口下Spring提供了各种xxxCache的实现。由于官方从SpringBoot 2.x后,将Caffeine代替Guava作为默认的缓存组件,因此这里我们需要用到的就是
CaffeineCache
这个类。如果需要自定义Cache实现,只需要实现Cache接口即可。 - CacheManager 定义了创建、配置、获取、管理和控制多个唯一命名的 Cache。这些 Cache 存在于 CacheManager 的上下文中。
创建一个缓存管理器:
1 |
|
使用这种方式,可以同时在缓存管理器中添加多个缓存。需要注意的是,SimpleCacheManager只能使用Cache和LoadingCache,异步缓存将无法支持。
使用@Cacheable相关注解
@Cacheable相关注解
添加完成缓存管理器后,我们可以方便地使用@Cacheable
相关注解对缓存进行管理了。为了使用该注解,需要引入如下依赖:
1 |
|
和@Cacheable相关的常用的注解包括:
-
@Cacheable
:表示该方法支持缓存。当调用被注解的方法时,如果对应的键已经存在缓存,则不再执行方法体,而从缓存中直接返回。当方法返回null时,将不进行缓存操作。 -
@CachePut
:表示执行该方法后,其值将作为最新结果更新到缓存中。每次都会执行该方法。 -
@CacheEvict
:表示执行该方法后,将触发缓存清除操作。 -
@Caching
:用于组合前三个注解,例如:
1 |
|
这类注解也同时可以标记在一个类上,表示该类的所有方法都支持对应的缓存注解。
常用注解属性
@Cacheable
常用的注解属性如下:
-
cacheNames/value
:缓存组件的名字,即cacheManager中缓存的名称。 -
key
:缓存数据时使用的key。默认使用方法参数值,也可以使用SpEL表达式进行编写。 -
keyGenerator
:和key二选一使用。 -
cacheManager
:指定使用的缓存管理器。 -
condition
:在方法执行开始前检查,在符合condition的情况下,进行缓存 -
unless
:在方法执行完成后检查,在符合unless的情况下,不进行缓存 -
sync
:是否使用同步模式。若使用同步模式,在多个线程同时对一个key进行load时,其他线程将被阻塞。
下面是一个注解使用示例:
1 |
|
该方法使用的缓存为UnitCache,并且手动指定缓存的key是#unitType + Constants.SPLIT_STR + #unitId
的拼接结果。该缓存将在#unitType != 'weapon'
时生效。
缓存同步模式
@Cacheable
注解支持配置同步模式。在不同的Caffeine配置下,对是否开启同步模式进行观察。
Caffeine缓存类型 | 是否开启同步 | 多线程读取不存在/已驱逐的key | 多线程读取待刷新的key |
---|---|---|---|
Cache | 否 | 各自独立执行被注解方法 | - |
Cache | 是 | 线程1执行被注解方法,线程2被阻塞,直至缓存更新完成 | - |
LoadingCache | 否 | 线程1执行load() ,线程2被阻塞,直至缓存更新完成 |
线程1使用老值立即返回,并异步更新缓存值;线程2立即返回,不进行更新。 |
LoadingCache | 是 | 线程1执行被注解方法,线程2被阻塞,直至缓存更新完成 | 线程1使用老值立即返回,并异步更新缓存值;线程2立即返回,不进行更新。 |
从上面的总结可以看到,sync开启或关闭,在Cache和LoadingCache中的表现是不一致的:
- Cache中,sync表示是否需要所有线程同步等待
- LoadingCache中,sync表示在读取不存在/已驱逐的key时,是否执行被注解方法
事实上,Cache AOP的读取流程中并没有进行加锁处理,这个参数的实际表现形式是由缓存实现方决定的。在使用Caffeine Cache时,可以根据上表,快速找到合适的组合方式。