Caffeine 介绍

Caffeine 是一个高性能 Java 缓存库,提供了近乎最佳的命中率,它是 Guava Cache 的升级版;本文主要介绍它的相关功能及基本使用,文中所使用到的软件版本:Java 1.8.0_341、Caffeine 2.9.3。

1、简介

Caffeine 和 ConcurrentMap 有点相似,但还是有所区别。最根本的区别是 ConcurrentMap 将会持有所有加入到缓存当中的元素,直到它们被从缓存当中手动移除。但是,Caffeine 通常会被配置成自动驱逐缓存中元素,以限制其内存占用。在某些场景下,LoadingCache 和 AsyncLoadingCache 因为其自动加载缓存的能力将会变得非常实用。

Caffeine 提供了灵活的构造器去创建一个拥有下列特性的缓存:

  • 自动加载元素到缓存当中,异步加载的方式也可供选择
  • 当达到最大容量的时候可以使用基于就近度和频率的算法进行基于容量的驱逐
  • 将根据缓存中的元素上一次访问或者被修改的时间进行基于过期时间的驱逐
  • 当向缓存中一个已经过时的元素进行访问的时候将会进行异步刷新
  • key 将自动被弱引用所封装
  • value 将自动被弱引用或者软引用所封装
  • 驱逐(或移除)缓存中的元素时将会进行通知
  • 写入传播到一个外部数据源当中
  • 持续计算缓存的访问统计指标

为了提高集成度,扩展模块提供了JSR-107 JCache 和 Guava 适配器。JSR-107 规范了基于Java 6 的 API,在牺牲了功能和性能的代价下使代码更加规范。Guava 的 Cache 是 Caffeine 的原型库并且Caffeine 提供了适配器以供简单的迁移策略。

2、数据加载

Caffeine 提供了四种缓存数据加载策略:手动加载,自动加载,手动异步加载和自动异步加载。

2.1、手动加载

Cache<Integer, String> cache = Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(10_000)
        .build();
//查找缓存元素,找不到返回 null
String s = cache.getIfPresent(1);
//查找缓存元素,如果找不到则加载缓存元素, 如果无法加载则返回 null
String s2 = cache.get(1, integer -> {
    //TODO: 根据业务加载数据
    return RandomStringUtils.randomAlphanumeric(10);
});
//添加或更新缓存元素
cache.put(1, "abc");
//移除缓存元素
cache.invalidate(1);

Cache 接口提供了显式搜索查找、更新和移除缓存元素的能力。

推荐使用 cache.get(key, k -> value) 方法,在元素不存在的时候会进行计算写入缓存,元素存在的时候会直接返回已存在的元素。一次 cache.put(key, value) 操作将会添加或者更新缓存里的元素,在缓存中已经该元素时会被覆盖。值得注意的是,当缓存的元素无法生成或者在生成的过程中抛出异常而导致生成元素失败,cache.get 可能会返回 null。
当然,也可以使用 Cache.asMap() 所暴露出来的 ConcurrentMap 的方法对缓存进行操作。

2.2、自动加载

