Caffeine使用及原理

缓存和 Map 之间的一个根本区别在于缓存可以回收存储的 item。
回收策略为在指定时间删除哪些对象。此策略直接影响缓存的命中率 —— 缓存库的一个重要特性。Caffeine 因使用了 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率。

依赖

我们需要在 pom.xml 中添加 caffeine 依赖:


    com.github.ben-manes.caffeine
    caffeine
    2.5.5


填充缓存

让我们来了解一下 Caffeine 的三种缓存填充策略:手动、同步加载和异步加载。

首先,我们为要缓存中存储的值类型写一个类:

class DataObject {
    private final String data;
 
    private static int objectCounter = 0;
    // standard constructors/getters
     
    public static DataObject get(String data) {
        objectCounter++;
        return new DataObject(data);
    }
}
手动填充

在此策略中,我们手动将值放入缓存后再检索。

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

我们可以使用 put 方法手动填充缓存:

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

assertNotNull(dataObject);

我们也可以使用 get 方法获取值,该方法将一个参数为 key 的 Function 作为参数传入。如果缓存中不存在该 key,则该函数将用于提供默认值,该值在计算后插入缓存中:

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

get 方法可以以原子方式执行计算。这意味着你只进行一次计算 —— 即使有多个线程同时请求该值.

有时我们需要手动触发一些缓存的值失效:

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

同步加载

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

首先,我们需要初始化缓存:

LoadingCache 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 dataObjectMap 
  = cache.getAll(Arrays.asList("A", "B", "C"));
 
assertEquals(3, dataObjectMap.size());

从传给 build 方法的初始化函数检索值,这使得可以使用缓存作为访问值的主要门面(Facade)。

异步加载

此策略的作用与之前相同,但是以异步方式执行操作,并返回一个包含值的 CompletableFuture:

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

我们可以以相同的方式使用 get 和 getAll 方法,同时考虑到他们返回的是 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()));

值回收

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

基于大小回收:
这种回收方式假定当缓存大小超过配置的大小限制时会发生回收。 获取大小有两种方法:缓存中计数对象,或获取权重。

LoadingCache 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 Function 来获取缓存的大小:

LoadingCache 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());

当 weight 超过 10 时,值将从缓存中删除:

cache.get("C");
cache.cleanUp();
 
assertEquals(2, cache.estimatedSize());

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

  • 访问后到期 — 从上次读或写发生后,条目即过期。
  • 写入后到期 — 从上次写入发生之后,条目即过期。
  • 自定义策略 — 到期时间由 Expiry 实现独自计算。

访问后过期

