一、前言
缓存在我们的日常开发中具有极高的使用频率,当一个系统遇到性能瓶颈的时候往往会考虑使用缓存来解决问题。
对于那些访问频率高、更新频率低的数据,我们可以考虑把查询结果保存起来,这样下次查询的时候直接根据key到缓存中查询数据,从而极大的降低数据库的访问压力、提高数据访问速度。
我们可以把缓存分为两大类:分布式缓存和本地缓存。
Java本地缓存可以通过使用Java提供的各种数据结构(如HashMap或ConcurrentHashMap)或使用专门的缓存框架(如Guava或Caffeine)来实现。 今天我们就来深入分析一下本地缓存的特点和用法。
二、本地缓存技术介绍
1.HashMap
通过Map的底层方式,直接将需要缓存的对象放在内存中。
使用HashMap作为缓存的优点包括:
-
简单易用:HashMap是Java中非常基础的数据结构,使用起来简单直观。
-
灵活:HashMap允许你根据需要存储任何类型的数据。 然而,使用HashMap作为缓存也存在一些缺点:
-
不支持并发访问:HashMap不是线程安全的,如果在多线程环境下使用,需要考虑同步机制或者使用ConcurrentHashMap,否则可能存在数据不一致的问题。
-
没有自动过期策略:HashMap没有内置的过期策略,你需要手动删除过期的缓存项,否则可能导致缓存数据一直存在,影响数据的准确性。
-
没有容量控制:如果没有限制HashMap的容量,当缓存过多时可能会导致内存溢出。
-
性能问题:HashMap在处理大量的读写操作时可能存在性能问题,因为所有的读写操作都需要对整个集合进行遍历。
public class ConcurrentHashMapTest {
private final Map<String, Object> cache;
public ConcurrentHashMapTest() {
cache = new ConcurrentHashMap<>();
}
public Object get(String key) {
return cache.get(key);
}
public void put(String key, Object value) {
cache.put(key, value);
}
public void clear() {
cache.clear();
}
}
2.Guava Cache
Guava是Google提供的一套Java工具包,而Guava Cache是一套非常完善的本地缓存机制(JVM缓存)。Guava cache的设计来源于CurrentHashMap,可以按照多种策略来清理存储在其中的缓存值且保持很高的并发读写性能。
使用Guava Cache作为本地缓存的优点包括:
-
线程安全:Guava Cache是线程安全的,可以在多线程环境下使用。
-
自动过期策略:Guava Cache支持设置缓存的过期时间,当缓存过期后会自动从缓存中删除。
-
高效的性能:Guava Cache采用了高效的算法和数据结构,可以提供较好的读写性能。
-
丰富的功能:Guava Cache提供了许多其他的功能,如缓存值的序列化和反序列化、缓存值的移除监听等。
需要注意的是,springboot2和spring5都放弃了对Guava Cache的支持。
public class GuavaCacheTest {
private final Cache<String, Object> cache;
public GuavaCacheTest() {
cache = CacheBuilder.newBuilder()
.maximumSize(100) // 设置缓存的最大容量
.expireAfterWrite(10, TimeUnit.MINUTES) // 设置缓存的过期时间,这里表示缓存中的数据在写入10分钟后自动过期
.build();
}
public Object get(String key) {
return cache.getIfPresent(key);
}
public void put(String key, Object value) {
cache.put(key, value);
}
public void invalidate(String key) {
cache.invalidate(key);
}
}
3.Caffeine
Caffeine是基于Java 1.8的高性能本地缓存库,由Guava改进而来,而且在Spring5开始的默认缓存实现就将Caffeine代替原来的Google Guava,官方说明指出,其缓存命中率已经接近最优值。实际上Caffeine这样的本地缓存和ConcurrentMap很像,即支持并发,并且支持O(1)时间复杂度的数据存取。二者的主要区别在于:
-
ConcurrentMap将存储所有存入的数据,直到你显式将其移除; -
Caffeine将通过给定的配置,自动移除“不常用”的数据,以保持内存的合理占用。
使用Caffeine,需要在工程中引入如下依赖:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
3.1缓存加载类型
3.1.1 Cache
最普通的一种缓存,无需指定加载方式,需要手动调用put()
进行加载。
public static Cache<String, String> LOADING_CACHE = Caffeine.newBuilder()
//cache的初始容量
.initialCapacity(5)
//cache最大缓存数
.maximumSize(10)
//设置写缓存后n秒钟过期
.expireAfterWrite(17, TimeUnit.SECONDS)
//设置读写缓存后n秒钟过期,实际很少用到,类似于expireAfterWrite
//.expireAfterAccess(17, TimeUnit.SECONDS)
.build();
public static void main(String[] args) throws Exception {
//创建guava cache
String key = "key";
// 往缓存写数据
LOADING_CACHE.put(key, "value");
// 获取value的值,如果缓存不存在则生成缓存元素, 如果无法生成则返回null
String value = LOADING_CACHE.get("key1", CaffeineCacheTest::getValueFromDB);
System.out.println(value);
// 获取value的值,如果key不存在,立即返回null
String ifPresent = LOADING_CACHE.getIfPresent("key1");
System.out.println(ifPresent);
// 移除一个缓存元素
LOADING_CACHE.invalidate(key);
}
private static String getValueFromDB(String key) {
return "data";
}
3.1.2 Loading Cache自动创建
LoadingCache是一种自动加载的缓存。使用LoadingCache时,需要指定CacheLoader,并实现其中的load()
方法供缓存缺失时自动加载。若调用get()
方法,则会自动调用CacheLoader.load()
方法加载最新值。调用getAll()
方法将遍历所有的key调用get()。
public static LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(CaffeineCacheTest::getValueFromDB);
public static void main(String[] args) throws Exception {
// 查找缓存,如果缓存不存在则生成缓存元素, 如果无法生成则返回null
String value = cache.get("key");
System.out.println(value);
// 批量查找缓存,如果缓存不存在则生成缓存元素
List<String> keys = Lists.newArrayList("key1", "key2", "key3");
Map<String, String> graphs = cache.getAll(keys);
System.out.println(graphs);
}
private static String getValueFromDB(String key) {
return "data";
}
3.1.3 Async Loading Cache
AsyncLoadingCache就是LoadingCache的异步形式,提供了异步load生成缓存元素的功能,其响应结果均为CompletableFuture
。
public static AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
//默认情况下,缓存计算使用ForkJoinPool.commonPool()作为线程池,如果想要指定线程池,则可以覆盖并实现Caffeine.executor(Executor)方法
.executor(new ThreadPoolExecutor(5, 10,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(2000), new ThreadFactoryBuilder()
.setNameFormat("asyncTaskThreadPool-pool-%d").build(), new ThreadPoolExecutor.AbortPolicy()))
// 构建一个异步缓存元素操作并返回一个future
.buildAsync(CaffeineCacheTest::createExpensiveStringAsync);
private static CompletableFuture<String> createExpensiveStringAsync(String key, Executor executor) {
System.out.println(executor.toString());
return CompletableFuture.supplyAsync(() -> getValueFromDB(key), executor);
}
public static void main(String[] args) throws Exception {
// 查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<String> future = cache.get("key");
future.thenAccept(System.out::println);
// 批量查找缓存元素,如果其不存在,将会异步进行生成
List<String> keys = Lists.newArrayList("key1", "key2", "key3");
CompletableFuture<Map<String, String>> graphs = cache.getAll(keys);
graphs.thenAccept(System.out::println);
}
private static String getValueFromDB(String key) {
return "DATA";
}
3.2Caffeine 驱逐策略
Caffeine提供三类驱逐策略:基于大小(size-based),基于时间(time-based)和基于引用(reference-based)。
3.2.1 基于大小(size-based)
基于大小驱逐,有两种方式:一种是基于缓存大小,一种是基于权重。
// 根据缓存的计数进行驱逐
public static void main(String[] args) throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
//超过10个后会使用W-TinyLFU算法进行淘汰
.maximumSize(10)
.removalListener((key, val, removalCause) -> log.info("淘汰缓存:key:{} val:{},removalCause={}", key, val,removalCause))
.build();
for (int i = 1; i < 20; i++) {
cache.put(i, i);
}
Thread.sleep(500);//缓存淘汰是异步的
// 打印还没被淘汰的缓存
System.out.println(cache.asMap());
}
// 基于缓存内元素权重进行驱逐
public static void main(String[] args) throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
//限制总权重,若所有缓存的权重加起来>总权重 就会淘汰权重小的缓存
.maximumWeight(100)
.weigher((Weigher<Integer, Integer>) (key, value) -> key)
.removalListener((key, val, removalCause) -> {
log.info("淘汰缓存:key:{} val:{}", key, val);
})
.build();
//总权重其实是=所有缓存的权重加起来
int maximumWeight = 0;
for (int i = 1; i < 20; i++) {
cache.put(i, i);
maximumWeight += i;
}
System.out.println("总权重=" + maximumWeight);
Thread.sleep(500);//缓存淘汰是异步的
// 打印还没被淘汰的缓存
System.out.println(cache.asMap());
}
3.2.2 基于时间(Time-based)
Caffeine提供了三种定时驱逐策略:
-
expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。 -
expireAfterWrite(long, TimeUnit): 在最后一次写入缓存后开始计时,在指定的时间后过期。 -
expireAfter(Expiry): 自定义策略,过期时间由Expiry实现独自计算。
/**
* 访问后到期(每次访问都会重置时间,也就是说如果一直被访问就不会被淘汰)
*/
public static void main(String[] args) throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.SECONDS)
//可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
//若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
.scheduler(Scheduler.systemScheduler())
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
Thread.sleep(3000);
System.out.println(cache.getIfPresent(1));//null
}
/**
* 写入后到期
*/
public static void main(String[] args) throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
//可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
//若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
.scheduler(Scheduler.systemScheduler())
.build();
cache.put(1, 2);
Thread.sleep(3000);
System.out.println(cache.getIfPresent(1));//null
}
// 基于不同的过期驱逐策略
public static void main(String[] args) {
LoadingCache<String, Integer> graphs = Caffeine.newBuilder()
.expireAfter(new Expiry<String, Integer>() {
// 缓存创建后指定时间过期
public long expireAfterCreate(String str, Integer integer, long currentTime) {
// Use wall clock time, rather than nanotime, if from an external resource
/* long seconds = graph.creationDate().plusHours(5)
.minus(System.currentTimeMillis(), MILLIS)
.toEpochSecond();*/
System.out.println("缓存创建后指定时间过期"+currentTime);
return TimeUnit.SECONDS.toNanos(integer);
}
// 缓存更新后指定时间过期
public long expireAfterUpdate(String str, Integer integer,
long currentTime, long currentDuration) {
System.out.println(currentTime + "缓存更新后指定时间过期" + currentDuration);
return currentDuration;
}
// 缓存读取后指定时间过期
public long expireAfterRead(String str, Integer integer,
long currentTime, long currentDuration) {
System.out.println(currentTime + "缓存读取后指定时间过期" + currentDuration);
return currentDuration;
}
})
.build(CaffeineCacheTest::getValueFromDB);
graphs.put("key", 2000);
Integer key = graphs.getIfPresent("key");
System.out.println(key);
}
private static Integer getValueFromDB(String key) {
return 10000;
}
3.2.3 基于引用
Java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用。