LoadingCache<Integer, String> cache = Caffeine.newBuilder()
        .maximumSize(10_1000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build(new CacheLoader<Integer, String>() {
            @Override
            public @Nullable String load(@NonNull Integer integer) throws Exception {
                //TODO: 根据业务加载数据
                return RandomStringUtils.randomAlphanumeric(10);
            }
        });
//查找缓存元素,如果不存在则生成缓存元素, 如果无法生成则返回 null
String s = cache.get(1);
//批量查找缓存元素,如果不存在则生成缓存元素
Map<Integer, String> all = cache.getAll(Arrays.asList(1, 2, 3));

LoadingCache 是 Cache 附加上 CacheLoader 能力之后的缓存。

通过 getAll 可以达到批量查找缓存的目的。 默认情况下,在 getAll 方法中,将会对每个不存在对应缓存的 key 调用一次 CacheLoader.load 来生成缓存元素。 在批量检索比单个查找更有效率的场景下,你可以覆盖 CacheLoader.loadAll 方法来使你的缓存更有效率。

2.3、手动异步加载

AsyncCache<Integer, String> asyncCache = Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(10_000)
        .buildAsync();

//查找缓存元素,找不到返回null
CompletableFuture<String> completableFuture = asyncCache.getIfPresent(1);
//查找缓存元素,如果不存在,则异步生成
CompletableFuture<String> completableFuture2 = asyncCache.get(1, integer -> {
    //TODO: 根据业务加载数据
    return RandomStringUtils.randomAlphanumeric(10);
});
//添加或更新缓存元素
asyncCache.put(1, CompletableFuture.supplyAsync(() -> {
    //TODO: 根据业务加载数据
    return RandomStringUtils.randomAlphanumeric(10);
}));
//移除缓存元素
asyncCache.synchronous().invalidate(1);

AsyncCache 是 Cache 的变体,AsyncCache 提供了在 Executor 上生成缓存元素并返回 CompletableFuture 的能力。这提供了在当前流行的响应式编程模型中利用缓存的能力。

synchronous() 方法会把 AsyncCache 转成 Cache,该方法会阻塞直到计算完成。

当然,也可以使用 AsyncCache.asMap() 所暴露出来的 ConcurrentMap 的方法对缓存进行操作。

默认的线程池实现是 ForkJoinPool.commonPool() ,当然你也可以通过覆盖 Caffeine.executor(Executor) 方法来使用你自己定义的线程池。

2.4、自动异步加载

AsyncLoadingCache<Integer, String> asyncLoadingCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .buildAsync(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });
// 查找缓存元素,如果不存在则异步生成
CompletableFuture<String> completableFuture = asyncLoadingCache.get(1);
//批量查找缓存元素,如果不存在则异步生成
CompletableFuture<Map<Integer, String>> completableFuture2 = asyncLoadingCache.getAll(Arrays.asList(1, 2, 3));

AsyncLoadingCache 是 AsyncCache 附加上 AsyncCacheLoader能力的缓存。

在需要同步的方式去生成缓存元素的时候,CacheLoader 是合适的选择。而在异步生成缓存的场景下, AsyncCacheLoader 则是更合适的选择并且它会返回一个 CompletableFuture。

通过 getAll 可以达到批量查找缓存的目的。 默认情况下,在 getAll 方法中,将会对每个不存在对应缓存的 key 调用一次 AsyncCacheLoader.asyncLoad 来生成缓存元素。 在批量检索比单个查找更有效率的场景下,你可以覆盖 AsyncCacheLoader.asyncLoadAll 方法来使你的缓存更有效率。

值得注意的是,你可以实现 AsyncCacheLoader.asyncLoadAll 方法并在其中为没有请求的 key 也生成缓存元素。例如,如果一组 key 中的任何 key 计算出的元素和改组中其他的 key 计算出的元素一直,那么在 asyncLoadAll 中也可以同时加载剩下的 key 对应的元素到缓存当中 。

3、数据移除

3.1、驱逐策略

Caffeine 提供了三种驱逐策略,分别是基于容量的驱逐,基于时间的驱逐和基于引用的驱逐。

3.1.1、基于容量的驱逐

//基于元素个数驱逐
LoadingCache<Integer, String> loadingCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });
//基于元素权重驱逐
LoadingCache<Integer, String> loadingCache2 = Caffeine.newBuilder()
        .maximumWeight(10_000)
        .weigher((Weigher<Integer, String>) (key, value) -> value.getBytes().length)
        .build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });

如果你的缓存容量不希望超过某个特定的大小,那么记得使用 Caffeine.maximumSize(long)。缓存将会尝试通过基于"就近度和频率"的算法来驱逐掉不会再被使用到的元素。

