Caffeine

Caffeine is a high performance, near optimal caching library based on Java 8.

特性

Caffeine 提供灵活的结构,以创建一个缓存与下列功能的组合:

  • 自动将条目加载到缓存中,可选异步加载

  • 当基于频率和最近度超过最大值时,基于尺寸的驱逐

  • 基于时间的条目过期,从上次访问或上次写入开始计算

  • 当出现第一个过时的条目请求时,异步刷新

  • 自动封装在弱引用中的键

  • 自动封装在弱引用或软引用中的值

  • 退出(或以其他方式删除)条目的通知

  • 传播到外部资源的写

  • 缓存访问统计数据的积累

性能对比

读取性能

优秀之处

guava-cache-api 兼容。

ps: 这使我想起了 ssdb 替代 redis,也默认可以使用 redis 的 api 一样。

站在巨人的肩膀上。

快速开始

maven 引入

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

代码

  • DataObject.java

用于演示的实体类。

public class DataObject {

    private final String data;

    /**
     * 计数器
     */
    private volatile static AtomicInteger counter = new AtomicInteger(0);

    public DataObject(String data) {
        this.data = data;
    }

    public String getData() {
        return data;
    }

    public static int getCounter() {
        return counter.get();
    }

    public static DataObject get(final String key) {
        counter.getAndIncrement();
        return new DataObject(key);
    }

}

基础功能

  • baseTest
/**
 * 基础测试
 */
@Test
public void baseTest() {
    Cache<String, DataObject> cache = Caffeine.newBuilder()
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .maximumSize(100)
            .build();
    // 1. getIfPresent
    String key = "A";
    DataObject dataObject = cache.getIfPresent(key);
    assertNull(dataObject);

    // 2. put
    cache.put(key, new DataObject(key));
    assertNotNull(cache.getIfPresent(key));

    // 3. get 方法可以原子方式执行计算。这意味着您只进行一次计算 — 即使多个线程同时请求该值。
    // 这就是为什么使用 get 优于 getIfPresent。
    final String newKey = "B";
    dataObject = cache.get(newKey, k -> DataObject.get("Data for B"));
    assertNotNull(dataObject);
    assertEquals("Data for B", dataObject.getData());

    // 4. 使得某个键失效
    cache.invalidate(key);
    dataObject = cache.getIfPresent(key);
    assertNull(dataObject);
}

同步加载

这种加载缓存的方法使用了与用于初始化值的 Function 相似的手动策略的 get 方法。让我们看看如何使用它。

  • syncLoadTest()
/**
 * 同步加载测试
 */
@Test
public void syncLoadTest() {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .build(k -> DataObject.get("Data for " + k));
    final String key = "A";

    //1. 这个获取的仍然为空
    DataObject dataObject = cache.getIfPresent(key);
    assertNull(dataObject);

    //2. get() 检索值
    dataObject = cache.get(key);
    assertNotNull(dataObject);
    assertEquals("Data for " + key, dataObject.getData());

    //3. getAll() 获取一组值
    Map<String, DataObject> dataObjectMap
            = cache.getAll(Arrays.asList("A", "B", "C"));
    assertEquals(3, dataObjectMap.size());
}

异步加载

  • asyncLoadTest()

和同步加载类似。但返回值为 CompletableFuture

/**
 * 异步加载测试
 */
@Test
public void asyncLoadTest() {
    AsyncLoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .buildAsync(k -> DataObject.get("Data for " + k));
    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()));
}

值回收

Caffeine 有三个值回收策略:基于大小,基于时间和参考。

基于大小回收

  • 基于大小删除元素
/**
 * 基于大小删除元素
 */
@Test
public void maximumSizeTest() {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .maximumSize(1)
            .build(k -> DataObject.get("Data for " + k));
    // 1. 初始值为 0
    assertEquals(0, cache.estimatedSize());

    // 2. 添加元素之后,+1
    cache.get("A");
    assertEquals(1, cache.estimatedSize());

    // 3. 超出最大值之后,则会删除第一个元素
    cache.get("B");
    // 这是因为缓存回收被异步执行,这种方法有助于等待回收的完成。
    cache.cleanUp();
    assertEquals(1, cache.estimatedSize());
}
  • 基于权重大小删除元素