public static void main(String[] args) {
// 当key和缓存元素都不再存在其他强引用的时候驱逐
LoadingCache<Cat, AppLog> graphs = Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(CaffeineCacheTest::getValueFromDB);
// 当进行GC的时候进行驱逐
LoadingCache<Cat, AppLog> loadingCache = Caffeine.newBuilder()
.softValues()
.build(CaffeineCacheTest::getValueFromDB);
}
private static AppLog getValueFromDB(Cat key) {
return new AppLog();
}
3.3刷新机制
refreshAfterWrite()
表示x秒后自动刷新缓存的策略可以配合淘汰策略使用,注意的是刷新机制只支持LoadingCache和AsyncLoadingCache。
与驱逐不同的是,在刷新的时候如果查询缓存元素,其旧值将仍被返回,直到该元素的刷新完毕后结束后才会返回刷新后的新值。驱逐会阻塞查询操作直到驱逐操作完成才会进行其他操作。
private static int NUM = 0;
public static void main(String[] args) throws InterruptedException {
LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
.refreshAfterWrite(1, TimeUnit.SECONDS)
//模拟获取数据,每次获取就自增1
.build(integer -> ++NUM);
//获取ID=1的值,由于缓存里还没有,所以会自动放入缓存
System.out.println(cache.get(1));// 1
// 延迟2秒后,理论上自动刷新缓存后取到的值是2
// 但其实不是,值还是1,因为refreshAfterWrite并不是设置了n秒后重新获取就会自动刷新
// 而是x秒后&&第二次调用getIfPresent的时候才会被动刷新
Thread.sleep(2000);
System.out.println(cache.getIfPresent(1));// 1
//此时才会刷新缓存,而第一次拿到的还是旧值
System.out.println(cache.getIfPresent(1));// 2
}
3.4统计(Statistics)
public static void main(String[] args) {
LoadingCache<String, String> cache = Caffeine.newBuilder()
//创建缓存或者最近一次更新缓存后经过指定时间间隔,刷新缓存;refreshAfterWrite仅支持LoadingCache
.refreshAfterWrite(1, TimeUnit.SECONDS)
.expireAfterWrite(1, TimeUnit.SECONDS)
.expireAfterAccess(1, TimeUnit.SECONDS)
.maximumSize(10)
//开启记录缓存命中率等信息
.recordStats()
//根据key查询数据库里面的值
.build(key -> {
Thread.sleep(1000);
return new Date().toString();
});
cache.put("1", "shawn");
System.out.println(cache.get("1")); ;
CacheStats stats = cache.stats();
System.out.println(stats);
System.out.println(stats.hitCount());
}
通过使用Caffeine.recordStats()方法可以打开数据收集功能。Cache.stats()方法将会返回一个CacheStats对象,其将会含有一些统计指标,比如:
-
hitCount :命中的次数 -
missCount:未命中次数 -
requestCount:请求次数 -
hitRate:命中率 -
missRate:丢失率 -
loadSuccessCount:成功加载新值的次数 -
loadExceptionCount:失败加载新值的次数 -
totalLoadCount:总条数 -
loadExceptionRate:失败加载新值的比率 -
totalLoadTime:全部加载时间 -
evictionCount:丢失的条数
三、SpringBoot整合Caffeine
SpringBoot使用Caffeine有两种方式:
-
方式一:直接引入Caffeine依赖,然后使用Caffeine的函数实现缓存。 -
方式二:引入Caffeine和Spring Cache依赖,使用SpringCache注解方法实现缓存。
下面分别介绍两种使用方式。
方式一:使用Caffeine依赖
首先引入maven相关依赖:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
然后,设置缓存的配置选项:
/**
* 缓存配置
*/
@Configuration
public class CacheConfig {
/**
* Caffeine配置说明:
* initialCapacity=[integer]: 初始的缓存空间大小
* maximumSize=[long]: 缓存的最大条数
* maximumWeight=[long]: 缓存的最大权重
* expireAfterAccess=[duration]: 最后一次写入或访问后经过固定时间过期
* expireAfterWrite=[duration]: 最后一次写入后经过固定时间过期
* refreshAfterWrite=[duration]: 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
* weakKeys: 打开key的弱引用
* weakValues:打开value的弱引用
* softValues:打开value的软引用
* recordStats:开发统计功能
* 注意:
* expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准。
* maximumSize和maximumWeight不可以同时使用
* weakValues和softValues不可以同时使用
*/
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
// 设置最后一次写入或访问后经过固定时间过期
.expireAfterWrite(60, TimeUnit.SECONDS)
// 初始的缓存空间大小
.initialCapacity(100)
// 缓存的最大条数
.maximumSize(1000)
.build();
}
}
最后给服务添加缓存功能:
@RestController
@RequestMapping("/productInfo")
public class ProductInfoController {
@Autowired
private ProductInfoService productInfoService;
@Autowired
private Cache<String, Object> caffeineCache;
/**
* 通过id查询商品
*
* @param id id
* @return R
*/
@GetMapping("/{id}")
public ProductInfo getById(@PathVariable("id") Integer id) {
ProductInfo productInfo = (ProductInfo) caffeineCache.getIfPresent(String.valueOf(id));
if (Objects.nonNull(productInfo)) {
return productInfo;
}
productInfo = productInfoService.getById(id);
if (Objects.nonNull(productInfo)) {
caffeineCache.put(productInfo.getId().toString(), productInfo);
}
return productInfo;
}
/**
* 新增商品
*
* @param productInfo 商品
* @return Boolean
*/
@PostMapping
public Boolean save(@RequestBody ProductInfo productInfo) {
productInfoService.save(productInfo);
caffeineCache.put(productInfo.getId().toString(), productInfo);
return Boolean.TRUE;
}
/**
* 修改商品
*
* @param productInfo 商品
* @return Boolean
*/
@PutMapping
public Boolean updateById(@RequestBody ProductInfo productInfo) {
productInfoService.updateById(productInfo);
caffeineCache.put(productInfo.getId().toString(), productInfo);
return Boolean.TRUE;
}
/**
* 通过id删除商品
*
* @param id id
* @return Boolean
*/
@DeleteMapping("/{id}")
public Boolean removeById(@PathVariable Integer id) {
productInfoService.removeById(id);
caffeineCache.asMap().remove(String.valueOf(id));
return Boolean.TRUE;
}
}
方式二:使用Spring Cache注解
1.常用注解介绍
-
@Cacheable:表示该方法支持缓存。当调用被注解的方法时,如果对应的键已经存在缓存,则不再执行方法体,而从缓存中直接返回。当方法返回null时,将不进行缓存操作。 -
@CachePut:表示执行该方法后,其值将作为最新结果更新到缓存中,每次都会执行该方法。 -
@CacheEvict:表示执行该方法后,将触发缓存清除操作。 -
@Caching:用于组合前三个注解。例如:
@Caching(cacheable = @Cacheable("CacheConstants.GET_USER"),
evict = {@CacheEvict(value = "CacheConstants.GET_DYNAMIC", allEntries = true)})
2.常用注解属性
-
cacheNames/value:缓存组件的名字,即cacheManager中缓存的名称。 -
key:缓存数据时使用的key。默认使用方法参数值,也可以使用SpEL表达式进行编写。 -
keyGenerator:和key二选一使用。 -
cacheManager:指定使用的缓存管理器。 -
condition:在方法执行开始前检查,在符合condition的情况下,进行缓存 -
unless:在方法执行完成后检查,在符合unless的情况下,不进行缓存 -
sync:是否使用同步模式。若使用同步模式,在多个线程同时对一个key进行load时,其他线程将被阻塞。
3.项目代码介绍:
首先引入maven相关依赖。
如果要使用@Cacheable
注解,需要引入相关依赖,并在任一配置类文件上添加@EnableCaching
注解。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
缓存配置类CacheConfig。
/**
* 缓存配置
*/
@EnableCaching
@Configuration
public class CacheConfig {
/**
* Caffeine配置说明:
* initialCapacity=[integer]: 初始的缓存空间大小
* maximumSize=[long]: 缓存的最大条数
* maximumWeight=[long]: 缓存的最大权重
* expireAfterAccess=[duration]: 最后一次写入或访问后经过固定时间过期
* expireAfterWrite=[duration]: 最后一次写入后经过固定时间过期
* refreshAfterWrite=[duration]: 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
* weakKeys: 打开key的弱引用
* weakValues:打开value的弱引用
* softValues:打开value的软引用
* recordStats:开发统计功能
* 注意:
* expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准。
* maximumSize和maximumWeight不可以同时使用
* weakValues和softValues不可以同时使用
*/
/**
* 配置缓存管理器
*
* @return 缓存管理器
*/
@Bean("caffeineCacheManager")
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
// 设置最后一次写入或访问后经过固定时间过期
.expireAfterAccess(60, TimeUnit.SECONDS)
// 初始的缓存空间大小
.initialCapacity(100)
// 缓存的最大条数
.maximumSize(1000));
return cacheManager;
}}
调用缓存。
这里要注意的是Cache和@Transactional一样也使用了代理,类内调用将失效。
@CacheConfig(cacheNames = "caffeineCacheManager")
@RestController
@RequestMapping("/productInfo")
public class ProductInfoController {
@Autowired
private ProductInfoService productInfoService;
/**
* 通过id查询商品
*
* @param id id
* @return R
*/
@Cacheable(key = "#id")
@GetMapping("/{id}")
public ProductInfo getById(@PathVariable("id") Integer id) {
return productInfoService.getById(id);
}
/**
* 新增商品
*
* @param productInfo 商品
* @return Boolean
*/
@CachePut(key = "#productInfo.id")
@PostMapping
public Boolean save(@RequestBody ProductInfo productInfo) {
productInfoService.save(productInfo);
return Boolean.TRUE;
}
/**
* 修改商品
*
* @param productInfo 商品
* @return Boolean
*/
@CachePut(key = "#productInfo.id")
@PutMapping
public Boolean updateById(@RequestBody ProductInfo productInfo) {
productInfoService.updateById(productInfo);
return Boolean.TRUE;
}
/**
* 通过id删除商品
*
* @param id id
* @return Boolean
*/
@CacheEvict(key = "#id")
@DeleteMapping("/{id}")
public Boolean removeById(@PathVariable Integer id) {
productInfoService.removeById(id);
return Boolean.TRUE;
}
}
四、总结:
本文主要介绍了Java中的几种本地缓存Map,包括普通的Java Map、Google Guava Cache和Caffeine。其中,Caffeine被证明具有最好的性能。本文详细介绍了Caffeine的特性,包括其高效的内存管理、近似最近最少使用策略以及支持缓存项的批量加载和清除等。最后,本文还提供了Spring Boot整合Caffeine的实战代码示例。通过使用Caffeine,可以提高应用程序的性能并减少对数据库的访问。
有什么问题欢迎交流。
原文始发于微信公众号(明月予我):Spring Boot集成Caffeine缓存介绍
暂无评论内容