另外,如果不同的缓存条目具有不同的"权重",例如,如果缓存元素具有完全不同的内存占用,可以使用 Caffeine.weigher(Weigher) 指定一个权重函数,并使用 Caffeine.maximumWeight(long) 指定最大缓存权重。除了与 maximumSize 相同的注意事项之外,需要注意的是,权重是在条目创建和更新时计算的,之后保持不变,并且在进行驱逐选择时不使用相对权重。

3.1.2、基于时间的驱逐

//基于固定时间的过期时间驱逐
LoadingCache<Integer, String> loadingCache = Caffeine.newBuilder()
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });
//根据时间情况自己实现基于时间的过期策略
LoadingCache<Integer, String> loadingCache2 = Caffeine.newBuilder()
        .expireAfter(new Expiry<Integer, String>() {
            @Override
            public long expireAfterCreate(Integer key, String value, long currentTime) {
                if (key < 1000) {
                    return TimeUnit.MINUTES.toNanos(5);
                } else {
                    return TimeUnit.MINUTES.toNanos(10);
                }
            }
            @Override
            public long expireAfterUpdate(Integer key, String value, long currentTime, long currentDuration) {
                return currentDuration;
            }
            @Override
            public long expireAfterRead(Integer key, String value, long currentTime, long currentDuration) {
                return currentDuration;
            }
        }).build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });

Caffeine 提供了三种基于时间的驱逐策略:

expireAfterAccess(long, TimeUnit): 自上次读或写访问以来经过指定时间后,使缓存数据过期。如果缓存数据与会话绑定并由于不活动而过期,则这是理想的选择。
expireAfterWrite(long, TimeUnit): 在创建缓存条目或替换值后,经过指定的时间后使条目过期。如果缓存数据在一定时间后变得陈旧,则这是理想的选择。
expireAfter(Expiry): 在经过可变的持续时间后,使条目过期。如果条目的过期时间由外部资源确定,则这是理想的选择。

在写操作和偶尔的读操作中将会进行周期性的过期事件执行。过期事件的调度和触发将会在O(1)的时间复杂度内完成。

如果需要及时过期,而不是依赖其他缓存活动来触发常规维护,可以使用 Scheduler 接口和 Caffeine.scheduler(Scheduler) 方法在缓存构建器中指定一个调度线程。使用 Java 9+ 的用户可以使用Scheduler.systemScheduler() 来利用专用的系统级调度线程。

3.1.3、基于引用的驱逐

//key 和 value 都是弱引用
LoadingCache<Integer, String> loadingCache = Caffeine.newBuilder()
        .weakKeys()
        .weakValues()
        .build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });
//value 为软引用
LoadingCache<Integer, String> loadingCache2 = Caffeine.newBuilder()
        .softValues()
        .build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });

Caffeine 可以让 GC 帮助清理缓存当中的元素,其中 key 支持弱引用,而 value 则支持弱引用和软引用。注意 AsyncCache不支持软引用和弱引用。

Caffeine.weakKeys() 使用弱引用存储键。如果没有其他对键的强引用,这将允许条目被垃圾回收。由于垃圾回收仅依赖于内存地址相等性,因此整个缓存将使用(==)来比较键,而不是 equals()。
Caffeine.weakValues() 使用弱引用存储值。如果没有其他对值的强引用,这将允许条目被垃圾回收。由于垃圾回收仅依赖于内存地址相等性,因此整个缓存将使用(==)来比较值,而不是 equals()。
Caffeine.softValues() 使用软引用存储值。软引用对象会根据内存需求以全局最近最少使用的方式进行垃圾回收。由于使用软引用会影响性能,我们通常建议使用可预测的最大缓存大小。使用 softValues() 将导致值使用(==)来比较,而不是 equals()。

3.2、显式移除

在任何时候,你都可以手动去让某个缓存元素失效而不是只能等待其因为策略而被驱逐。

