更好的本地缓存 - Caffeine

Caffeine和Guava cache的对比

之前创建一个Guava cache对象时的代码:

scss 复制代码
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对象的时:

scss 复制代码
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这般,终究是难逃沦为时代眼泪的结局。

纵观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并行处理的代码:

arduino 复制代码
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采用的也是分段锁的策略:Guava Cache中的底层数据结构是 **LocalCache,它是Guava Cache的核心实现类。 LocalCache使用了一种名为「数组+链表」的方式来存储缓存项,并提供了并发访问和更新缓存的能力。虽然 LocalCache的底层实现不是基于 ConcurrentHashMap,但它从 ConcurrentHashMap****获得了灵感。),分段锁虽然在一定程度上可以降低锁竞争的冲突,但是在一些极高并发场景下,或者并发请求分布较为集中的时候,仍然会出现较大概率的阻塞等待情况。此外,这些版本中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提供了大部分场景下近乎完美命中率表现。

Caffeine使用

容器创建

通过构造器来方便的创建出一个Caffeine对象。

ini 复制代码
Cache<Integer, String> cache = Caffeine.newBuilder().build();

除了上述这种方式,Caffeine还支持使用不同的构造器方法,构建不同类型的Caffeine对象。对各种构造器方法梳理如下:

  • **build():**构建一个手动回源的Cache对象

  • **build(CacheLoader):**构建一个支持使用给定CacheLoader对象进行自动回源操作的LoadingCache对象

  • **buildAsync():**构建一个支持异步操作的异步缓存对象

  • **buildAsync(CacheLoader):**使用给定的CacheLoader对象构建一个支持异步操作的缓存对象

  • **buildAsync(AsyncCacheLoader):**与buildAsync(CacheLoader)相似,区别点仅在于传入的参数类型不一样。

为了便于异步场景 中处理,可以通过buildAsync()构建一个手动回源数据加载的缓存对象:

csharp 复制代码
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)来实现:

vbnet 复制代码
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:**设定开启此容器的数据加载与缓存命中情况统计

综合上述方法,就可以创建出更加符合自己业务场景的缓存对象。

scss 复制代码
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格式返回

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

arduino 复制代码
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);
}

同样地,异步缓存的时候,业务代码中操作示意如下:

arduino 复制代码
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的同步、异步回源方式

与Guava Cache相似,

Caffeine

回源填充主要有两种手段:

  • Callable方式

  • CacheLoader方式

根据执行调用方式不同,又可以细分为同步阻塞 方式与异步非阻塞方式。

同步方式

同步 方式是最常被使用的一种形式。查询缓存、数据回源、数据回填缓存、返回执行结果等一系列操作都是在一个调用线程中同步阻塞完成的。

Callable

在每次get请求的时候,传入一个Callable函数式接口具体实现,当没有命中缓存的时候,Caffeine框架会执行给定的Callable实现逻辑,去获取真实的数据并且回填到缓存中,然后返回给调用方。

ini 复制代码
public static void main(String[] args) {
    Cache<String, User> cache = Caffeine.newBuilder().build();
    User user = cache.get("123", s -> userDao.getUser(s));
    System.out.println(user);
}

Callable方式的回源填充,有个明显的优势就是调用方可以根据自己的场景,

灵活

的给定不同的回源执行逻辑。但是这样也会带来一个问题 ,就是如果需要获取缓存的地方太多,会导致每个调用的地方都得指定下对应Callable回源方法,调用起来比较麻烦,且对于需要保证回源逻辑统一的场景

管控能力不够强势

,无法约束所有的调用方使用相同的回源逻辑。

这种时候,便需要CacheLoader登场了。

CacheLoader

在创建缓存对象的时候,可以通在build()方法中传入指定的CacheLoader 对象的方式来指定回源时默认使用的回源数据加载器,这样当使用方调用get方法获取不到数据的时候,框架就会自动使用给定的CacheLoader对象执行对应的数据加载逻辑。

比如下面的代码中,便在创建缓存对象时指定了当缓存未命中时通过userDao.getUser()方法去

