缓存和 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;
}
}
}
但是,上面两个示例都不是并发安全的,需要加锁.
LFU
LFU(Least Frequently Used)算法根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
LFU的每个数据块都有一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。
- 新加入数据插入到队列尾部(因为引用计数为1);
- 队列中的数据被访问后,引用计数增加,队列重新排序;
- 当需要淘汰数据时,将已经排序的列表最后的数据块删除。
一般情况下,LFU效率要优于LRU,且能够避免周期性或者偶发性的操作导致缓存命中率下降的问题。但LFU需要记录数据的历史访问记录,一旦数据访问模式改变,LFU需要更长时间来适用新的访问模式,即:LFU存在历史数据影响将来数据的“缓存污染”效用。
需要维护一个队列记录所有数据的访问记录,每个数据都需要维护引用计数。需要记录所有数据的访问记录,内存消耗较高;需要基于引用计数排序,性能消耗较高。
Guava cache 实现SLRU
对上面的示例,要并发安全,只能加锁。为了改善加锁后开销,可以像ConcurrentHashMap一样,把table分到一个个segment下,每个segment对应一个锁,来分散全局锁带来的性能损失,GuavaCache就是这样的实现,如下图
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优点的实现方式:
其中TinyLFU维护了近期访问记录的频率信息,作为一个过滤器,当新记录来时,只有满足TinyLFU要求的记录才可以被插入缓存。其中实现算法采用ount-Min Sketch算法。以LRU作为淘汰方式,TinyLFU作为许入过滤器。
CountMin Sketch 通过矩阵和多个hash函数实现:
一个key,通过不同的hash定位到数组index,值加1,最后取min(8,6,7,6)=6作为此key的访问次数记录。因为hash冲突的原因,值不一定准确,但对于LFU的实现,这个误差可以忽略。下面我们看它是怎么解决LFU缺点的。
因为使用矩阵,跟数据量大小没关系,很好地解决了LFU的内存开销问题。对于数据年龄,可以添加一个计数上限,一旦到达上限,所有记录的Sketch数据都除2,从而实现衰减效果,对于短暂热点数据,如果之后一直没有访问,count/2不断衰减,直至淘汰。
下图是另一种表达方式: sketch 作为过滤器(filter)。当新来的数据比要驱逐的数据高频时,这个数据才会被缓存接纳。
窗口缓存占用总大小的1%左右,主缓存占用99%。Caffeine可以根据工作负载特性动态调整窗口和主空间的大小,如果新增数据频率比较高,大窗口更受欢迎;如果新增数据频率偏小,小窗口更受欢迎。主缓存内部包含两个部分,一部分为Protected,用于存比较热的数据,它占用主缓存80%空间;另一部分是Probation,用于存相对比较冷的数据,占用主缓存20%空间,数据可以在这两部分空间里面互相转移。
缓存淘汰的过程:新添加的数据首先放入窗口缓存中,如果窗口缓存满了,则把窗口缓存淘汰的数据转移到主缓存Probation区域中。如果这时主缓存也满了,则从主缓存的Probation区域淘汰数据,把这条数据称为受害者,从窗口缓存淘汰的数据称为候选人。接下来候选人和受害者进行一次pk,来决定去留。pk的方式是通过TinyFLU记录的访问频率来进行判断
首先获取候选人和受害者的频率
1.如果候选人大于受害者,则淘汰受害者
- 如果候选人频率小于等于5,则淘汰候选人
- 其余情况随机处理。
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使用上述这些策略,来提高其在缓存过期清除时的效率。