//失效一个 key
cache.invalidate(key)
//批量失效 key
cache.invalidateAll(keys)
//失效所有的 key
cache.invalidateAll()

3.3、移除监听器

Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumSize(1)
        .evictionListener((key, value, cause) -> log.info("{}由于驱逐策略被移除了", key))
        .removalListener((key, value, cause) -> log.info("{}被移除了", key))
        .build();

可以通过 Caffeine.removalListener(RemovalListener) 指定一个移除监听器以在条目被移除时执行一些操作。这些操作将使用 Executor 异步执行,默认的 Executor 是 ForkJoinPool.commonPool(),可以通过 Caffeine.executor(Executor) 指定自定义的 Executor。

当仅需在因驱逐策略移除缓存元素时执行相关操作,可以使用 Caffeine.evictionListener(RemovalListener)。只有在 RemovalCause.wasEvicted() 为 true 时才会通知此监听器。对于显式移除,Cache.asMap() 提供了原子执行的计算方法。

注意,任何由 RemovalListene r抛出的异常都将被记录下来(使用System.Logger)并被忽略。

3.4、刷新

LoadingCache<Integer, String> loadingCache = Caffeine.newBuilder()
        .maximumSize(10_1000)
        .refreshAfterWrite(5, TimeUnit.MINUTES)
        .build(key -> {
            //TODO: 根据业务加载数据
            return RandomStringUtils.randomAlphanumeric(10);
        });

刷新和驱逐并不相同。可以通过 LoadingCache.refresh(K) 方法异步刷新数据。与驱逐不同的是,在刷新的时候查询缓存元素,仍返回旧值,直到该元素刷新结束后才会返回刷新后的新值。

与 expireAfterWrite不同,refreshAfterWrite 会使一个键在特定时长后有资格进行刷新,但只有在查询条目时才会实际发起刷新。举例来说,你可以在一个缓存上同时指定 refreshAfterWrite 和expireAfterWrite,以便当一个条目有资格进行刷新时,不会盲目地重设条目的过期计时器。如果一个条目在有资格进行刷新之后未被查询,它将被允许过期。

可以通过重写 CacheLoader.reload(K, V) 方法使得在刷新中可以将旧值也参与到更新的过程中去,这也使得刷新操作显得更加智能。

刷新操作将会异步执行在一个Executor上。默认的线程池是 ForkJoinPool.commonPool(),当然也可以通过 Caffeine.executor(Executor) 方法设置自定义的线程池。

如果在刷新时抛出异常,则会保留旧值,记录异常(使用System.Logger)并将其忽略。

4、统计指标

Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumSize(10_1000)
        .recordStats()
        .build();

Caffeine.recordStats() 方法可以打开数据收集功能。Cache.stats() 方法将返回一个 CacheStats 对象,其含有一些统计指标,比如:

hitRate(): 缓存查询的命中率
evictionCount(): 被驱逐的元素数量
averageLoadPenalty(): 新值被载入的平均耗时

这些统计指标在缓存的调优中十分重要,强烈的建议在性能关键的程序中留意这些统计指标。

5、Guava 适配器

com.google.common.cache.LoadingCache<Integer, String> loadingCache = CaffeinatedGuava.build(
        Caffeine.newBuilder().maximumSize(10_1000),
        new CacheLoader<Integer, String>() {//Guava 的 CacheLoader
            @Override
            public String load(Integer key) throws Exception {
                //TODO: 根据业务加载数据
                return RandomStringUtils.randomAlphanumeric(10);
            }
        });

5.1、API 兼容性

Caffeine 提供了适配器使缓存暴露 Guava 接口。这些适配器提供与 Guava 相同的 API 规范。这些模仿 Guava 的操作已经经过 Guava 的测试组件验证。
当迁移至 Caffeine 接口的时候,记得注意虽然两个缓存之间可能存在相同名字的方法但是操作完全不同。请仔细阅读 JavaDoc 比较两者的用法。