DB

中执行数据查询操作:

scss 复制代码
public LoadingCache<String, User> createUserCache() {
    return Caffeine.newBuilder()
            .maximumSize(10000L)
            .build(key -> userDao.getUser(key));
}

相比于Callable方式,CacheLoader更适用

所有回源场景使用的回源策略都固定且统一

的情况。对具体业务使用的时候更加的友好,调用get方法也更加简单,只需要传入带查询的key值即可。

上面的示例代码中还有个需要关注的点,即创建缓存对象的时候指定了CacheLoader,最终创建出来的缓存对象是LoadingCache类型,这个类型是Cache的一个子类,扩展提供了

无需传入

Callable

参数的get方法

。进一步地,我们打印出对应的详细类名,会发现得到的缓存对象具体类型为:

复制代码
com.github.benmanes.caffeine.cache.BoundedLocalCache.BoundedLocalLoadingCache

当然,如果创建缓存对象的时候没有指定最大容量限制,则创建出来的缓存对象还可能会是下面这个:

复制代码
com.github.benmanes.caffeine.cache.UnboundedLocalCache.UnboundedLocalManualCache

通过UML图,可以清晰的看出其与Cache之间的继承与实现链路情况:

因为LoadingCache是Cache对象的子类,根据JAVA中类继承的特性,LoadingCache也完全具备Cache所有的接口能力。所以,对于大部分场景都需要固定且统一的回源方式,但是某些特殊场景需要自定义回源逻辑的情况,也可以通过组合使用Callable的方式来实现。

csharp 复制代码
public static void main(String[] args) {
    LoadingCache<String, User> cache = Caffeine.newBuilder().build(userId -> userDao.getUser(userId));
    // 使用CacheLoader回源
    User user = cache.get("123");
    System.out.println(user);
    // 使用自定义Callable回源
    User techUser = cache.get("J234", userId -> {
        // 仅J开头的用户ID才会去回源
        if (!StringUtils.isEmpty(userId) && userId.startsWith("J")) {
            return userDao.getUser(userId);
        } else {
            return null;
        }
    });
    System.out.println(techUser);
}

上述代码中,构造的是一个指定了CacheLoader的LoadingCache缓存类型,这样对于大众场景可以直接使用get方法由CacheLoader提供统一 的回源能力,而特殊场景中也可以在get方法中传入需要的定制化回源Callable逻辑。

不回源

在实际的缓存应用场景中,并非是所有的场景都要求缓存没有命中的时候要去执行回源查询。对于一些业务规划上无需执行回源操作的请求,也可以要求Caffeine不要执行回源操作(比如黑名单列表,只要用户在黑名单就禁止操作,不在黑名单则允许继续往后操作,因为大部分请求都不会命中到黑名单中,所以不需要执行回源操作)。为了实现这一点,在查询操作的时候,可以使用Caffeine提供的免回源查询方法来实现。

  • **getIfPresent:**从内存中查询,如果存在则返回对应值,不存在则返回null

  • **getAllPresent:**批量从内存中查询,如果存在则返回存在的键值对,不存在的key则不出现在结果集里

    public static void main(String[] args) { LoadingCache<String, User> cache = Caffeine.newBuilder().build(userId -> userDao.getUser(userId)); cache.put("124", new User("124", "张三")); User userInfo = cache.getIfPresent("123"); System.out.println(userInfo); Map<String, User> presentUsers = cache.getAllPresent(Stream.of("123", "124", "125").collect(Collectors.toList())); System.out.println(presentUsers); }

异步方式

CompletableFuture并行流水线能力,是JAVA8异步编程领域的一个重大改进。可以将一系列耗时且无依赖的操作改为并行同步处理,并等待各自处理结果完成后继续进行后续环节的处理,由此来降低阻塞等待时间,进而达到降低请求链路时长的效果。

