⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 https://www.iocoder.cn// 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

话说,中间件的选择上,Spring(SpringBoot)一直是业界的风向标。比如Spring一直使用**「Jackson」,而没有使用Gson和fastjson。SpringBoot2.0默认数据库连接池从TomcatPool换到了「HikariCP」。在本地缓存方面,SpringFramework5.0(SpringBoot2.0)放弃了Google的GuavaCache,选择了「Caffeine」**(Drop Guava caching - superseded by Caffeine [SPR-13797] #18370)。那么Caffeine有什么魔力,能干掉Google的Guava呢?

压力测试

我们用数据说话。Caffeine官方利用最权威的压测工具**「JMH」**对Caffeine、ConcurrentMap、GuavaCache、ehcache等做了详细的压测对比,结果如下:图片

更多压测对比请参考:https://github.com/ben-manes/caffeine/wiki/Benchmarks。从官方的压测结果来看,无论是全读场景、全写场景、或者读写混合场景,无论是8个线程,还是16个线程,Caffeine都是完胜、碾压,简直就是拿着望远镜都看不到对手。

简介

官方介绍Caffeine是基于JDK8的高性能本地缓存库,提供了几乎完美的命中率。它有点类似JDK中的ConcurrentMap,实际上,Caffeine中的LocalCache接口就是实现了JDK中的ConcurrentMap接口,但两者并不完全一样。最根本的区别就是,ConcurrentMap保存所有添加的元素,除非显示删除之(比如调用remove方法)。而本地缓存一般会配置自动剔除策略,为了保护应用程序,限制内存占用情况,防止内存溢出。

Caffeine提供了灵活的构造方法,从而创建可以满足如下特性的本地缓存:

  1. 自动把数据加载到本地缓存中,并且可以配置异步;
  2. 基于数量剔除策略;
  3. 基于失效时间剔除策略,这个时间是从最后一次访问或者写入算起;
  4. 异步刷新;
  5. Key会被包装成Weak引用;
  6. Value会被包装成Weak或者Soft引用,从而能被GC掉,而不至于内存泄漏;
  7. 数据剔除提醒;
  8. 写入广播机制;
  9. 缓存访问可以统计;

使用

Caffeine使用还是非常简单的,如果你用过GuavaCache,那就更简单了,因为Caffeine的API设计大量借鉴了GuavaCache。首先,引入Maven依赖:

<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.8.4</version>
</dependency>

然后构造Cache使用即可:

Cache<String, String> cache = Caffeine.newBuilder()
// 数量上限
.maximumSize(1024)
// 过期机制
.expireAfterWrite(5, TimeUnit.MINUTES)
// 弱引用key
.weakKeys()
// 弱引用value
.weakValues()
// 剔除监听
.removalListener((RemovalListener<String, String>) (key, value, cause) ->
System.out.println("key:" + key + ", value:" + value + ", 删除原因:" + cause.toString()))
.build();
// 将数据放入本地缓存中
cache.put("username", "afei");
cache.put("password", "123456");
// 从本地缓存中取出数据
System.out.println(cache.getIfPresent("username"));
System.out.println(cache.getIfPresent("password"));
System.out.println(cache.get("blog", key -> {
// 本地缓存没有的话,从数据库或者Redis中获取
return getValue(key);
}));

当然,使用本地缓存时,我们也可以使用异步加载机制:

AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
// 数量上限
.maximumSize(2)
// 失效时间
.expireAfterWrite(5, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES)
// 异步加载机制
.buildAsync(new CacheLoader<String, String>() {
@Nullable
@Override
public String load(@NonNull String key) throws Exception {
return getValue(key);
}
});
System.out.println(cache.get("username").get());
System.out.println(cache.get("password").get(10, TimeUnit.MINUTES));
System.out.println(cache.get("username").get(10, TimeUnit.MINUTES));
System.out.println(cache.get("blog").get());

接下来,我们对一些重要特性进行更加深入的分析。

过期机制

本地缓存的过期机制是非常重要的,因为本地缓存中的数据并不像业务数据那样需要保证不丢失。本地缓存的数据一般都会要求保证命中率的前提下,尽可能的占用更少的内存,并可在极端情况下,可以被GC掉。

Caffeine的过期机制都是在构造Cache的时候申明,主要有如下几种:

  1. expireAfterWrite:表示自从最后一次写入后多久就会过期;
  2. expireAfterAccess:表示自从最后一次访问(写入或者读取)后多久就会过期;
  3. expireAfter:自定义过期策略;

刷新机制

在构造Cache时通过refreshAfterWrite方法指定刷新周期,例如refreshAfterWrite(10, TimeUnit.SECONDS)表示10秒钟刷新一次:

.build(new CacheLoader<String, String>() {
@Override
public String load(String k) {
// 这里我们就可以从数据库或者其他地方查询最新的数据
return getValue(k);
}
});

需要注意的是,Caffeine的刷新机制是**「被动」的。举个例子,假如我们申明了10秒刷新一次。我们在时间T访问并获取到值v1,在T+5秒的时候,数据库中这个值已经更新为v2。但是在T+12秒,即已经过了10秒我们通过Caffeine从本地缓存中获取到的「还是v1」**,并不是v2。在这个获取过程中,Caffeine发现时间已经过了10秒,然后会将v2加载到本地缓存中,下一次获取时才能拿到v2。即它的实现原理是在get方法中,调用afterRead的时候,调用refreshIfNeeded方法判断是否需要刷新数据。这就意味着,如果不读取本地缓存中的数据的话,无论刷新时间间隔是多少,本地缓存中的数据永远是旧的数据!

剔除机制

在构造Cache时可以通过removalListener方法申明剔除监听器,从而可以跟踪本地缓存中被剔除的数据历史信息。根据RemovalCause.java枚举值可知,剔除策略有如下5种:

  • 「EXPLICIT」:调用方法(例如:cache.invalidate(key)、cache.invalidateAll)显示剔除数据;
  • 「REPLACED」:不是真正被剔除,而是用户调用一些方法(例如:put(),putAll()等)盖了之前的值;
  • 「COLLECTED」:表示缓存中的Key或者Value被垃圾回收掉了;
  • 「EXPIRED」: expireAfterWrite/expireAfterAccess约定时间内没有任何访问导致被剔除;
  • 「SIZE」:超过maximumSize限制的元素个数被剔除的原因;

GuavaCache和Caffeine差异

  1. 剔除算法方面,GuavaCache采用的是**「LRU」算法,而Caffeine采用的是「Window TinyLFU」**算法,这是两者之间最大,也是根本的区别。
  2. 立即失效方面,Guava会把立即失效 (例如:expireAfterAccess(0) and expireAfterWrite(0)) 转成设置最大Size为0。这就会导致剔除提醒的原因是SIZE而不是EXPIRED。Caffiene能正确识别这种剔除原因。
  3. 取代提醒方面,Guava只要数据被替换,不管什么原因,都会触发剔除监听器。而Caffiene在取代值和先前值的引用完全一样时不会触发监听器。
  4. 异步化方方面,Caffiene的很多工作都是交给线程池去做的(默认:ForkJoinPool.commonPool()),例如:剔除监听器,刷新机制,维护工作等。

内存占用对比

Caffeine可以根据使用情况延迟初始化,或者动态调整它内部数据结构。这样能减少对内存的占用。如下图所示,使用了gradle memoryOverhead对内存占用进行了压测。结果可能会受到JVM的指针压缩、对象Padding等影响:图片

LRU P.K. W-TinyLFU

缓存的驱逐策略是为了预测哪些数据在短期内最可能被再次用到,从而提升缓存的命中率。由于简洁的实现、高效的运行时表现以及在常规的使用场景下有不错的命中率,LRU(Least Recently Used)策略或许是最流行的驱逐策略,,它在保持算法简单的前提下,效果还不错。但LRU对未来的预测有明显的局限性,它会认为**「最后到来的数据是最可能被再次访问」**的,从而给予它最高的优先级。

现代缓存扩展了对历史数据的使用,结合就近程度(recency)和访问频次(frequency)来更好的预测数据。其中一种保留历史信息的方式是使用**「popularity sketch」(一种压缩、概率性的数据结构)来从一大堆访问事件中定位频繁的访问者。可以参考「CountMin Sketch」**算法,它由计数矩阵和多个哈希方法实现。发生一次读取时,矩阵中每行对应的计数器增加计数,估算频率时,取数据对应是所有行中计数的最小值。这个方法让我们从空间、效率、以及适配矩阵的长宽引起的哈希碰撞的错误率上做权衡:图片

Window TinyLFU(W-TinyLFU)算法将Sketch作为过滤器,当新来的数据比要驱逐的数据高频时,这个数据才会被缓存接纳(admission)。这个许可窗口给予每个数据项积累热度的机会,而**「不是立即过滤掉」**。这避免了持续的未命中,特别是在突然流量暴涨的的场景中,一些短暂的重复流量就不会被长期保留。为了刷新历史数据,一个时间衰减进程被周期性或增量的执行,给所有计数器减半:图片

对于长期保留的数据,W-TinyLFU使用了分段LRU(Segmented LRU,缩写SLRU)策略。起初,一个数据项存储被存储在试用段(probationary segment)中,在后续被访问到时,它会被提升到保护段(protected segment)中(保护段占总容量的80%)。保护段满后,有的数据会被淘汰回试用段,这也可能级联的触发试用段的淘汰。这套机制确保了访问间隔小的热数据被保存下来,而被重复访问少的冷数据则被回收:图片

如图中数据库和搜索场景的结果展示,通过考虑就近程度和频率能大大提升LRU的表现。一些高级的策略,像ARC,LIRS和W-TinyLFU都提供了接近最理想的命中率。想看更多的场景测试,请查看相应的论文,也可以在使用simulator来测试自己的场景:图片

Guava迁移

那么,如果我的项目之前用的是GuavaCache,如何以尽可能低的成本迁移到Caffeine上来呢?嘿嘿,Caffeine已经想到了这一点,它提供了一个适配器,让你用Guava的接口操作它的缓存。代码片段如下所示:

// Guava's LoadingCache interface
LoadingCache<Key, Graph> graphs = CaffeinatedGuava.build(
Caffeine.newBuilder().maximumSize(10_000),
new CacheLoader<Key, Graph>() { // Guava's CacheLoader
@Override public Graph load(Key key) throws Exception {
return createExpensiveGraph(key);
}
});

文章目录
  1. 1. 压力测试
  2. 2. 简介
  3. 3. 使用
    1. 3.0.1. 过期机制
    2. 3.0.2. 刷新机制
    3. 3.0.3. 剔除机制
  • 4. GuavaCache和Caffeine差异
  • 5. 内存占用对比
  • 6. LRU P.K. W-TinyLFU
  • 7. Guava迁移