@Test
public void expireAfterAccessTest() throws InterruptedException {
    LoadingCache 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 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 cache = Caffeine.newBuilder().expireAfter(new Expiry() {
        @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 cache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .weakKeys()
            .weakValues()
            .build(k -> DataObject.get("Data for " + k));
}

软引用

@Test
public void softTest() {
    LoadingCache cache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .softValues()
            .build(k -> DataObject.get("Data for " + k));
}

原理

淘汰策略

LRU

LRU的全称是Least Recently Used,最近最少使用。如果缓存满了,把最近没有被使用到的数据移出。它的思想是:如果数据最近被访问过,将来被访问的概率也更高。

最简单的方式就是使用LinkedHashMap,几行代码就能实现。

public class LruCacheLinkedHashMapLazy implements LruCache {
    private LinkedHashMap map;
    private final int CAPACITY;

    public LruCacheLinkedHashMapLazy(int capacity) {
        CAPACITY = capacity;
        map = new LinkedHashMap(capacity, 0.75f, true) {
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > CAPACITY;
            }
        };
    }
    @Override
    public V get(K key) {
        return map.getOrDefault(key, null);
    }
    @Override
    public void put(K key, V value) {
        map.put(key, value);
    }
}

LinkedHashMap通过hash表和双向链表维护数据,如果初始化时accessOrder=true,新加入或者访问的数据都会放到链表头部,这样,一直没被访问的node就会被顶到尾部,如果超出缓存大小,把链表尾部的数据删除即可。

也可以通过HashMap和Doubley-Linked实现,这样更清晰看出怎么通过双向链表实现:

public class LRUCacheHashMapAndDoublyLinked implements LruCache {
    private class Node {
        K key;
        V value;
        Node prev, next;

        Node(K k, V v) {
            this.key = k;
            this.value = v;
        }
    }

    private int capacity, count;
    private Map map;
    private Node head, tail;

    public LRUCacheHashMapAndDoublyLinked(int capacity) {
        this.capacity = capacity;
        this.count = 0;
        map = new HashMap<>();
        head = new Node(null, null);
        tail = new Node(null, null);
        head.next = tail;
        tail.prev = head;
    }

    private void update(Node node) {
        remove(node);
        add(node);
    }

    private void add(Node node) {
        Node after = head.next;
        head.next = node;
        node.prev = head;
        node.next = after;
        after.prev = node;
    }

    private void remove(Node node) {
        Node before = node.prev, after = node.next;
        before.next = after;
        after.prev = before;
    }

    @Override
    public V get(K k) {
        Node n = map.get(k);
        if (null == n) {
            return null;
        }
        update(n);
        return n.value;
    }

    @Override
    public void put(K key, V value) {
        Node n = map.get(key);
        if (null == n) {
            n = new Node(key, value);
            map.put(key, n);
            add(n);
            ++count;
        } else {
            n.value = value;
            update(n);
        }
        if (count > capacity) {
            Node toDel = tail.prev;
            remove(toDel);
            map.remove(toDel.key);
            --count;
        }
    }
}

image.png

但是,上面两个示例都不是并发安全的,需要加锁.

LFU

LFU(Least Frequently Used)算法根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

LFU的每个数据块都有一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。

image.png
  1. 新加入数据插入到队列尾部(因为引用计数为1);
  2. 队列中的数据被访问后,引用计数增加,队列重新排序;
  3. 当需要淘汰数据时,将已经排序的列表最后的数据块删除。
  • 一般情况下,LFU效率要优于LRU,且能够避免周期性或者偶发性的操作导致缓存命中率下降的问题。但LFU需要记录数据的历史访问记录,一旦数据访问模式改变,LFU需要更长时间来适用新的访问模式,即:LFU存在历史数据影响将来数据的“缓存污染”效用。

  • 需要维护一个队列记录所有数据的访问记录,每个数据都需要维护引用计数。需要记录所有数据的访问记录,内存消耗较高;需要基于引用计数排序,性能消耗较高。

Guava cache 实现SLRU

对上面的示例,要并发安全,只能加锁。为了改善加锁后开销,可以像ConcurrentHashMap一样,把table分到一个个segment下,每个segment对应一个锁,来分散全局锁带来的性能损失,GuavaCache就是这样的实现,如下图

image.png

guava cache还维护两个队列,accesQueue和writeQueue,用来实现segement的局部LRU和过期时间。另外还有一个recencyQueue,它用来提高accessQueue更新锁消耗。如果每次访问都加锁更新accessQueue,影响性能,guava把访问的数据更新到recencyQueue,recencyQueue通过ConcurrentLinkedQueue实现,并发安全。等写入数据时,再加锁从recencyQueue更新到accesQueue。

另外,对于过期数据的清理,guava并不是另起一个线程,而是每次有访问的时候才清理。

W-TinyLFU

不管是LFU还是LRU,都是希望在缓存填满后,淘汰掉那些在短期内可能不会使用的数据,从而提升缓存的命中率。LRU和LFU都有他的局限性。LRU是一个比较流行的算法,它什么问题?它不是抗扫描的。比如cache的大小是N,缓存N+1个item(第一个会被淘汰),如果顺序发起N个请求。将导致没有一个命中缓存。

而TinyLFU有一个缺点,在应对突发流量的时候,可能由于没有及时构建足够的频率数据来保证自己驻留在缓存中,从而导致缓存的命中率下降,为了解决这个问题,产生了W-TinyLFU算法。
W-TinyLFU由两部分组成,主缓存使用SLRU回收策略和TinyLFU回收策略,而窗口缓存使用没有任何回收策略的LRU回收策略,增加的窗口缓存用于应对突发流量的问题。

W-TinyLFU 是一个综合LRU和LFU优点的实现方式:


image.png

其中TinyLFU维护了近期访问记录的频率信息,作为一个过滤器,当新记录来时,只有满足TinyLFU要求的记录才可以被插入缓存。其中实现算法采用ount-Min Sketch算法。以LRU作为淘汰方式,TinyLFU作为许入过滤器。

CountMin Sketch 通过矩阵和多个hash函数实现:


image.png

一个key,通过不同的hash定位到数组index,值加1,最后取min(8,6,7,6)=6作为此key的访问次数记录。因为hash冲突的原因,值不一定准确,但对于LFU的实现,这个误差可以忽略。下面我们看它是怎么解决LFU缺点的。

因为使用矩阵,跟数据量大小没关系,很好地解决了LFU的内存开销问题。对于数据年龄,可以添加一个计数上限,一旦到达上限,所有记录的Sketch数据都除2,从而实现衰减效果,对于短暂热点数据,如果之后一直没有访问,count/2不断衰减,直至淘汰。

下图是另一种表达方式: sketch 作为过滤器(filter)。当新来的数据比要驱逐的数据高频时,这个数据才会被缓存接纳。

image.png

窗口缓存占用总大小的1%左右,主缓存占用99%。Caffeine可以根据工作负载特性动态调整窗口和主空间的大小,如果新增数据频率比较高,大窗口更受欢迎;如果新增数据频率偏小,小窗口更受欢迎。主缓存内部包含两个部分,一部分为Protected,用于存比较热的数据,它占用主缓存80%空间;另一部分是Probation,用于存相对比较冷的数据,占用主缓存20%空间,数据可以在这两部分空间里面互相转移。

缓存淘汰的过程:新添加的数据首先放入窗口缓存中,如果窗口缓存满了,则把窗口缓存淘汰的数据转移到主缓存Probation区域中。如果这时主缓存也满了,则从主缓存的Probation区域淘汰数据,把这条数据称为受害者,从窗口缓存淘汰的数据称为候选人。接下来候选人和受害者进行一次pk,来决定去留。pk的方式是通过TinyFLU记录的访问频率来进行判断

首先获取候选人和受害者的频率
1.如果候选人大于受害者,则淘汰受害者

  1. 如果候选人频率小于等于5,则淘汰候选人
  2. 其余情况随机处理。

Caffeine中的pk代码:

 boolean admit(K candidateKey, K victimKey) {

 int victimFreq = frequencySketch().frequency(victimKey);

 int candidateFreq = frequencySketch().frequency(candidateKey);

 if (candidateFreq > victimFreq) {

 return true;

 } else if (candidateFreq <= 5) {

 return false;

 }

 int random = ThreadLocalRandom.current().nextInt();

 return ((random & 127) == 0);

 }

读写优化

Caffeine的底层数据存储采用ConcurrentHashMap。因为Caffeine面向JDK8,在jdk8中ConcurrentHashMap增加了红黑树,在hash冲突严重时也能有良好的读性能。
Caffeine的缓存清除动作是触发式的,它可能发生在读、写请求后。这个动作在Caffeine中是异步执行的,默认执行的线程池是ForkJoinPool.commonPool()。相比较Guava cache的同步执行清除操作,Caffeine的异步执行能够提高读写请求的效率。针对读写请求后的异步操作(清除缓存、调整LRU队列顺序等操作),Caffeine分别使用了ReadBuffer和WriterBuffer。使用Buffer一方面能够合并操作,另一方面可以避免锁争用的问题。

在时间的过期策略中,Caffeine针对不同的过期策略采用不同的实现:针对写后过期,维护了一个写入顺序队列;针对读后过期,维护了一个读取顺序队列;针对expireAfter指定的多种时间组合过期策略中,采用了二维时间轮来实现。Caffeine使用上述这些策略,来提高其在缓存过期清除时的效率。

你可能感兴趣的:(Caffeine使用及原理)