5.2、最大容量 (或总权重)

Guava 将通过 LRU 算法在到达最大容量之前就开始进行驱逐。Caffeine 通过 Window TinyLFU 算法在超过容量之后进行清除。

5.3、立即过期

Guava 将立即过期(expireAfterAccess(0, timeUnit) 和 expireAfterWrite(0, timeUnit))转换为设置最大大小等于零。这导致移除通知原因为容量限制,而非过期时间。Caffeine 正确地识别了过期时间作为移除原因。

5.4、替换通知

Guava 在条目的值由于任何原因被替换时通知移除监听器。而 Caffeine 在前一个值和替换值的引用相等时不会发出通知。

5.5、失效正在计算生成的元素

当元素正在计算生成的时候,Guava 将会忽略将其失效的请求。在 Caffeine 中的做法则是阻塞发起失效请求的线程直到生成计算结束,再将其移除。但是, 当通过 invalidateAll() 批量失效一批正在计算的元素的时候,由于底层 hash 表的抑制,移除操作将跳过正在计算的元素。当使用异步缓存的时候,失效操作将不会阻塞,因为未完成的 future 将被移除,并由失效操作线程发出移除通知。

5.6、异步维护

Guava 将在读写操作中分摊缓存的维护操作。Caffeine 通过 executor(默认: ForkJoinPool.commonPool()) 周期性地执行维护操作。也可以在调用线程中通过 cleanUp() 方法执行维护操作。

5.7、异步通知

Guava 通过队列处理驱逐通知,任何一个线程都可以从这个队列中获取驱逐通知。Caffeine 则交给配置的 executor 去执行(默认: ForkJoinPool.commonPool())。

5.8、异步刷新

Guava 在请求刷新的线程中执行元素的重新计算。Caffeine 则交给 executor 去执行(默认: ForkJoinPool.commonPool())。

5.9、null 值处理

Guava 计算生成元素时如果值为 null 将会抛出异常,如果在刷新过程中出现这种情况,则会保留这个元素。Caffeine 会直接返回 null,如果在刷新过程中出现这种情况,则会移除这个元素。在使用了Guava 适配器的情况下,Caffeine 如果使用了 Guava 的 CacheLoader 接口的话将会选择和 Guava 一样的处理措施。

5.10、Map 视图

Guava 在 21.0 版本之前所继承 ConcurrentMap 的默认方法 (compute,computeIfAbsent,computeIfPresent和 merge)都是非原子性的。Caffeine 实现了这些 Java8 新增方法的原子操作。

5.11、统计信息

Guava 中 CacheStats.loadExceptionCount() 和 CacheStats.loadExceptionRate() 方法在 Caffeine 中被重命名为 CacheStats.loadFailureCount() 和 CacheStats.loadFailureRate()。因为 Caffeine 在计算生成元素时,如果值为 null 会视为加载失败而不是当作异常处理。

5.12、并发

Caffeine 的数据结构会自动适应不同级别的并发。对于出现显著写入争用的情况,请考虑 FAQ 中记录的缓解措施

6、简单使用

6.1、引入依赖

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.9.3</version>
</dependency>
<!--使用 Guava适配器时需要 -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>guava</artifactId>
    <version>2.9.3</version>
</dependency>

6.2、简单使用

public static void main(String[] args) {
    LoadingCache<Long, String> loadingCache = Caffeine.newBuilder()
            .initialCapacity(1000)
            .maximumSize(10000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .refreshAfterWrite(3, TimeUnit.MINUTES)
            .recordStats()
            .build(key -> {
                //TODO: 根据业务加载数据
                return RandomStringUtils.randomAlphanumeric(10);
            });
    log.info(loadingCache.get(1L));
}

 

 

参考:https://github.com/ben-manes/caffeine/wiki/Home-zh-CN

posted @ 2024-01-28 15:39  且行且码  阅读(52)  评论(0编辑  收藏  举报