1. 简介

本文将介绍 Caffeine —— 一个 高性能的 Java 缓存库

缓存和 Map 的一个根本区别在于:缓存会自动淘汰存储的条目。

淘汰策略决定了在任意时刻应该删除哪些对象。这个策略 直接影响缓存的命中率 —— 这是缓存库的核心特性。

Caffeine 使用 Window TinyLfu 淘汰策略,提供了 接近最优的命中率

2. 依赖配置

pom.xml 中添加 caffeine 依赖:

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

最新版本可在 Maven Central 获取。

3. 缓存填充策略

Caffeine 提供 三种缓存填充策略:手动填充、同步加载和异步加载。

先定义一个缓存值类型:

class DataObject {
    private final String data;

    private static int objectCounter = 0;
    // 标准构造器/getter
    
    public static DataObject get(String data) {
        objectCounter++;
        return new DataObject(data);
    }
}

3.1. 手动填充

手动存取值的策略:

初始化缓存:

Cache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .maximumSize(100)
  .build();

使用 getIfPresent 获取值,不存在时返回 null

String key = "A";
DataObject dataObject = cache.getIfPresent(key);

assertNull(dataObject);

通过 put 手动填充缓存

cache.put(key, dataObject);
dataObject = cache.getIfPresent(key);

assertNotNull(dataObject);

使用 get 方法获取值,接受一个 Function 参数。当键不存在时,该函数会计算值并插入缓存:

dataObject = cache
  .get(key, k -> DataObject.get("Data for A"));

assertNotNull(dataObject);
assertEquals("Data for A", dataObject.getData());

get 方法是原子操作。即使多个线程同时请求,计算也只会执行一次。**因此优先使用 get 而非 getIfPresent**。

需要手动淘汰缓存时:

cache.invalidate(key);
dataObject = cache.getIfPresent(key);

assertNull(dataObject);

3.2. 同步加载

此策略接受一个初始化函数,类似手动策略的 get 方法:

初始化缓存:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

使用 get 方法获取值:

DataObject dataObject = cache.get(key);

assertNotNull(dataObject);
assertEquals("Data for " + key, dataObject.getData());

使用 getAll 批量获取值:

Map<String, DataObject> dataObjectMap 
  = cache.getAll(Arrays.asList("A", "B", "C"));

assertEquals(3, dataObjectMap.size());

值从传入 build 方法的 后端初始化函数 中获取。这使得缓存可作为访问值的主接口

3.3. 异步加载

此策略 **与同步加载类似,但异步执行操作,返回包含实际值的 CompletableFuture**:

AsyncLoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .buildAsync(k -> DataObject.get("Data for " + k));

使用 getgetAll 方法,注意它们返回 CompletableFuture

String key = "A";

cache.get(key).thenAccept(dataObject -> {
    assertNotNull(dataObject);
    assertEquals("Data for " + key, dataObject.getData());
});

cache.getAll(Arrays.asList("A", "B", "C"))
  .thenAccept(dataObjectMap -> assertEquals(3, dataObjectMap.size()));

CompletableFuture 提供了丰富的 API,可参考 相关文章

4. 值淘汰策略

Caffeine 提供 三种淘汰策略:基于大小、基于时间和基于引用。

4.1. 基于大小的淘汰

缓存超过配置的大小限制时触发淘汰。有两种计算大小的方式:对象计数或权重计算。

对象计数示例。初始化时大小为 0:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(1)
  .build(k -> DataObject.get("Data for " + k));

assertEquals(0, cache.estimatedSize());

添加值后大小增加:

cache.get("A");

assertEquals(1, cache.estimatedSize());

添加第二个值导致第一个值被淘汰:

cache.get("B");
cache.cleanUp();

assertEquals(1, cache.estimatedSize());

⚠️ 注意:获取大小前调用 cleanUp。因为淘汰是异步的,此方法 确保淘汰操作完成

权重计算示例。传入 weigher 函数:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumWeight(10)
  .weigher((k,v) -> 5)
  .build(k -> DataObject.get("Data for " + k));

assertEquals(0, cache.estimatedSize());

cache.get("A");
assertEquals(1, cache.estimatedSize());

cache.get("B");
assertEquals(2, cache.estimatedSize());

当权重超过 10 时淘汰值:

cache.get("C");
cache.cleanUp();

assertEquals(2, cache.estimatedSize());

4.2. 基于时间的淘汰

此策略 基于条目的过期时间,分三种类型:

  • 访问后过期:条目在最后一次读/写后经过指定时间过期
  • 写入后过期:条目在最后一次写入后经过指定时间过期
  • 自定义策略:通过 Expiry 实现为每个条目单独计算过期时间

使用 expireAfterAccess 配置访问后过期:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterAccess(5, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

使用 expireAfterWrite 配置写入后过期:

cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .weakKeys()
  .weakValues()
  .build(k -> DataObject.get("Data for " + k));

自定义策略需实现 Expiry 接口:

cache = Caffeine.newBuilder().expireAfter(new Expiry<String, DataObject>() {
    @Override
    public long expireAfterCreate(
      String key, DataObject value, long currentTime) {
        return value.getData().length() * 1000;
    }
    @Override
    public long expireAfterUpdate(
      String key, DataObject value, long currentTime, long currentDuration) {
        return currentDuration;
    }
    @Override
    public long expireAfterRead(
      String key, DataObject value, long currentTime, long currentDuration) {
        return currentDuration;
    }
}).build(k -> DataObject.get("Data for " + k));

4.3. 基于引用的淘汰

可配置缓存 允许垃圾回收键和/或值。使用 WeakReference 回收键/值,或 SoftReference 仅回收值。

WeakReference 在对象无强引用时允许回收。SoftReference 基于 JVM 的全局 LRU 策略回收。Java 引用详情见 此处

使用以下方法启用:

  • Caffeine.weakKeys():弱引用键
  • Caffeine.weakValues():弱引用值
  • Caffeine.softValues():软引用值
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .weakKeys()
  .weakValues()
  .build(k -> DataObject.get("Data for " + k));

cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .softValues()
  .build(k -> DataObject.get("Data for " + k));

5. 刷新机制

可配置缓存 在指定时间后自动刷新条目。使用 refreshAfterWrite 方法:

Caffeine.newBuilder()
  .refreshAfterWrite(1, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

关键区别

  • expireAfter:过期条目被请求时,阻塞线程直到新值计算完成
  • refreshAfter:符合刷新条件的条目,返回旧值并异步加载新值

6. 统计功能

Caffeine 提供 缓存使用统计

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .recordStats()
  .build(k -> DataObject.get("Data for " + k));
cache.get("A");
cache.get("A");

assertEquals(1, cache.stats().hitCount());
assertEquals(1, cache.stats().missCount());

也可向 recordStats 传入 StatsCounter 实现的工厂方法,用于自定义统计逻辑。

7. 总结

本文介绍了 Java 的 Caffeine 缓存库。我们学习了如何配置和填充缓存,以及如何根据需求选择合适的淘汰或刷新策略。

本文源码可在 GitHub 获取。


原始标题:Introduction to Caffeine

» 下一篇: Java Weekly, 第199期