本文为稀土技术社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!


咱们好,又见面了。

在前面的几篇文章中,咱们一同聊了下本地缓存的动手完结、本地缓存相关的规范等,也聊了下Google的Guava Cache的相关原理与运用办法。比较心急的小伙伴现已坐不住了,说到本地缓存,怎么能不提一下“地上最强”的Caffeine Cache呢?

能被小伙伴称之为“地上最强”,可见Caffeine的魅力之大!确实,说到JAVA中的本地缓存结构,Caffeine是怎么也没法轻视的重磅嘉宾。前面几篇文章中,咱们一同探索了JVM等级的优异缓存结构Guava Cache,而比较之下,Caffeine可谓是站在伟人膀子上,在许多方面做了深度的优化改善,能够说在功能体现命中率上全方位的碾压Guava Cache,体现可谓杰出。

下面就让咱们一同来解读下Caffeine Cache的规划完结改善点原理,揭秘Caffeine 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与Guava —— 是传承而非竞赛

许多人都知道Caffeine在各方面的体现都因为Guava Cache, 乃至对比之下有些小伙伴觉得Guava Cache几乎一无是处。但不可否认的是,在曾经的一段韶光里,Guava Cache供给了尽可能高效且轻量级的并发本地缓存工具结构。技术总是在不断的更新与迭代的,纵使优异如Guava Cache这般,终究是难逃沦为时代眼泪的结局。

纵观Caffeine,其本来便是依据Guava cache基础上孵化而来的改善版别,许多的特性与规划思路都彻底沿用了Guava Cache相同的逻辑,且供给的接口与运用风格也与Guava Cache无异。所以,从这个层面而言,自己更乐意将Caffeine看作是Guava Cache的一种优异基因的传承与发扬光大,而非是竞赛与打压关系。

那么Caffeine能够后来居上的诀窍在哪呢?下面总结了其最要害的3大关键,一同看下。

贯穿始终的异步战略

Caffeine在恳求上的处理流程做了许多的优化,作用比较显著的当属数据筛选处理履行战略的改善。之前在Guava Cache的介绍中,有提过Guava Cache的战略是在恳求的时分一同去履行对应的整理操作,也便是读恳求中混杂着写操作,尽管Guava Cache做了一系列的战略来减少其触发的概率,但一旦触发总归是会对读取操作的功能有必定的影响。

Caffeine则选用了异步处理的战略,get恳求中尽管也会触发筛选数据的整理操作,可是将整理任务增加到了独立的线程池中进行异步的不会堵塞 get 恳求的履行与回来,这样大大缩短了get恳求的履行时长,提升了呼应功能。

除了对自身的异步处理优化,Caffeine还供给了全套的Async异步处理机制,能够支撑事务在异步并行流水线式处理场景中运用以取得愈加丝滑的体会。

Caffeine完美的支撑了在异步场景下的流水线处理运用场景,回源操作也支撑异步的办法来完结。CompletableFuture并行流水线才能,是JAVA8异步编程范畴的一个严重改善。能够将一系列耗时且无依靠的操作改为并行同步处理,并等候各自处理成果完结后继续进行后续环节的处理,由此来下降堵塞等候时间,从而到达下降恳求链路时长的作用。

比方下面这段异步场景运用Caffeine并行处理的代码:

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);
}

ConcurrentHashMap优化特性

作为运用JAVA8新特性进行构建的Caffeine,充沛享受了JAVA8语言层面优化改善所带来的功能上的增益。咱们知道ConcurrentHashMap是JDK原生供给的一个线程安全的HashMap容器类型,而Caffeine底层也是依据ConcurrentHashMap进行构建与数据存储的。

JAVA7以及更早的版别中,ConcurrentHashMap选用的是分段锁的战略来完结线程安全的(前面文章中咱们讲过Guava Cache选用的也是分段锁的战略),分段锁尽管在必定程度上能够下降锁竞赛的抵触,可是在一些极高并发场景下,或许并发恳求分布较为会集的时分,仍然会出现较大概率的堵塞等候状况。此外,这些版别中ConcurrentHashMap底层选用的是数组+链表的存储办法,这种状况在Hash抵触较为显着的状况下,需求频繁的遍历链表操作,也会影响全体的处理功能。