很多小伙伴对JAVA8之后的CompletableFuture并行处理能力接触的不是很多,有兴趣的可以移步看下我之前专门介绍JAVA8流水线并行处理能力的介绍《JAVA基于CompletableFuture的流水线并行处理深度实践,满满干货,相信可以让你对ComparableFututre并行编程有全面的认识与理解。

Caffeine完美的支持了在异步场景下的流水线 处理使用场景,回源操作也支持异步的方式来完成。

异步Callable

要想支持异步场景下使用缓存,则创建的时候必须要创建一个异步缓存类型,可以通过buildAsync()方法来构建一个AsyncCache类型缓存对象,进而可以在异步场景下进行使用。

ini 复制代码
public static void main(String[] args) {
    AsyncCache<String, User> asyncCache = Caffeine.newBuilder().buildAsyn();
    CompletableFuture<User> userCompletableFuture = asyncCache.get("123", s -> userDao.getUser(s));
    System.out.println(userCompletableFuture.join());
}

上述代码中,get方法传入了Callable回源逻辑,然后会开始异步 的加载处理操作,并返回了个CompletableFuture类型结果,最后如果需要获取其实际结果的时候,需要等待其异步执行完成然后获取到最终结果(通过上述代码中的join()方法等待并获取结果)。

同步处理逻辑中,回源操作直接占用的

调用线程

进行操作,而异步处理时则是

单独线程

负责回源处理、不会阻塞调用线程的执行 ------ 这也是异步处理的优势所在。

异步CacheLoader

异步处理的时候,Caffeine也支持直接在创建的时候指定CacheLoader对象,然后生成支持异步回源操作的AsyncLoadingCache缓存对象,然后在使用get方法获取结果的时候,也是返回的CompletableFuture异步封装类型,满足在异步编程场景下的使用。