/**
 * 基于权重大小删除元素
 */
@Test
public void maximumWeightTest() {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .maximumWeight(5)
            .weigher((k,v)->5)
            .build(k -> DataObject.get("Data for " + k));
    // 1. 初始值为 0
    assertEquals(0, cache.estimatedSize());

    // 2. 添加元素之后,+1
    cache.get("A");
    assertEquals(1, cache.estimatedSize());

    // 3. 超出最大值之后,则会删除第一个元素
    cache.get("B");
    // 这是因为缓存回收被异步执行,这种方法有助于等待回收的完成。
    cache.cleanUp();
    assertEquals(1, cache.estimatedSize());
}

基于时间回收

这种回收策略是基于条目的到期时间,有三种类型:

  1. 访问后到期 — 从上次读或写发生后,条目即过期。

  2. 写入后到期 — 从上次写入发生之后,条目即过期。

  3. 自定义策略 — 到期时间由 Expiry 实现独自计算。

访问后过期

@Test
public void expireAfterAccessTest() throws InterruptedException {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .expireAfterAccess(2, TimeUnit.SECONDS)
            .build(k -> DataObject.get("Data for " + k));
            
    final String key = "A";
    cache.get(key);
    TimeUnit.SECONDS.sleep(2);
    assertNull(cache.getIfPresent(key));
}

写入后到期

@Test
public void expireAfterWriteTest() throws InterruptedException {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .expireAfterWrite(2, TimeUnit.SECONDS)
            .build(k -> DataObject.get("Data for " + k));

    final String key = "A";
    cache.get(key);

    TimeUnit.SECONDS.sleep(2);
    assertNull(cache.getIfPresent(key));
}

自定义过期策略

@Test
public void defineExpireTest() {
    LoadingCache<String, DataObject> 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));
}

基于引用回收

我们可以将缓存配置为启用缓存键值的垃圾回收。为此,我们将 key 和 value 配置为 弱引用,并且我们可以仅配置软引用以进行垃圾回收。

当没有任何对对象的强引用时,使用 WeakRefence 可以启用对象的垃圾收回收。

SoftReference 允许对象根据 JVM 的全局最近最少使用(Least-Recently-Used)的策略进行垃圾回收。

  • 弱引用
@Test
public void weakTest() {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .weakKeys()
            .weakValues()
            .build(k -> DataObject.get("Data for " + k));
}
  • 软引用
@Test
public void softTest() {
    LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .softValues()
            .build(k -> DataObject.get("Data for " + k));
}

刷新策略

可以将缓存配置为在定义的时间段后自动刷新条目。

让我们看看如何使用 refreshAfterWrite 方法:

@Test
public void refreshTest() {
    Caffeine.newBuilder()
            .refreshAfterWrite(1, TimeUnit.MINUTES)
            .build(k -> DataObject.get("Data for " + k));
}

这里我们应该要明白 expireAfter 和 refreshAfter 之间的区别。

当请求过期条目时,执行将发生阻塞,直到 build Function 计算出新值为止。

但是,如果条目可以刷新,则缓存将返回一个旧值,并异步重新加载该值。

统计

Caffeine 有一种记录缓存使用情况的统计方式:

我们也可能会传入 recordStats supplier,创建一个 StatsCounter 的实现。每次与统计相关的更改将推送此对象。

@Test
public void statsTest() {
    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());
}

参考资料

  • learn

https://www.baeldung.com/java-caching-caffeine

https://www.jianshu.com/p/ba2ac225836d

  • article

https://www.voxxed.com/2015/12/add-a-boost-of-caffeine-to-your-java/

http://highscalability.com/blog/2016/1/25/design-of-a-modern-cache.html

  • 淘汰算法

TinyLFU: A Highly Efficient Cache Admission Policy