JAVA8中对ConcurrentHashMap的完结战略进行了较大调整,大幅提升了其在的并发场景的功能体现。首要能够分为2个方面的优化。

  • 数组+链表结构主动晋级为数组+红黑树

默许状况下,ConcurrentHashMap的底层结构是数组+链表的办法,元素存储的时分会先核算下key对应的Hash值来将其划分到对应的数组对应的链表中,而当链表中的元素个数超过8个的时分,链表会主动转换为红黑树结构。如下所示:

在遍历查询方面,红黑树有着比链表要愈加杰出的功能体现。

  • 分段锁晋级为synchronized+CAS

分段锁的中心思维便是缩小锁的范围,从而下降锁竞赛的概率。当数据量特别大的时分,其实每个锁包括的数据范围依旧会很大,假如并发恳求量特别大的时分,依旧会出现许多线程争夺同一把分段锁的状况。

在JAVA8中,ConcurrentHashMap 抛弃分段锁的概念,改为了synchronized+CAS的战略,凭借CAS的乐观锁战略,大大提升了读多写少场景下的并发才能。

得益于JAVA8对ConcurrentHashMap的优化,使得Caffeine在多线程并发场景下的体现十分的超卓。

筛选算法W-LFU的加持

惯例的缓存筛选算法一般选用FIFOLRU或许LFU,可是这些算法在实践缓存场景中都会存在一些坏处

算法 坏处阐明
FIFO 先进先出战略,归于一种最为简单与原始的战略。假如缓存运用频率较高,会导致缓存数据始终在不断的进进出出,影响功能,且命中率体现也一般。
LRU 最近最久未运用战略,保存最近被拜访到的数据,而筛选最久没有被拜访的数据。假如遇到偶然的批量刷数据状况,很简单将其他缓存内容都挤出内存,带来缓存击穿的风险。
LFU 最近少频率战略,这种依据拜访次数进行筛选,比较而言内存中存储的热点数据命中率会更高些,缺陷便是需求保护独立字段用来记载每个元素的拜访次数,占用内存空间。

为了保证命中率,一般缓存结构都会挑选运用LRU或许LFU战略,很少会有运用FIFO战略进行数据筛选的。Caffeine缓存的LFU选用了Count-Min Sketch频率统核算法(参见下图暗示,图片来源:点此查看),因为该LFU的计数器只要4bit巨细,所以称为TinyLFU。在TinyLFU算法基础上引进一个依据LRU的Window Cache,这个新的算法叫就叫做W-TinyLFU

图源网络图源网络

W-TinyLFU算法有用的解决了LRU以及LFU存在的坏处,为Caffeine供给了大部分场景下近乎完美命中率体现。

关于W-TinyLFU的详细阐明,有爱好的话能够点此了解。

怎么挑选

在Caffeine与Guava Cache之间怎么挑选?其实Spring现已给咱们做了示范,从Spring5开端,其内置的本地缓存结构由Guava Cache切换到了Caffeine。应用到项目中的缓存选型,能够结合项目实践从多个方面进行选择。

  • 全新项目,闭眼选Caffeine
    Java8也现已被广泛的运用多年,现在的新项目基本上都是JAVA8或以上的版别了。假如有新的项目需求做本地缓存选型,闭眼挑选Caffeine就能够,错不了。

  • 前史低版别JAVA项目
    因为Caffeine对JAVA版别有依靠要求,关于一些前史项目的保护而言,假如项目的JDK版别过低则无法运用Caffeine,这种状况下Guava Cache依旧是一个不错的挑选。当然,也能够下定决心将项目的JDK版别晋级到JDK1.8+版别,然后运用Caffeine来取得更好的功能体会 —— 可是关于一个前史项目而言,晋级基础JDK版别带来的影响可能会比较大,需求提早评价好。

  • 有一同运用Guava其它才能
    假如你的项目里边现已有引进并运用了Guava供给的相关功能,这种状况下为了避免太多外部组件的引进,也能够直接运用Guava供给的Cache组件才能,毕竟Guava Cache的体现并不算差,敷衍惯例场景的本都缓存诉求彻底满足。当然,为了追求愈加极致的功能体现,另外引进并运用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("123", s -> {
        System.out.println("异步callable thread:" + Thread.currentThread().getId());
        return userDao.getUser(s);
    }).join();
}

当然,为了支撑异步场景中的主动异步回源,咱们能够经过buildAsync(CacheLoader)或许buildAsync(AsyncCacheLoader)来完结:

public static void main(String[] args) throws Exception{
    AsyncLoadingCache<String, User> asyncLoadingCache =
            Caffeine.newBuilder().maximumSize(1000L).buildAsync(key -> userDao.getUser(key));
    User user = asyncLoadingCache.get("123").join();
}

在创立缓存目标的一同,能够指定此缓存目标的一些处理战略,比方容量限制、比方过期战略等等。作为以替换Guava Cache为己任的后继者,Caffeine在缓存容器目标创立时的相关构建API也沿用了与Guava Cache相同的界说,常见的办法及其意义整理如下:

办法 意义阐明
initialCapacity 待创立的缓存容器的初始容量巨细(记载条数
maximumSize 指定此缓存容器的最大容量(最大缓存记载条数)
maximumWeight 指定此缓存容器的最大容量(最大比重值),需结合weighter方可体现出作用
expireAfterWrite 设定过期战略,依照数据写入时间进行核算
expireAfterAccess 设定过期战略,依照数据最终拜访时间来核算
expireAfter 依据个性化定制的逻辑来完结过期处理(能够定制依据新增读取更新等场景的过期战略,乃至支撑为不同记载指定不同过期时间
weighter 入参为一个函数式接口,用于指定每条存入的缓存数据的权重占比状况。这个需求与maximumWeight结合运用
refreshAfterWrite 缓存写入到缓存之后
recordStats 设定开启此容器的数据加载与缓存命中状况计算

归纳上述办法,咱们能够创立出愈加符合自己事务场景的缓存目标。

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支撑创立出同步缓存异步缓存,也即CacheAsyncCache两种不同类型。而假如指定了CacheLoader的时分,又能够细分出LoadingCache子类型与AsyncLoadingCache子类型。关于惯例事务运用而言,知道这四种类型的缓存类型基本就能够满足大部分场景的正常运用了。可是Caffeine的全体缓存类型其实是细分成了许多不同的详细类型的,从下面的UML图上能够看出一二。

  • 同步缓存

  • 异步缓存

事务层面对缓存的运用,无外乎往缓存里边写入数据、从缓存里边读取数据。不管是同步仍是异步,常见的用于操作缓存的办法整理如下:

办法 意义阐明
get 依据key获取指定的缓存值,假如没有则履行回源操作获取
getAll 依据给定的key列表批量获取对应的缓存值,回来一个map格局的成果,没有命中缓存的部分会履行回源操作获取
getIfPresent 不履行回源操作,直接从缓存中测验获取key对应的缓存值
getAllPresent 不履行回源操作,直接从缓存中测验获取给定的key列表对应的值,回来查询到的map格局成果, 异步场景不支撑此办法
put 向缓存中写入指定的key与value记载
putAll 批量向缓存中写入指定的key-value记载集,异步场景不支撑此办法
asMap 将缓存中的数据转换为map格局回来

针对同步缓存,事务代码中操作运用举例如下:

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);
}

小结回顾

好啦,关于Caffeine Cache的详细运用办法、中心的优化改善点相关的内容,以及与Guava Cache的比较,就介绍到这里了。不知道小伙伴们是否对Caffeine Cache有了全新的认识了呢?而关于Caffeine Cache与Guava Cache的差别,你是否有自己的一些想法与见解呢?欢迎谈论区一同交流下,等待和各位小伙伴们一同切磋、共同生长。

下一篇文章中,咱们将深化讲解下Caffeine同步、异步回源操作的各种不同完结,以及对应的完结与底层规划逻辑。如有爱好,欢迎重视后续更新。

📣 弥补阐明1

本文归于《深化理解缓存原理与实战规划》系列专栏的内容之一。该专栏围绕缓存这个宏大出题进行打开论述,全方位、体系性地深度剖析各种缓存完结战略与原理、以及缓存的各种用法、各种问题应对战略,并一同讨论下缓存规划的哲学。

假如有爱好,也欢迎重视此专栏。

📣 弥补阐明2

  • 关于本文中触及的演示代码的完整示例,我现已整理并提交到github中,假如您有需求,能够自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

假如觉得有用,请点赞 + 重视让我感受到您的支撑。也能够重视下我的大众号【架构悟道】,获取更及时的更新。

等待与你一同讨论,一同生长为更好的自己。