@[toc]
背景
现在只要我们一提到缓存
,当下的环境,肯定很多人的脑子里立马会蹦出来Redis
,俨然已经成为行业标杆了,但是中小型项目
中对于简单
的缓存需求,还用Redis
的话,感觉杀鸡用牛刀了
,这时我们可以选择常用的轻量级临时缓存,例如:使用最多的应该就是HashMap
或者ConcurrentHashMap
了,但是呢,它没有设置最大缓存数量
的设置,以及过期淘汰的策略
,如果一个不小心,很容易造成系统的OOM
,So,来跟着laker
手撸一个小型的缓存组件吧(麻雀虽小五脏俱全,堪称缓存界瑞士军刀)。
首先先设计下缓存的核心接口。
设计缓存核心接口
这个应该很简单,一个缓存组件需要提供什么功能呢?(先思考2分钟)
无非是:
put(key,value,timeout);
将对象加入到缓存,timeout:
失效时长get(key);
从缓存中获得对象remove(key);
从缓存中移除对象
这3个方法基本上就够用了,其他的特性可自行扩展
我们再加上一些泛型支持,接口代码如下:
/**
* 缓存接口
*
* @param <K> 键类型
* @param <V> 值类型
*/
public interface Cache<K, V> {
/**
* 将对象加入到缓存
*
* @param key 键
* @param value 对象
* @param timeout 过期时间
*/
void put(K key, V value, long timeout);
/**
* 从缓存中获得对象
*
* @param key 键
* @return 键对应的对象
*/
V get(K key);
/**
* 从缓存中删除对象
*
* @param key 键
*/
void remove(K key);
}
紧接着我们继续来实现缓存的大致龙骨
。
缓存框架实现
1. 设置最大缓存数量
需要一个属性来存储这个容量值。
// 容量大小
private int capacity;
2. 选择合适容器来放置缓存对象
我们还用Map
类型存储,新增一个Map
类型属性
// 存储缓存对象
private Map<K, V> cacheMap;
3. 选择合适淘汰策略
这里我选用实现简单的LFU
(least frequently used) 最少使用率策略,来说明一下大致原理,其他实现类似的。
LFU:根据使用次数来判定对象是否被持续缓存(使用率是通过访问次数计算),当缓存满时清理过期对象,清理后依旧满的情况下清除最少访问(访问计数最小)的对象并将其他对象的访问数减去这个最小访问数,以便新对象进入后可以公平计数。
这里要做到LFU
,需要以下属性:
- value 缓存对象
latestTime
最新访问时间 ,用于判断对象是否过期,惰性删除的时候使用。count
访问次数 ,用于LFU
判断。ttl
对象存活时长,用于判断对象是否过期,惰性删除的时候使用。
代码如下:
public class CacheObject<V> {
/**
* 缓存对象
*/
V value;
/**
* 最新访问时间
*/
long latestTime;
/**
* 访问次数
*/
int count;
/**
* 对象存活时长,0表示永久存活
*/
private final long ttl;
/**
* 构造
*
* @param value 值
* @param ttl 超时时长
*/
protected CacheObject(V value, long ttl) {
this.value = value;
this.ttl = ttl;
this.latestTime = System.currentTimeMillis();
}
整体实现代码框架
我们整理下上面的思路,转化为代码龙骨
。
/**
* LFU 最少使用率策略
*
* @param <K>
* @param <V>
*/
public class LFUCache<K, V> implements ICache<K, V> {
// 容量大小
private int capacity;
// 存储缓存对象
private Map<K, CacheObject<V>> cacheMap;
public LFUCache(int capacity) {
this.capacity = capacity;
this.cacheMap = new HashMap<>(capacity, 1.0f);
}
@Override
public void put(K key, V value, long timeout) {
}
@Override
public V get(K key) {
return null;
}
@Override
public void remove(K key) {
}
}
有了这个龙骨
,那么下面我们就根据它来把空白的地方填满吧,继续实现丰满我们的组件吧!
丰满相关实现
1. 将对象加入到缓存put
操作
相关流程如下:
伪代码如下:
public void put(K key, V value, long timeout) {
if (缓存满了) {
删除所有的存活到期的对象();
if(缓存满了) {
删除一个访问次数最少的对象();
}
}
cacheMap.put(key, new CacheObject<>(value, timeout));
}
关键点
- 存活到期对象的判断
CacheObject.latestTime + CacheObject.ttl < System.currentTimeMillis()
- 删除访问次数最少的对象时,将其他对象的访问数减去这个最小访问数,否则老的数据越来越大,对新进入对象
不公平
,随着时间的增长,还有可能溢出
。
2. 从缓存中获得对象get
操作
相关流程如下:
大致代码如下:
public V get(K key) {
CacheObject<V> cacheObject = cacheMap.get(key);
if (cacheObject == null) {
return null;
}
cacheObject.latestTime = System.currentTimeMillis();
cacheObject.count++;
return cacheObject.value;
}
关键点
- 每次访问到的对象,要
更新最新访问时间
和访问次数
3. 从缓存中删除对象remove
操作
这里简单的调用Map
的删除接口即可
public void remove(K key) {
cacheMap.remove(key);
}
好了,写到这儿,我们的缓存组件就基本成型
了。
为什么说基本成型?
如果你真的是用心在看的话,估计会提问了卧槽,你这个压根线程非安全啊?
是的,那么我们来给每个方法加个synchronized
即可,jdk1.6之后synchronized
性能很强劲的。
整体代码如下
public class LFUCache<K, V> implements ICache<K, V> {
// 容量大小
private int capacity;
// 存储缓存对象
private Map<K, CacheObject<V>> cacheMap;
public LFUCache(int capacity) {
this.capacity = capacity;
this.cacheMap = new HashMap<>(capacity, 1.0f);
}
@Override
public synchronized void put(K key, V value, long timeout) {
if (isFull()) {
pruneCache();
}
cacheMap.put(key, new CacheObject<>(value, timeout));
}
@Override
public synchronized V get(K key) {
CacheObject<V> cacheObject = cacheMap.get(key);
if (cacheObject == null) {
return null;
}
cacheObject.latestTime = System.currentTimeMillis();
cacheObject.count++;
return cacheObject.value;
}
@Override
public synchronized void remove(K key) {
cacheMap.remove(key);
}
public boolean isFull() {
return (capacity > 0) && (cacheMap.size() >= capacity);
}
protected int pruneCache() {
int count = 0;
CacheObject<V> comin = null;
// 清理过期对象并找出访问最少的对象
Iterator<CacheObject<V>> values = cacheMap.values().iterator();
CacheObject<V> co;
while (values.hasNext()) {
co = values.next();
if (co.isExpired() == true) {
values.remove();
count++;
continue;
}
//找出访问最少的对象
if (comin == null || co.count < comin.count) {
comin = co;
}
}
// 减少所有对象访问量,并清除减少后为0的访问对象
if (isFull() && comin != null) {
long minAccessCount = comin.count;
values = cacheMap.values().iterator();
CacheObject<V> co1;
while (values.hasNext()) {
co1 = values.next();
co1.count -= minAccessCount;
if (co1.count <= 0) {
values.remove();
count++;
}
}
}
return count;
}
}
验证
测试代码如下:
//通过实例化对象创建
LFUCache<String, String> lfuCache = new LFUCache<String, String>(3);
lfuCache.put("key1", "value1", DateUnit.SECOND.getMillis() * 3);
lfuCache.get("key1");//使用次数+1
lfuCache.put("key2", "value2", DateUnit.SECOND.getMillis() * 3);
lfuCache.put("key3", "value3", DateUnit.SECOND.getMillis() * 3);
lfuCache.put("key4", "value4", DateUnit.SECOND.getMillis() * 3);
//由于缓存容量只有3,当加入第四个元素的时候,根据LRU规则,最少使用的将被移除(2,3被移除)
String value1 = lfuCache.get("key1");
System.out.println(value1);//key1
String value2 = lfuCache.get("key2");
System.out.println(value2);//null
String value3 = lfuCache.get("key3");
System.out.println(value3);//null
String value4 = lfuCache.get("key4");
System.out.println(value4);//key4
预期:
由于缓存容量只有
3
,当加入第四个元素的时候,根据LRU规则,最少使用的将被移除(2,3
被移除)
测试结果如下:
value1
null
null
value4
总结
此文只为抛砖引玉,后面还可以实现各种缓存策略
的方案,例如FIFO
、LRU
等,但是缓存的这个骨架、原理基本一致
,一通百通
。
参考: www.hutool.cn/
QQ群【837324215】 关注我的公众号【Java大厂面试官】,回复:架构、资源等关键词(更多关键词,关注后注意提示信息)获取更多免费资料。
公众号也会持续输出高质量文章,和大家共同进步。