vbnet 复制代码
public static void main(String[] args) {
    try {
        AsyncLoadingCache<String, User> asyncLoadingCache =
                Caffeine.newBuilder().maximumSize(1000L).buildAsync(key -> userDao.getUser(key));
        CompletableFuture<User> userCompletableFuture = asyncLoadingCache.get("123");
        System.out.println(userCompletableFuture.join());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

异步AsyncCacheLoader

除了上述这种方式,在创建的时候给定一个用于回源处理的CacheLoader之外,Caffeine还有一个buildAsync的重载版本,允许传入一个同样是支持异步并行处理的AsyncCacheLoader对象。使用方式如下:

scss 复制代码
public static void main(String[] args) {
    try {
        AsyncLoadingCache<String, User> asyncLoadingCache =
                Caffeine.newBuilder().maximumSize(1000L).buildAsync(
                        (key, executor) -> CompletableFuture.supplyAsync(() -> userDao.getUser(key), executor)
                );
        CompletableFuture<User> userCompletableFuture = asyncLoadingCache.get("123");
        System.out.println(userCompletableFuture.join());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

不管是使用CacheLoader还是AsyncCacheLoader对象,最终生成的缓存类型都是AsyncLoadingCache 类型,使用的时候也并没有实质性的差异,两种方式的差异点仅在于传入buildAsync方法中的对象类型不同而已,使用的时候可以根据喜好自行选择。

如果我们尝试将上面代码中的asyncLoadingCache缓存对象的具体类型打印出来,我们会发现其具体类型可能是:

复制代码
com.github.benmanes.caffeine.cache.BoundedLocalCache.BoundedLocalAsyncLoadingCache

而如果我们在构造缓存对象的时候没有限制其最大容量信息,其构建出来的缓存对象类型还可能会是下面这个:

复制代码
com.github.benmanes.caffeine.cache.UnboundedLocalCache.UnboundedLocalAsyncLoadingCache

与前面同步方式一样,我们也可以看下这两个具体的缓存类型对应的UML类图关系:

异步缓存不同类型最终都实现了同一个AsyncCache顶层接口类,而AsyncLoadingCache作为继承自

AsyncCache

的子类,除具备了AsyncCache的所有接口外,还额外扩展了部分的接口,以支持未命中目标时自动使用指定的CacheLoader或者AysncCacheLoader对象去执行回源逻辑。

Caffeine的缓存淘汰机制与用法

在惰性删除实现机制这边,Caffeine做了一些改进优化以提升在并发场景下的性能表现。我们可以和Guava Cache的基于容量大小的淘汰处理做个对比。

当限制了Guava Cache最大容量之后,有新的记录写入超过了总大小,会理解触发数据淘汰策略,然后腾出空间给新的记录写入。

深扒一下源码,可以发现Caffeine在读写操作时会使用独立****线程 池执行对应的清理任务;Caffeine为了提升读写操作的并发效率而将数据淘汰清理操作改为了异步处理,而异步处理时会有微小的延时;但是作为一个以

高并发

吞吐量

为优先考量点的组件而言,这一点点的误差也是可以接受的。

Caffeine多种数据淘汰机制

基于时间

Caffine支持基于时间 进行数据的淘汰驱逐处理。这部分的能力与Guava Cache相同,支持根据记录创建时间 以及访问时间两个维度进行处理。

数据的过期时间在创建缓存对象的时候进行指定,Caffeine在创建缓存对象的时候提供了3种设定过期策略的方法。

  • **expireAfterWrite:**基于创建时间进行过期处理

  • **expireAfterAccess:**基于最后访问时间进行过期处理

  • expireAfter:基于个性化定制 的逻辑来实现过期处理(可以定制基于新增读取更新等场景的过期策略,甚至支持为

    不同记录指定不同过期时间

expireAfterWrite

expireAfterWrite用于指定数据创建之后多久会过期,使用方式举例如下:

sql 复制代码
Cache<String, User> userCache = 
    Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build();
userCache.put("123", new User("123", "张三"));

当记录被写入缓存之后达到指定的时间之后,就会被过期淘汰(惰性删除,并不会立即从内存中移除,而是在下一次操作的时候触发清理操作)。

expireAfterAccess

expireAfterAccess用于指定缓存记录多久没有被访问之后就会过期。使用方式与expireAfterWrite类似:

ini 复制代码
Cache<String, User> userCache = 
    Caffeine.newBuilder().expireAfterAccess(1, TimeUnit.SECONDS).build();
userCache.get("123", s -> userDao.getUser(s));

这种是基于最后一次访问时间来计算数据是否过期,如果一个数据一直被访问,则其就不会过期。比较适用于热点数据 的存储场景,可以保证较高的缓存命中率。同样地,数据过期时也不会被立即从内存中移除,而是基于惰性删除机制进行处理。

expireAfter

上面两种设定过期时间的策略与Guava Cache是相似的。为了提供更为灵活的过期时间设定能力,Caffeine提供了一种全新的的过期时间设定方式,也即这里要介绍的expireAfter方法。其支持传入一个自定义的Expiry对象,自行实现数据的过期策略,甚至是针对不同的记录来定制不同的过期时间。

先看下Expiry接口中需要实现的三个方法:

  • expireAfterCreate指定一个过期时间,从记录创建的时候开始计时,超过指定的时间之后就过期淘汰,效果类似expireAfterWrite,但是支持更灵活的定制逻辑。

  • expireAfterUpdate指定一个过期时间,从记录最后一次被更新的时候开始计时,超过指定的时间之后就过期。每次执行更新操作之后,都会重新计算过期时间。

  • expireAfterRead指定一个过期时间,从记录最后一次被访问的时候开始计时,超过指定时间之后就过期。效果类似expireAfterAccess,但是支持更高级的定制逻辑。

比如下面的代码中,定制了expireAfterCreate方法的逻辑,根据缓存key来决定过期时间,如果key以字母A开头则设定1s过期,否则设定2s过期:

less 复制代码
public static void main(String[] args) {
    try {
        LoadingCache<String, User> userCache = Caffeine.newBuilder()
                .removalListener((key, value, cause) -> {
                    System.out.println(key + "移除,原因:" + cause);
                })
                .expireAfter(new Expiry<String, User>() {
                    @Override
                    public long expireAfterCreate(@NonNull String key, @NonNullUser value, long currentTime) {
                        if (key.startsWith("A")) {
                            return TimeUnit.SECONDS.toNanos(1);
                        } else {
                            return TimeUnit.SECONDS.toNanos(2);
                        }
                    }
                    @Override
                    public long expireAfterUpdate(@NonNull String key, @NonNullUser value, long currentTime,
                                                  @NonNegative longcurrentDuration) {
                        return Long.MAX_VALUE;
                    }
                    @Override
                    public long expireAfterRead(@NonNull String key, @NonNull Uservalue, long currentTime,
                                                @NonNegative long currentDuration){
                        return Long.MAX_VALUE;
                    }
                })
                .build(key -> userDao.getUser(key));
        userCache.put("123", new User("123", "123"));
        userCache.put("A123", new User("A123", "A123"));
        Thread.sleep(1100L);
        System.out.println(userCache.get("123"));
        System.out.println(userCache.get("A123"));
    } catch (Exception e) {
        e.printStackTrace();
    }
}

可以发现,不同的key拥有了不同的过期时间

基于大小

除了前面提到的基于访问时间或者创建时间来执行数据过期淘汰的方式之外,Caffeine还支持针对缓存总体容量 大小进行限制,如果容量满的时候,基于W-TinyLFU算法,淘汰最不常被使用的数据,腾出空间给新的记录写入。

Caffeine支持按照Size(记录条数)或者按照Weighter(记录权重)值进行总体容量的限制。关于Size和Weighter的区别,之前的文章中有介绍过,如果不清楚可以查看下《重新认识下JVM级别的本地缓存框架Guava Cache(2)------深入解读其容量限制与数据淘汰策略

maximumSize

在创建Caffeine缓存对象的时候,可以通过maximumSize来指定允许缓存的最大条数。

scss 复制代码
Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumSize(1000L) // 限制最大缓存条数
        .build();

maximumWeight

在创建Caffeine缓存对象的时候,可以通过maximumWeightweighter组合的方式,指定按照权重进行限制缓存总容量。比如一个字符串value值的缓存场景下,可以根据字符串的长度来计算权重值,最后根据总权重大小来限制容量。

scss 复制代码
Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumWeight(1000L) // 限制最大权重值
        .weigher((key, value) -> (String.valueOf(value).length() / 1000) + 1)
        .build();

使用注意点

需要注意一点:如果创建的时候指定了weighter,则必须同时指定maximumWeight值,如果不指定、或者指定了maximumSize,会报错(这一点与Guava Cache一致)。

基于引用

基于引用回收的策略,核心是利用JVM虚拟机的GC****机制 来达到数据清理的目的。当一个对象不再被引用的时候,JVM会选择在适当的时候将其回收。Caffeine支持三种不同的基于引用的回收方法:

  • weakKeys:采用弱引用方式存储key值内容,当key对象不再被引用的时候,由GC进行回收

  • weakValues:采用弱引用方式存储value值内容,当value对象不再被引用的时候,由GC进行回收

  • softValues:采用软引用方式存储value值内容,当内存容量满时基于LRU策略进行回收

weakKeys

默认情况下,创建出一个Caffeine缓存对象并写入key-value映射数据时,key和value都是以强引用 的方式存储的。而使用weakKeys可以指定将缓存中的key值以弱引用 (WeakReference)的方式进行存储,这样一来,如果程序运行时没有其它地方使用或者依赖此缓存值的时候,该条记录就可能会被GC回收掉。

ini 复制代码
 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .weakKeys()
                .build(key -> userDao.getUser(key));

两个对象进行比较是否相等的时候,要使用equals方法而非==。而且很多时候我们会主动去覆写hashCode方法与equals方法来指定两个对象的相等判断逻辑。但是基于引用的数据淘汰策略,关注的是引用地址值而非实际内容值,也即一旦使用weakKeys 指定了基于引用方式回收,那么查询的时候将只能是使用同一个key对象(内存地址相同)才能够查询到数据,因为这种情况下查询的时候,使用的是==判断是否为同一个key。

typescript 复制代码
public static void main(String[] args) {
    Cache<String, String> cache = Caffeine.newBuilder()
            .weakKeys()
            .build();
    String key1 = "123";
    cache.put(key1, "value1");
    System.out.println(cache.getIfPresent(key1));
    String key2 = new String("123");
    System.out.println("key1.equals(key2) : " + key1.equals(key2));
    System.out.println("key1==key2 : " + (key1==key2));
    System.out.println(cache.getIfPresent(key2));
}

执行之后,会发现使用存入时的key1进行查询的时候是可以查询到数据的,而使用key2去查询的时候并没有查询到记录,虽然key1与key2的值都是字符串123!

csharp 复制代码
value1
key1.equals(key2) : true
key1 == key2 : false
null

在实际使用的时候,这一点务必需要注意,对于新手而言,很容易踩进坑里

weakValues

与weakKeys类似,可以在创建缓存对象的时候使用weakValues指定将value值以弱引用的方式存储到缓存中。这样当这条缓存记录的对象不再被引用依赖的时候,就会被JVM在适当的时候回收释放掉。

ini 复制代码
 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .weakValues()
                .build(key -> userDao.getUser(key));

实际使用的时候需要注意weakValues不支持AsyncLoadingCache中使用。比如下面的代码:

typescript 复制代码
public static void main(String[] args) {
    AsyncLoadingCache<String, User> cache = Caffeine.newBuilder()
            .weakValues()
            .buildAsync(key -> userDao.getUser(key));
}

启动运行的时候,就会报错:

php 复制代码
Exception in thread "main" java.lang.IllegalStateException: Weak or soft values cannot be combined with AsyncLoadingCache
        at com.github.benmanes.caffeine.cache.Caffeine.requireState(Caffeine.java:201)
        at com.github.benmanes.caffeine.cache.Caffeine.buildAsync(Caffeine.java:1192)
        at com.github.benmanes.caffeine.cache.Caffeine.buildAsync(Caffeine.java:1167)
        at com.veezean.skills.cache.caffeine.CaffeineCacheService.main(CaffeineCacheService.java:297)

当然咯,很多时候也可以将weakKeysweakValues组合起来使用,这样可以获得到两种能力的综合加成。

scss 复制代码
 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .weakKeys()
                .weakValues()
                .build(key -> userDao.getUser(key));

softValues

softValues是指将缓存内容值以软引用 的方式存储在缓存容器中,当内存****容量满 的时候Caffeine会以LRU(least-recently-used,最近最少使用)顺序进行数据淘汰回收。对比下其与weakValues的差异:

  • weakValues:****弱引用方式存储,一旦不再被引用,则会被GC回收

  • softValues:****软引用 方式存储,不会被GC回收,但是在内存容量满的时候,会基于LRU策略数据回收

具体使用的时候,可以在创建缓存对象的时候进行指定基于软引用方式数据淘汰:

ini 复制代码
 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .softValues()
                .build(key -> userDao.getUser(key));

与weakValues一样,需要注意softValues不支持AsyncLoadingCache中使用。此外,还需要注意softValuesweakValues两者也不可以一起使用。

scss 复制代码
public static void main(String[] args) {
    LoadingCache<String, User> cache = Caffeine.newBuilder()
            .weakKeys()
            .weakValues()
            .softValues()
            .build(key -> userDao.getUser(key));
}

启动运行的时候,也会报错:

php 复制代码
Exception in thread "main" java.lang.IllegalStateException: Value strength was already set to WEAK
        at com.github.benmanes.caffeine.cache.Caffeine.requireState(Caffeine.java:201)
        at com.github.benmanes.caffeine.cache.Caffeine.softValues(Caffeine.java:572)
        at com.veezean.skills.cache.caffeine.CaffeineCacheService.main(CaffeineCacheService.java:297)

Ehcache的了解

前面详细的介绍与探讨了Guava CacheCaffeine的实现、特性与使用方式。提到JAVA本地缓存框架,还有一个同样无法被忽视的强大存在 ------ Ehcache!(暂时不做了解了,没必要)

Ehcache、Caffeine、Redis如何选择

EhcacheCaffeineRedis三者之间应该如何选择呢?先看下三者的定位:

  • Caffeine
  1. 更加轻量级,使用更加简单,可以理解为一个

    增强版的HashMap

  2. 足够纯粹,适用于仅需要本地缓存数据的常规场景,可以获取到绝佳的命中率与并发访问性能。

  • Redis
  1. 纯粹的集中缓存,为集群化、分布式多节点场景而生,可以保证缓存的一致性;

  2. 业务需要通过网络进行交互,相比与本地缓存而言

    性能上会有损耗

  • Ehcache
  1. 支持多级缓存扩展能力。通过内存+磁盘等多种存储机制,解决缓存容量问题,适合本地缓存中对容量有特别要求的场景;

  2. 支持缓存数据持久化操作。允许将内存中的缓存数据持久化到磁盘上,进程启动的时候从磁盘加载到内存中;

  3. 支持多节点集群化组网。可以将分布式场景下的各个节点组成集群,实现缓存数据一致,解决缓存漂移问题。

相比而言,Caffeine专注于提供纯粹且简单的本地基础缓存能力、Redis则聚焦统一缓存的数据一致性方面,而Ehcache的功能则是更为的中庸,介于两者之间,既具有本地缓存无可比拟的性能优势,又兼具分布式缓存的多节点数据一致性与容量扩展能力。项目里面进行选型的时候,可以结合上面的差异点,评估下自己的实际诉求,决定如何选择。

简单来说,把握如下原则即可:

  • 如果只是本地简单、少量缓存数据使用的,选择Caffeine

  • 如果本地缓存数据量较大、内存不足需要使用磁盘缓存的,选择EhCache

  • 如果是大型分布式多节点系统,业务对缓存使用较为重度,且各个节点需要依赖并频繁操作同一个缓存,选择Redis

Caffeine与Guava Cache之间如何选择

在Caffeine与Guava Cache之间如何选择?从Spring5开始,其内置的本地缓存框架由Guava Cache切换到了Caffeine。

  • 全新项目,闭眼选Caffeine Java8也已经被广泛的使用多年,现在的新项目基本上都是JAVA8或以上的版本了。

  • 历史低版本JAVA项目 由于Caffeine对JAVA版本有依赖要求,对于一些历史项目的维护而言,如果项目的JDK版本过低 则无法使用Caffeine,这种情况下Guava Cache依旧是一个不错的选择。当然,也可以下定决心将项目的JDK版本升级到JDK1.8+版本,然后使用Caffeine来获得更好的性能体验 ------ 但是对于一个历史项目而言,升级基础JDK版本带来的影响可能会比较大,需要提前评估好。

  • 有同时使用Guava****其它能力 如果你的项目里面已经有引入并使用了Guava提供的相关功能,这种情况下为了避免太多外部组件的引入,也可以直接使用Guava提供的Cache组件能力,毕竟Guava Cache的表现并不算差,应付常规场景的本都缓存诉求完全足够。

相关推荐
千|寻7 分钟前
【画江湖】langchain4j - Java1.8下spring boot集成ollama调用本地大模型之问道系列(第一问)
java·spring boot·后端·langchain
程序员岳焱21 分钟前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
后端·sql·mysql
龚思凯27 分钟前
Node.js 模块导入语法变革全解析
后端·node.js
天行健的回响29 分钟前
枚举在实际开发中的使用小Tips
后端
wuhunyu35 分钟前
基于 langchain4j 的简易 RAG
后端
techzhi35 分钟前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
写bug写bug2 小时前
手把手教你使用JConsole
java·后端·程序员
苏三说技术2 小时前
给你1亿的Redis key,如何高效统计?
后端
JohnYan2 小时前
工作笔记- 记一次MySQL数据移植表空间错误排除
数据库·后端·mysql
程序员清风3 小时前
阿里二面:Kafka 消费者消费消息慢(10 多分钟),会对 Kafka 有什么影响?
java·后端·面试