缓存
一、金字塔的存贮体系
如下图所示,从上到下依次为寄存机,一级缓存,二级缓存,主存,外存(本地磁盘),远程存贮(分布式系统,Web服务器)。
缓存的作用,由于在每一级的读取速度都比下一级的读取速度快很多,如果CPU运算时,每次都去速度较慢的主存或者外存中去获得数据,必然会等待和浪费较长时间。因此就会诞生了缓存,提前将一部分的内容从主存取到缓存中,从外存取到内存中,从远地的资源中取到本地,从而缩短时间,提高效率。
具体请参见CASPP
二、缓存的处理机制
当我们有了缓存之后,Cpu的读取数据过程如下:
1. CPU就会先去缓存中取数据,如果该数据存在,就命中;
2. 如果缓存中没有,将去内存中查找,如果内存中存在,就将包含该数据返回给CPU,同时将所在的一页(一般会采用分页式的存贮结构)的数据写进缓存;
3. 如果内存中没有,则去外存中查找,如果外存中存在,将数据返回的同时,并将主存中该块的内容写进内存。
ps: 1. 为什么会将该页或者块的内容缓存?这里利用了局部性原理( Locality)。
2. 从这个过程中,我们可以看到多级缓存的里面,一级二级缓存保留的数据其实是内存中数据的一部分,内存又是做为外存的缓存,同样外存也可以作为更大的数据源的缓存。
三、缓存替换策略
当缓存区满了,并且没有命中数据时,这个时候就需要按照一定策略(或者说算法)把旧的对象提出,把新的对象加入缓存中。常见的缓存的算法有以下几种:
LRU (Least Recently Used)
LFU (Least Frequently Used)
FIFO
Random Cache(随机替换算法)
因为在实际中的业务场景不一样,就需要根据具体的业务来选择最合适的缓存替换策略。
四、Java实现LRU和LFU
LRU: 最近最少被使用的内容被替换,
首先因为希望能够较快速度查到缓存内容这里可以采用hash表的方式;
其次需要查询出最近最晚被使用的项,给最近使用的项做上标记;
最后删除相应的项。
LFU:代码如下
Leetcode题目参见:https://leetcode.com/problems/lfu-cache/#/description
主要思路:
使用HashMap来做缓存,保证整个操作在O(1)的时间复杂度内,其次,因为要根据每个节点的使用频率来选择最少被使用的节点删除,所以需要保存使用频率,在相同频率时使用FIFO,故而在每个频度下选择了LinkedHashSet结构。
注意点:
1. 在增加同一个key的时候,其使用频率增加,这点题目说的不是太清楚,
2. 注意边界
public class LFUCache {
private int capacity;
private Map<Integer, Integer> keyAndValueMap;
private Map<Integer, Integer> keyAndFrequenceMap;
private Map<Integer, LinkedHashSet<Integer>> frequenceAndKeyMap;
private Integer smallestFrequenceValue = 1;
public LFUCache(int capacity) {
if (capacity <= 0) {
capacity = 0;
}
this.capacity = capacity;
keyAndValueMap = new HashMap<>(capacity);
keyAndFrequenceMap = new HashMap<>(capacity);
frequenceAndKeyMap = new LinkedHashMap<>();
}
public int get(int key) {
if (capacity <=0 || keyAndValueMap.get(key) == null ) {
return -1;
}
updateFrquence(key);
// System.out.println("keyAndValueMap : " + keyAndValueMap);
// System.out.println("keyAndFrequenceMap : " + keyAndFrequenceMap);
// System.out.println( "frequenceAndKeyMap" + frequenceAndKeyMap);
// System.out.println( "----------------------");
return keyAndValueMap.get(key);
}
public void put(int key, int value) {
if (capacity <= 0) {
return;
}
if (keyAndValueMap.size() == capacity && !keyAndValueMap.keySet().contains(key)) {
removeOldestValue();
}
if (keyAndValueMap.keySet().contains(key)) {
updateFrquence(key);
keyAndValueMap.put(key, value);
} else {
keyAndValueMap.put(key, value);
keyAndFrequenceMap.put(key, 0);
smallestFrequenceValue = 0;
LinkedHashSet<Integer> keySet = frequenceAndKeyMap.get(0);
if (keySet == null) {
keySet = new LinkedHashSet<>();
}
keySet.add(key);
frequenceAndKeyMap.put(0, keySet);
// System.out.println("keyAndValueMap : " + keyAndValueMap);
// System.out.println("keyAndFrequenceMap : " + keyAndFrequenceMap);
// System.out.println("frequenceAndKeyMap" + frequenceAndKeyMap);
// System.out.println("----------------------");
}
}
private void updateFrquence(int key) {
Integer currenceFrequence = keyAndFrequenceMap.get(key);
if (currenceFrequence == null) {
return;
}
if (frequenceAndKeyMap.get(currenceFrequence) == null) {
LinkedHashSet keySet = new LinkedHashSet();
keySet.add(key);
frequenceAndKeyMap.put(++currenceFrequence, keySet);
} else {
LinkedHashSet keySet = frequenceAndKeyMap.get(currenceFrequence);
keySet.remove(key);
if (currenceFrequence == smallestFrequenceValue.intValue() && keySet.size() == 0) {
smallestFrequenceValue++;
}
keySet = frequenceAndKeyMap.get(++currenceFrequence);
if (keySet == null) {
keySet = new LinkedHashSet();
}
keySet.add(key);
frequenceAndKeyMap.put(currenceFrequence, keySet);
}
keyAndFrequenceMap.put(key, currenceFrequence);
}
private void removeOldestValue() {
LinkedHashSet keySet = frequenceAndKeyMap.get(smallestFrequenceValue);
Iterator<Integer> it = keySet.iterator();
int oldestKey = it.next();
it.remove();
if (keySet.size() == 0) {
for (Map.Entry<Integer, LinkedHashSet<Integer>> entry : frequenceAndKeyMap.entrySet()) {
if(entry.getValue() != null && entry.getValue().size() > 0) {
smallestFrequenceValue = entry.getKey();
break;
}
}
}
keyAndValueMap.remove(oldestKey);
keyAndFrequenceMap.remove(oldestKey);
}
}
测试test
public class LFUCacheTest {
@Test
/*
* [[2],[1,1],[2,2],[1],[3,3],[2],[3],[4,4],[1],[3],[4]]
*/
public void test() throws Exception {
LFUCache cache = new LFUCache(10);
String value = "10,13],[3,17],[6,11],[10,5],[9,10],[13],[2,19],[2],[3],[5,25],[8],[9,22],[5,5],[1,30],[11],[9,12],[7],[5],[8]," +
"[9],[4,30],[9,3],[9],[10],[10],[6,14],[3,1],[3],[10,11],[8]," +
"[2,14],[1],[5],[4],[11,4],[12,24],[5,18],[13],[7,23],[8],[12]," +
"[3,27],[2,12],[5],[2,9],[13,4],[8,18],[1,7],[6],[9,29],[8,21]," +
"[5],[6,30],[1,12],[10],[4,15],[7,22],[11,26],[8,17],[9,29],[5]," +
"[3,4],[11,30],[12],[4,29],[3],[9],[6],[3,4],[1],[10],[3,29],[10,28]," +
"[1,20],[11,13],[3],[3,12],[3,8],[10,9],[3,26],[8],[7],[5],[13,17],[2,27]," +
"[11,15],[12],[9,19],[2,15],[3,16],[1],[12,17],[9,1],[6,19],[4],[5],[5],[8,1]," +
"[11,7],[5,2],[9,28],[1],[2,2],[7,4],[4,22],[7,24],[9,26],[13,28],[11,26" ;
String[] temp = value.split("],\\u005B");
for (int i =0 ;i< temp.length;i++) {
if(temp[i].split(",").length > 1) {
int key = Integer.parseInt(temp[i].split(",")[0]);
int val = Integer.parseInt(temp[i].split(",")[1]);
cache.put(key, val);
} else {
System.out.println(cache.get(Integer.parseInt(temp[i])));
// System.out.println("");
}
}
}
}
五、Redis、Memcached、Guava、Ehcache中的算法
缓存那些事,一是内存爆了要用LRU(最近最少使用)、LFU(最少访问次数)、FIFO的算法清理一些;二是设置了超时时间的键过期便要删除,用主动或惰性的方法。
- LRU 简单粗暴的Redis
今天看 Redis3.0的发行通告里说,LRU算法大幅提升了,就翻开源码来八卦一下,结果哭笑不得,这旧版的”近似LRU”算法,实在太简单,太偷懒,太Redis了。
在 Github的Redis项目里搜索lru,找到代码在redis.c的freeMemoryIfNeeded()函数里。
先看 2.6版的代码: 竟然就是随机找三条记录出来,比较哪条空闲时间最长就删哪条,然后再随机三条出来,一直删到内存足够放下新记录为止…….可怜我看 配置文档后的想象,一直以为它会帮我在整个Redis里找空闲时间最长的,哪想到我有一百万条记录的时候,它随便找三条就开始删了。
好,收拾心情再看 3.0版的改进:现在每次随机五条记录出来,插入到一个长度为十六的按空闲时间排序的队列里,然后把排头的那条删掉,然后再找五条出来,继续尝试插入队列………嗯,好了一点点吧,起码每次随机多了两条,起码不只在一次随机的五条里面找最久那条,会连同之前的一起做比较……
中规中矩的Memcached
相比之下,Memcached实现的是再标准不过的LRU算法,专门使用了一个教科书式的双向链表来存储slab内的LRU关系,代码在 item.c里,详见 memcached源码分析—–LRU队列与item结构体,元素插入时把自己放到列头,删除时把自己的前后两个元素对接起来,更新时先做删除再做插入。
分配内存超限时,很自然就会从LRU的队尾开始清理。
同样中规中矩的Guava Cache
Guava Cache同样做了一个双向的Queue,见 LocalCache中的AccessQueue类,也会在超限时从Queue的队尾清理,见 evictEntries()函数。
和Redis旧版一样的Ehcache/Hazelcast
看 文档,居然和Redis2.6一样,直接随机8条记录,找出最旧那条,刷到磁盘里,再看代码, Eviction类和 OnHeapStore的evict()函数。
再看Hazelcast,几乎一样,随机取25条。 这种算法,切换到LFU也非常简单。
小结
不过后来再想想,也许Redis本来就不是主打做Cache的,这种内存爆了需要通过LRU删掉一些元素不是它的主要功能,默认设置都是noeviction——内存不够直接报错的,所以就懒得建个双向链表,而且每次访问时都要更新它了,看Google Group里长长的讨论,新版算法也是社区智慧的结晶。何况,Ehcache和Hazelcast也是和它的旧版一样的算法,Redis的新版还比这两者强了。
后来,根据@刘少壮同学的提示,JBoss的InfiniSpan里还实现了比LRU更高级的 LIRS算法,可以避免一些冷数据因为某个原因被大量访问后,把热数据挤占掉。
- 过期键删除
如果能为每一个设置了过期的元素启动一个Timer,一到时间就触发把它删掉,那无疑是能最快删除过期键最省空间的,在Java里用一条 DeplayQueue存着,开条线程不断的读取就能做到。但因为该线程消耗CPU较多,在内存不紧张时有点浪费,似乎大家都不用这个方法。
所以有了惰性检查,就是每次元素被访问时,才去检查它是否已经超时了,这个各家都一样。但如果那个元素后来都没再被访问呢,会永远占着位子吗?所以各家都再提供了一个定期主动删除的方式。
Redis
代码在 redis.c的activeExpireCycle()里,看过文档的人都知道,它会在主线程里,每100毫秒执行一次,每次随机抽20条Key检查,如果有1/4的键过期了,证明此时过期的键可能比较多,就不等100毫秒,立刻开始下一轮的检查。不过为免把CPU时间都占了,又限定每轮的总执行时间不超过1毫秒。
Memcached
Memcached里有个文不对题的 LRU爬虫线程,利用了之前那条LRU的队列,可以设置多久跑一次(默认也是100毫秒),沿着列尾一直检查过去,每次检查LRU队列中的N条数据。虽然每条Key设置的过期时间可能不一样,但怎么说命中率也比Redis的随机选择N条数据好一点,但它没有Redis那种过期的多了立马展开下一轮检查的功能,所以每秒最多只能检查10N条数据,需要自己自己权衡N的设置。
Guava Cache
在Guava Cache里,同一个Cache里所有元素的过期时间是一样的,所以它比Memached更方便,顺着之前那条LRU的Queue检查超时,不限定个数,直到不超时为止。而且它这个检查的调用时机并不是100毫秒什么的,而是每次各种写入数据时的 preWriteCleanup()方法中都会调用。
吐槽一句,Guava的Localcache类里面已经4872行了,一点都不轻量了。
Ehcache
Ehcache更乱,首先它的内存存储中只有惰性检查,没有主动检查过期的,只会在内存超限时不断用近似LRU算法(见上)把内存中的元素刷到磁盘中,在文件存储中才有超时检查的线程, FAQ里专门解释了原因。
然后磁盘存储那有一条8小时左右跑一次的线程,每次遍历所有元素…..见 DiskStorageFactory里的DiskExpiryTask。 一圈看下来,Ehcache的实现最弱。
该部分(第五节)转载自:https://my.oschina.net/ffy/blog/501003