写的一个例子来谈谈缓存的写法以及淘汰策略。缓存的用途场景会比较多,这里可以结合计算机硬件分层的思想从寄存器-->Cache(L1,L2,L3)-->主存-->磁盘就可以知道缓存的用途。一般web应用最基本的场景是就是存数据和取数据,比如存储介质是数据库,数据库不完全都是磁盘IO,数据库本身也会在内存中开辟一块空间用来存一些热点数据。总归一句话:内存缓存就是为了利用内存本身的特性来加快访问数据的速度。然而内存有一个非常致命的地方就是容量小,不能把所有的东西都放到内存中。这样就有了缓存策略一说,归根结底是为了提高内存的利用率(命中率)。围绕内存有限的这么一个基本事实,要做的如果提高内存利用率,一般内存缓存都会有淘汰策略。
一般有两种淘汰策略:
一.访问末位淘汰
这个策略是基于这么一个假设:越是被访问的数据我们有理由相信它下次被访问的概率更大。
二.时间淘汰
时间淘汰就是在固定的时间内对数据进行清理。这也是为了提高内存利用率,因为末位淘汰策略是只有新的数据进来的时候才会淘汰掉老的数据,而有时候内存中其实有大部分的数据都是无用数据,适时的清理也是可以提高整个内存的利用率。
这里再讲两种应用场景,然后再分析各应用场景下所对应的策略。
一.数据平均场景
这种场景说明整个内存的访问不会非常集中在某一块,像这种场景下缓存的命中率会比较低,其实不太好发挥内存缓存的作用。这个更好理解,假如总共100万数据,缓存只能容纳1万数据,这样如果100万数据都会被平均访问的话每次数据被命中的概率只有1/100。这种场景用时间淘汰策略可能会更好一些。因为末位淘汰的假设在这里已经不成立了。
二.热点数据场景
这样的场景其实非常普遍,而这种场景也是最能发挥内存缓存的效能。一般这种场景只要淘汰策略好的话,可以很好的提高整体应用的性能。
总结一下,这里两种淘汰策略基本上会进行混合搭配使用,好了,这里结合我具体写的一个例子来讨论。
这里先简单描述我的整体思想:
第一:用普通的HashMap作为容器
第二:用时间淘汰策略,并支持热点数据前置。
第三:"分桶"的思想,前面两点都好理解,估计这个会有些人提出疑问,我这里详细说一下
a).所谓分桶的思想其实就是一个大容器中会切分成多个小容器。然后用运用一定的策略进行分发到不同的"桶"中,其实这里应用了分布式缓存的一些特性,其核心思想旨在让每个小桶能够独立运作,互不干扰。下面讲代码的时候我会具体说到为什么要进行分桶,到底在解决什么问题,还会讲分桶的一些弊端
b).桶的分配策略我用了最简单的取模运算。
开始上代码了,
/**
* @author sanxing 2012-11-3 下午10:33:30 本缓存旨在解决数据在内存区块缓存策略
*
* <p>
* 利用HashMap作为最基本容器进行存储
* <p>
* <p>
* 采用时间淘汰策略,并支持热点数据前置(更新其时间)。
* </p>
* <p>
* "分桶"的思想
* <i>
* 所谓分桶的思想其实就是一个大容器中会切分成多个小容器。然后用运用一定的策略进行分发到不同的
* "桶"中,其实这里应用了分布式缓存的一些特性,其核心思想旨在让每个小桶能够独立运作
* ,互不干扰。这样当桶清理(@see remove())的时候会保证达到影响最小。
* </i>
* </p>
*
* <p>
* 本缓存策略是建立在数据均匀分配的一个假设上,也就是说海量的数据会平均分配到各个桶上,这样才能保证每个桶的利用率达到最高,性能最好。
* 如果出现集中式缓存也就是数据集约到其中几个桶上,而其他桶的拿不到数据,不能使用此缓存或者需要改变数据的分发策略。
* </p>
*
*/
public class LRUCache {
/**
* 缓存过期时间
*/
private long aliveTime;
/**
* 桶的最大容量
*/
private int maxSize;
/**
* 每个小桶的容量
*/
private int everyPoolSize;
/**
* 命中次数
*/
private int hit;
/**
* 丢失次数
*/
private int missHit;
private Map<Integer, Map<String, CacheObject>> cacheMap;
/**
* 小桶的个数
*/
private int mod = 32;
public int getHit() {
return hit;
}
public int getMissHit() {
return missHit;
}
public void setMod(int mod) {
this.mod = mod;
}
public int getMod() {
return mod;
}
public int getEveryPoolSize() {
return everyPoolSize;
}
public LRUCache(int maxSize, long aliveTime) {
this.maxSize = maxSize;
this.aliveTime = aliveTime;
cacheMap = new HashMap<Integer, Map<String, CacheObject>>();
for (int i = 0; i < mod; i++) {
Map<String, CacheObject> sonMap = new HashMap<String, CacheObject>();
cacheMap.put(i, sonMap);
}
/**
* 这里并不是准确的,因为当不是mod的倍数的时候会多出<mod的个数,举一个简单的例子:假如maxSize=33,mod=32,那么
* 每个小桶的poosize=2,这样加起来是64,所以并不精准。 <br />
* 如果需要准确的可以寻求其他策略
*/
this.everyPoolSize = (this.maxSize + mod - 1) / mod;
}
public void put(String key, Object val) {
// 如果小于最大数量
int keyMod = getKeyMod(key);
Map<String, CacheObject> sonMap = null;
synchronized (sonMap = cacheMap.get(keyMod)) {
if (sonMap.size() >= everyPoolSize) {
/**
* 如果发现桶已经满了时候,触发remove对整个桶进行清理,有木有很像JVM中的FGC啊
* 其实基本思想就是这样,但是由于HashMap并非线程安全的,所以必须对桶进行加锁
* 而本身清理是有一定耗时的,这样势必降低缓存的访问。所以必须降低remove事件的
* 发生,也就像JVM需要减少FGC一样。这里会有许多策略。
* 而分桶的作用就出现了,这样触发remove事件,只需要锁住小桶,而其他小桶并不会
* 因此受到影响仍然可以访问。这里也是遵循锁运用基本原则:只在需要的地方加锁。
*/
remove(keyMod);
}
if (sonMap.size() < everyPoolSize) {
sonMap.put(key,
new CacheObject(val, System.currentTimeMillis()));
}
}
}
/**
* 最简单的取模数,进行桶的分发策略
*
* @param key
* @return
*/
private int getKeyMod(String key) {
return (key.hashCode() & Integer.MAX_VALUE) % mod;
}
/**
* 负责对桶进行清理工作,把所有已经过期的数据全部移除出缓存区,这项工作还是比较耗时的。
*
* @param keyMod
*/
public void remove(int keyMod) {
synchronized (cacheMap.get(keyMod)) {
Map<String, CacheObject> oldMap = cacheMap.get(keyMod);
Map<String, CacheObject> newMap = new HashMap<String, CacheObject>();
for (Map.Entry<String, CacheObject> entry : oldMap.entrySet()) {
if ((System.currentTimeMillis() - entry.getValue().getTime()) < aliveTime) {
newMap.put(entry.getKey(), entry.getValue());
}
}
oldMap = null;
cacheMap.put(keyMod, newMap);
}
}
/**
* 拿到缓存区的数据,这里的策略会先判断数据是否过期,过期则移除
*
* @param key
* @return
*/
public Object get(String key) {
int keyMod = getKeyMod(key);
Map<String, CacheObject> map = null;
synchronized (map = cacheMap.get(keyMod)) {
CacheObject co = map.get(key);
if (co != null) {
if ((System.currentTimeMillis() - co.getTime()) > aliveTime) {
// 过期则移除,这里类似于懒加载,和JVM里的YGC有点类似
map.remove(key);
missHit++;
} else {
// 没有过期
hit++;
/**
* 热点数据策略,当数据被访问时更新它的时间以延长它在内存中驻留的时间。 保证下次能够顺利命中。
*/
co.setTime(System.currentTimeMillis());
return co.getObj();
}
}
}
return null;
}
/**
* 获取整个数据区的数据容量
*
* @return
*/
public int size() {
int allSize = 0;
// 这里不加锁,不保证得到的size一定准确
for (int i = 0; i < mod; i++) {
allSize += cacheMap.get(i).size();
}
return allSize;
}
/**
* 拿到每个桶的容量。
*
* @param mod
* @return
*/
public int poolSize(int mod) {
if (mod < 0 || mod > this.mod) {
throw new IllegalArgumentException("mod 参数不在桶的区域内,mod=" + mod);
}
return cacheMap.get(mod).size();
}
/**
* 缓存数据结构
*
* @author sanxing 2012-11-4 下午01:48:47
*
*/
private class CacheObject {
private Object obj;
private long time;
public CacheObject(Object obj, long time) {
this.obj = obj;
this.time = time;
}
public Object getObj() {
return obj;
}
public void setTime(long time) {
this.time = time;
}
public long getTime() {
return time;
}
}
}
上面就是整个代码以及一些注释,最难的是其实是平均的把数据分发到每个桶上,也就是分发策略,保证缓存的最大利用率,这个需要实际测试来进行测试。我在这里作了最简单的取模运算。从统计学来讲,当数据是海量无序的时候应该基本符合平均分配的原则。
整个策略及思想就完了。经过一段时间的思考,发现整个计算机系统乃致其他非计算机场景都会遵循类似的策略。这似乎也是思考的魅力所在,能把整个计算机整合起来思考并进行发散是非常有趣的,有时候甚至有茅塞顿开的感觉。