引言
在现代应用程序开发中,缓存是提高程序性能和响应速度的关键技术之一。
对一个java开发者而言,提到缓存,第一反应就是Redis。利用这类缓存足以解决大多数的性能问题了,我们也要知道,这种属于remote cache(分布式缓存),应用的进程和缓存的进程通常分布在不同的服务器上,不同进程之间通过RPC或HTTP的方式通信。这种缓存的优点是缓存和应用服务解耦,支持大数据量的存储,缺点是数据要经过网络传输,性能上会有一定损耗。
与分布式缓存对应的是本地缓存,缓存的进程和应用进程是同一个,数据的读写都在一个进程内完成,这种方式的优点是没有网络开销,访问速度很快。缺点是受JVM内存的限制,不适合存放大数据。Java 提供了多种本地缓存解决方案,每种方案都有其特点和适用场景。
本文将介绍地表最强本地缓存Caffeine
。提到JAVA中的本地缓存框架,Caffeine
是怎么也没法轻视的重磅嘉宾。相比Guava Cache,Caffeine可谓是站在巨人肩膀上,在很多方面做了深度的优化与改良,可以说在性能表现与命中率上全方位的碾压Guava Cache,表现堪称卓越。
巨人肩膀上的产物
先来回忆下之前创建一个Guava cache
对象时的代码逻辑:
public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.initialCapacity(1000)
.maximumSize(10000L)
.expireAfterWrite(30L, TimeUnit.MINUTES)
.concurrencyLevel(8)
.recordStats()
.build((CacheLoader<String, User>) key -> userDao.getUser(key));
}
而使用Caffeine
来创建Cache对象的时候,我们可以这么做:
public LoadingCache<String, User> createUserCache() {
return Caffeine.newBuilder()
.initialCapacity(1000)
.maximumSize(10000L)
.expireAfterWrite(30L, TimeUnit.MINUTES)
//.concurrencyLevel(8)
.recordStats()
.build(key -> userDao.getUser(key));
}
可以发现,两者的使用思路与方法定义非常相近,对于使用过Guava Cache的小伙伴而言,几乎可以无门槛的直接上手使用。当然,两者也还是有点差异的,比如Caffeine创建对象时不支持使用concurrencyLevel
来指定并发量(因为改进了并发控制机制)。
相较于Guava Cache,Caffeine
在整体设计理念、实现策略以及接口定义等方面都基本继承了前辈的优秀特性。作为新时代背景下的后来者,Caffeine也做了很多细节层面的优化,比如:
- 基础数据结构层面优化
借助JAVA8对ConcurrentHashMap
底层由链表切换为**红黑树、以及废弃分段锁**逻辑的优化,提升了Hash冲突时的查询效率以及并发场景下的处理性能。 - 数据驱逐(淘汰)策略的优化
通过使用改良后的W-TinyLFU
算法,提供了更佳的热点数据留存效果,提供了近乎完美的热点数据命中率
,以及更低消耗的过程维护 - 异步并行能力的全面支持
完美适配JAVA8
之后的并行编程场景,可以提供更为优雅的并行编码体验与并发效率。
通过各种措施的改良,成就了Caffeine在功能与性能方面不俗的表现。
Caffeine使用
依赖引入
使用Caffeine,首先需要引入对应的库文件。如果是Maven项目,则可以在pom.xml
中添加依赖声明来完成引入。
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.1</version>
</dependency>
注意,如果你的本地JDK版本比较低,引入上述较新版本的时候可能会编译报错,遇到这种情况,可以考虑升级本地JDK版本(实际项目中升级可能有难度),或者将Caffeine版本降低一些,比如使用2.9.3
版本。具体的版本列表,可以点击此处进行查询。
容器创建
和Guava Cache创建缓存对象的操作相似,我们可以通过构造器来方便的创建出一个Caffeine对象。
Cache<Integer, String> cache = Caffeine.newBuilder().build();
除了上述这种方式,Caffeine还支持使用不同的构造器方法,构建不同类型的Caffeine对象。对各种构造器方法梳理如下:
方法 | 含义说明 |
---|---|
build() | 构建一个手动回源的Cache对象 |
build(CacheLoader) | 构建一个支持使用给定CacheLoader对象进行自动回源操作的LoadingCache对象 |
buildAsync() | 构建一个支持异步操作的异步缓存对象 |
buildAsync(CacheLoader) | 使用给定的CacheLoader对象构建一个支持异步操作的缓存对象 |
buildAsync(AsyncCacheLoader) | 与buildAsync(CacheLoader)相似,区别点仅在于传入的参数类型不一样。 |
为了便于异步场景中处理,可以通过buildAsync()
构建一个手动回源数据加载的缓存对象:
public static void main(String[] args) {
AsyncCache<String, User> asyncCache = Caffeine.newBuilder()
.buildAsync();
User user = asyncCache.get("110", userId -> {
System.out.println("异步callable thread:" + Thread.currentThread().getId());
return userDao.getUser(userId);
}).join();
}
当然,为了支持异步场景中的自动异步回源,我们可以通过buildAsync(CacheLoader)
或者buildAsync(AsyncCacheLoader)
来实现:
public static void main(String[] args) throws Exception{
AsyncLoadingCache<String, User> asyncLoadingCache =
Caffeine.newBuilder().maximumSize(1000L).buildAsync(userId -> userDao.getUser(userId));
User user = asyncLoadingCache.get("110").join();
}
在创建缓存对象的同时,可以指定此缓存对象的一些处理策略,比如容量限制、过期策略等等。作为以替换Guava Cache为己任的后继者,Caffeine在缓存容器对象创建时的相关构建API也沿用了与Guava Cache相同的定义,常见的方法及其含义梳理如下:
方法 | 含义说明 |
---|---|
initialCapacity | 待创建的缓存容器的初始容量大小(记录条数) |
maximumSize | 指定此缓存容器的最大容量(最大缓存记录条数) |
expireAfterWrite | 设定过期策略,按照数据写入时间进行计算 |
expireAfterAccess | 设定过期策略,按照数据最后访问时间来计算 |
expireAfter | 基于个性化定制的逻辑来实现过期处理(可以定制基于新增、读取、更新等场景的过期策略,甚至支持为不同记录指定不同过期时间) |
maximumWeight | 指定此缓存容器的最大容量(最大比重值),需结合weighter方可体现出效果 |
weighter | 入参为一个函数式接口,用于指定每条存入的缓存数据的权重占比情况。这个需要与maximumWeight结合使用 |
refreshAfterWrite | 指定刷新周期;缓存写入后每隔多少时间刷新 |
recordStats | 设定开启此容器的数据加载与缓存命中情况统计 |
注意:
weakValues
和softValues
不可以同时使用。maximumSize
和maximumWeight
不可以同时使用。expireAfterWrite
和expireAfterAccess
同事存在时,以expireAfterWrite
为准。
综合上述方法,我们可以创建出更加符合自己业务场景的缓存对象。
public static void main(String[] args) {
AsyncLoadingCache<String, User> asyncLoadingCache = CaffeinenewBuilder()
.initialCapacity(1000) // 指定初始容量
.maximumSize(10000L) // 指定最大容量
.expireAfterWrite(30L, TimeUnit.MINUTES) // 指定写入30分钟后过期
.refreshAfterWrite(1L, TimeUnit.MINUTES) // 指定每隔1分钟刷新下数据内容
.removalListener((key, value, cause) ->
System.out.println(key + "移除,原因:" + cause)) // 监听记录移除事件
.recordStats() // 开启缓存操作数据统计
.buildAsync(key -> userDao.getUser(key)); // 构建异步CacheLoader加载类型的缓存对象
}
业务使用
在上一章节创建缓存对象的时候,Caffeine支持创建出同步缓存与异步缓存,也即Cache
与AsyncCache
两种不同类型。而如果指定了CacheLoader的时候,又可以细分出LoadingCache
子类型与AsyncLoadingCache
子类型。对于常规业务使用而言,知道这四种类型的缓存类型基本就可以满足大部分场景的正常使用了。但是Caffeine的整体缓存类型其实是细分成了很多不同的具体类型的。
业务层面对缓存的使用,无外乎往缓存里面写入数据、从缓存里面读取数据。不管是同步还是异步,常见的用于操作缓存的方法如下:
方法 | 含义说明 |
---|---|
V get(K var1, Function<? super K, ? extends @PolyNull V> var2); | 根据key获取指定的缓存值,如果没有则执行回源操作获取 |
Map<K, V> getAll(Iterable<? extends K> var1, Function<? super Set<? extends K>, ? extends Map<? extends K, ? extends V>> var2); | 根据给定的key列表批量获取对应的缓存值,返回一个map格式的结果,没有命中缓存的部分会执行回源操作获取 |
V getIfPresent(K var1); | 不执行回源操作,直接从缓存中尝试获取key对应的缓存值;没有查找到的时候返回null |
Map<K, V> getAllPresent(Iterable<? extends K> var1); | 不执行回源操作,直接从缓存中尝试获取给定的key列表对应的值,返回查询到的map格式结果, 异步场景不支持此方法 |
void put(K var1, V var2); | 向缓存中写入指定的key与value记录 |
void putAll(Map<? extends K, ? extends V> var1); | 批量向缓存中写入指定的key-value记录集,异步场景不支持此方法 |
ConcurrentMap<K, V> asMap(); | 将缓存中的数据转换为map格式返回 |
void invalidate(K var1); | 移除一个缓存元素 |
void invalidateAll(Iterable<? extends K> var1); | 批量移除缓存元素 |
void invalidateAll(); | 清除所有缓存元素 |
void cleanUp(); | 清除所有缓存元素 |
针对同步缓存,业务代码中操作使用举例如下:
public static void main(String[] args) throws Exception {
LoadingCache<String, String> loadingCache = buildLoadingCache();
loadingCache.put("key1", "value1");
String value = loadingCache.get("key1");
System.out.println(value);
}
同样地,异步缓存的时候,业务代码中操作示意如下:
public static void main(String[] args) throws Exception {
AsyncLoadingCache<String, String> asyncLoadingCache = buildAsyncLoadingCache();
// 写入缓存记录(value值为异步获取)
asyncLoadingCache.put("key1", CompletableFuture.supplyAsync(() -> "value1"));
// 异步方式获取缓存值
CompletableFuture<String> completableFuture = asyncLoadingCache.get("key1");
String value = completableFuture.join();
System.out.println(value);
}