目录
一、几种不同性质的Cache
1. LRUCache
LRUCache是一种基于最近最久未使用的淘汰机制实现的本地缓存。 LRU算法会在缓存满的时候会淘汰掉最近未使用的缓存以满足新的缓存进入,具体实现原理可参考:
图解LRU算法_Dream_it_possible!的博客-CSDN博客_lru算法图解
LRU算法是一种比较折中的淘汰策略,适用于热点的Key(即经常被访问到对象)做缓存。
2. LFUCache
LFUCache 是一种基于最近最少使用的淘汰机制实现的本地缓存。LFU算法会在缓存满的时候以访问次数为统计基准,每次清除时淘汰访问次数最少的缓存。
3. FIFOCache
FIFOCache是基于先进先出的淘汰策略实现的本地缓存。 FIFO 基于队列的思想,先进先出。
二、Cache设计
1. 缓存容器选择
Java中好用的本地缓存就属Map, 可能有很多开发没用过hashmap做缓存,其实在很多开源项目中,map做本地缓存的次数极多,比如Spring 的ioc容器,存放BeanDefinition 、BeanName等属性都是用的Map结构,Map在一般情况下读数据的时间复杂度o(1),效率极高,也是缓存的最佳选择之一。
protected Map<K, CacheObj<K, V>> cacheObjMap;
2. 缓存行为抽象
上面提到了缓存有很多种,且每种缓存都有不同的性质,为了适应不同的种类的缓存,我们可以将这些缓存的共性给抽出来,放在一个接口里, 共有的缓存行为放在抽象类里,这种思想也是Java语言提倡的,面向对象、接口编程,提高设计实现的可插拔性。
1) 接口设计
定义Cache 接口,主要包含缓存共有的属性方法,如 capacity()获取缓存容量、ttl() 获取缓存失效时间、get(K key)根据key获取缓存中的值、put(K key, V value)等,这些能抽象出来放在接口里。
package com.bing.sh.core.cache;
import com.bing.sh.func.Func0;
public interface Cache<K, V> {
int capacity();
long ttl();
default V get(K key) {
return get(key, true);
}
default V get(K key, Func0<V> supplier) {
return get(key, true, supplier);
}
V get(K key, boolean isUpdateLastAccess);
V get(K key, boolean isUpdateLastAccess, Func0<V> supplier);
void put(K key, V value);
void put(K key, V object, long timeout);
int prune();
boolean containsKey(K key);
long size();
void clear();
void invalidate(K key);
void invalidateAll();
}
2) 抽象类AbstractCache
定义AbstractCache 抽象类并实现Cache接口,提供get()方法和Put方法实现, 同时定义抽象方法pruneCache() 由子类去实现该方法。
我们会往缓存里不断的push元素,因此prune 清理缓存的操作是非常有必要的,pruneCache()方法的作用:
3) 抽象类ReentrantCache
ReentrantCache提供并发安全的读写操作,同时继承AbstractCache, 在实现我们自定义缓存时LRUCache、LFUCache、FIFOCache都需要继承ReentrantCache。
ReentrantCache采用ReentrantLock对cachemap的读写进行lock和unlock操作,ReentrantLock是可重入锁,对同步块可以重复加锁。
例如get()操作加锁读,防止在写的时候读到未修改的数据。
public V get(K key, boolean isUpdateLastAccess) {
lock.lock();
CacheObj<K, V> cacheObj;
try {
cacheObj = cacheObjMap.get(key);
} finally {
lock.lock();
}
if (cacheObj == null) {
return null;
}
if (!cacheObj.isExpired()) {
// 刷新访问时间
return cacheObj.get(isUpdateLastAccess);
}
// 清理掉过期key
remove(key, true);
return null;
}
3. 缓存对象设计
1) 缓存失效设计
缓存失效是一个缓存具有的基本特性,例如我们可以将用户的token 存入到 redis缓存里并设置失效时间,缓存失效后,token失效。
利用redis的ttl概念作为CacheObj的属性ttl,在CacheObj 初始化时设置LastAccess为当前时间戳。
判定该对象是失效的依据: 当前时间戳- 最后访问的时间戳> ttl 表示该缓存失效。
public boolean isExpired() {
if (this.ttl > 0) {
return System.currentTimeMillis() - this.ttl > this.lastAccess;
}
return false;
}
注: lastAccess存在并发安全的问题,因此需要保证可见性,在设计时应当加上volatile关键字。
2) 缓存续命设计
所谓缓存续命是指同一个对象在处于某个失效的期间时,我们重新访问了此对象,那么该对象的存活时间要重置,这就是缓存续命,比如我设置一个user对象放入缓存的失效时间为60min, 那么还有20分钟失效的时间时候,我重新从缓存里再次拿到同样的user对象,此时的user对象会在缓存里的存活时间会重新设置为60min。
缓存续命的经典应用场景就是登录,我们可以发现现在很多Web应用、移动端等平台,我们只需要登录一次后,可以维持很长一段时间,不用重新登录,如果我们一年内或很久时间经常使用这个应用,那么有可能一年里基本不需要再次登录。
具体实现: 我们只需要重载一个get()方法,如果有get()实现也可以不用重载,给刷新时间戳设置一个开关,默认是开启的,每次访问该对象时刷新时间戳。
public V get(boolean isUpdateLastAccess) {
if (isUpdateLastAccess) {
this.lastAccess = System.currentTimeMillis();
}
accessCount.incrementAndGet();
return this.value;
}
4. CacheObj 代码
在CacheObj的构造方法里初始化对象的key 和value, 建议使用泛型,在初始化时我们只需要定义Cache的key和value的类型即可。
package com.bing.sh.core.cache.obj;
import com.bing.sh.utils.DateUtil;
import java.io.Serializable;
import java.util.Date;
import java.util.concurrent.atomic.AtomicLong;
public class CacheObj<K, V> implements Serializable {
private static final Long serialVersionID = 123795312468L;
protected final K key;
protected final V value;
protected volatile Long lastAccess;
private AtomicLong accessCount = new AtomicLong(0);
protected long ttl;
public CacheObj(K key, V value, long ttl) {
this(key, value);
this.ttl = ttl;
}
public CacheObj(K key, V value) {
this.key = key;
this.value = value;
this.lastAccess = System.currentTimeMillis();
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
/**
* 更新最后访问时间,缓存续命
*
* @param isUpdateLastAccess
* @return
*/
public V get(boolean isUpdateLastAccess) {
if (isUpdateLastAccess) {
this.lastAccess = System.currentTimeMillis();
}
accessCount.incrementAndGet();
return this.value;
}
public AtomicLong getAccessCount() {
return accessCount;
}
public long getTtl() {
return ttl;
}
public Long getLastAccess() {
return lastAccess;
}
public Date getExpireTime() {
return this.ttl > 0L ? DateUtil.date(this.ttl) : null;
}
/**
* 判断是否失效
*/
public boolean isExpired() {
if (this.ttl > 0) {
return System.currentTimeMillis() - this.ttl > this.lastAccess;
}
return false;
}
}
3. 缓存满时触发清除策略原理
LRU 清除末尾节点
LRU算法我们可以通过LinkedHashMap 去帮我们完成清除策略,也可以通过双向链表来自定义读写操作,核心思想如下:
每次get 操作时,将key放入到双向链表的队头,put操作时检查链表的容量是否已满, 如果已经满了,那么移除掉tail节点。
图解LRU算法_Dream_it_possible!的博客-CSDN博客_lru算法图解
因此在pruneCache()方法里,只需要清除掉过期的key即可。
int pruneCache() {
// 1. clear expired cache
int count = 0;
Iterator<CacheObj<K, V>> values = cacheObjMap.values().iterator();
CacheObj<K, V> co;
while (values.hasNext()) {
co = values.next();
// 如果失效,那么就清除
if (co.isExpired()) {
values.remove();
logger.info("{} is expired! remove complete from cache", co.getKey());
count++;
}
}
return count;
}
LFU 清除访问次数最少节点
为了统计缓存的访问次数,我们在CacheObj类里添加里一个accessAcount属性,用于统计该缓存被get()多少次。
思路: 先在cacheMap里找到访问次数最少的一个cacheObj,同时清除掉失效的key; 根据最小的访问次数,再次根据最小访问量降维整体对象的访问量,如果还有访问量<=0的对象,一并可以清除掉。
@Override
int pruneCache() {
// 1. clear expired cache
int count = 0;
Iterator<CacheObj<K, V>> iterator = cacheObjMap.values().iterator();
CacheObj<K, V> co;
CacheObj<K, V> coMin = null;
while (iterator.hasNext()) {
co = iterator.next();
if (co.isExpired()) {
iterator.remove();
count++;
continue;
}
if (coMin == null || co.getAccessCount().get() < coMin.getAccessCount().get()) {
coMin = co;
}
}
// 2. clear at least access nums of cache, include itself.
if (isFull() && coMin != null) {
long minAccessCount = coMin.getAccessCount().get();
Iterator<CacheObj<K, V>> cacheObjIterator = cacheObjMap.values().iterator();
CacheObj<K, V> co1;
while (cacheObjIterator.hasNext()) {
co1 = cacheObjIterator.next();
if (co1.getAccessCount().addAndGet(-minAccessCount) <= 0) {
cacheObjIterator.remove();
count++;
}
}
}
return count;
}
FIFO 清除第一个进队列节点
FIFOCache在设计时的结构同样可以使用LinkedHashMap,底层是一个双向链表。在pruneCache时删除掉队列中的第一个元素即可。
@Override
int pruneCache() {
Iterator<CacheObj<K, V>> iterator = cacheObjMap.values().iterator();
int count = 0;
CacheObj<K, V> co;
CacheObj<K, V> first = null;
while (iterator.hasNext()) {
co = iterator.next();
if (co == null) {
continue;
}
if (co.isExpired()) {
iterator.remove();
count++;
}
if (first == null) {
first = co;
}
}
// clear first element in the cache.
if (isFull() && null != first) {
cacheObjMap.remove(first.getKey());
count++;
}
return count;
}
4. 通过降低锁粒度提升并发能力
使用keyLockMap降低锁粒度
CacheMap在高并发情况下会出现线程安全的问题,因此有效加锁是提高性能和解决并发安全问题的手段。
在get()时,map里没有key对应的value值时,会回调supplier提供的方法,然后将得到值写入到map里,因此在写之前需要再次加锁,我们可以采用对单个key进行加锁,此场景下可以避免对全局map加锁导致性能丢失。
final Lock keyLock = keyLockMap.computeIfAbsent(key, k -> new ReentrantLock());
@Override
public V get(K key, boolean isUpdateLastAccess, Func0<V> supplier) {
V v = get(key, isUpdateLastAccess);
if (null == v && null != supplier) {
//每个key单独获取一把锁,降低锁的粒度提高并发能力,see pr#1385@Github
final Lock keyLock = keyLockMap.computeIfAbsent(key, k -> new ReentrantLock());
keyLock.lock();
try {
// 双重检查锁,防止在竞争锁的过程中已经有其它线程写入
final CacheObj<K, V> co = cacheMap.get(key);
if (null == co || co.isExpired()) {
try {
v = supplier.call();
} catch (Exception e) {
throw new RuntimeException(e);
}
put(key, v, this.timeout);
} else {
v = co.get(isUpdateLastAccess);
}
} finally {
keyLock.unlock();
keyLockMap.remove(key);
}
}
return v;
}
三、读多写少的场景采用乐观锁StampedLock
StampedLock是JDK 1.8里JUC包提供的一个乐观锁。适用于读多写少的场景,用stampedLock读时,默认是没有写锁,即先读再检查修改,如果有修改那么就转为悲观读,因此StampedLock能有效的解决大量写阻塞读的情况。
protected final StampedLock lock = new StampedLock();
默认乐观读,有线程修改了才转为悲观读。
@Override
public V get(K key, boolean isUpdateLastAccess) {
// 尝试读取缓存,使用乐观读锁
long stamp = lock.tryOptimisticRead();
CacheObj<K, V> co = cacheMap.get(key);
if(false == lock.validate(stamp)){
// 有写线程修改了此对象,悲观读
stamp = lock.readLock();
try {
co = cacheMap.get(key);
} finally {
lock.unlockRead(stamp);
}
}
// 未命中
if (null == co) {
missCount.increment();
return null;
} else if (false == co.isExpired()) {
hitCount.increment();
return co.get(isUpdateLastAccess);
}
// 过期,既不算命中也不算非命中
remove(key, true);
return null;
}
四、函数式编程在缓存中的应用
函数式编程简介
有些场景下我们希望从缓存中根据key获取某个value时,即使获取到value为null, 也希望能有一个默认的值做为返回,我们不希望得到一个null, 还有null 在java中很不友好,容易产生NPE异常等问题。
Map中提供了一个computeIfAbsent()方法,看看此方法的源码,如果get(key)获取到的值为null时,那么会调用maapingFuction接口里的apply()方法,产生一个newValue, 然后将newValue作为key的value存放到map里。
default V computeIfAbsent(K key,
Function<? super K, ? extends V> mappingFunction) {
Objects.requireNonNull(mappingFunction);
V v;
if ((v = get(key)) == null) {
V newValue;
if ((newValue = mappingFunction.apply(key)) != null) {
put(key, newValue);
return newValue;
}
}
return v;
}
看一个test:
@Test
public void testFunction() {
Map<String, Integer> map = new HashMap<>();
map.put("1", 1);
Integer value = map.computeIfAbsent("2", new Function<String, Integer>() {
@Override
public Integer apply(String s) {
return 0;
}
});
logger.info("key 2, get value {}", value);
}
执行结果为: 0, 调用了function 里的apply()方法:
也可以直接使用lambda表达式: s->0
@Test
public void testFunction() {
Map<String, Integer> map = new HashMap<>();
map.put("1", 1);
Integer value = map.computeIfAbsent("2", s -> 0);
logger.info("key 2, get value {}", value);
}
Funciton接口
从JDK1.8开始,Java提供了一个@FunctionalInterface注解标记的接口Function, @FunctionalInterface注解只允许一个接口里只能包含一个抽象方法,如果有多个的话那么会报错,一般用此方法来做回调。
该接口可以作为方法的参数,在自定义的apply()方法里获取到方法的返回结果。
五、利用工厂模式根据缓存Clazz创建缓存实例
package com.bing.sh.core.cache.factory;
import com.bing.sh.core.cache.AbstractCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
public class CacheFactory {
private CacheFactory() {
}
private static Logger logger = LoggerFactory.getLogger(CacheFactory.class);
private static final String FAIL_MSG = "cache init failed : {}";
public static AbstractCache<Object, Object> getCache(Class<? extends AbstractCache<Object, Object>> clazz) {
try {
return clazz.getDeclaredConstructor().newInstance();
} catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) {
logger.error(FAIL_MSG, e.getMessage());
}
return null;
}
public static AbstractCache<Object, Object> getCache(Class<? extends AbstractCache<Object, Object>> clazz, int capacity) {
try {
return clazz.getDeclaredConstructor(Integer.class).newInstance(capacity);
} catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) {
logger.error(FAIL_MSG, e.getMessage());
}
return null;
}
public static AbstractCache<Object, Object> getCache(Class<? extends AbstractCache<Object, Object>> clazz, int capacity, long timeout) {
try {
return clazz.getDeclaredConstructor(Integer.class, Long.class).newInstance(capacity, timeout);
} catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) {
logger.error(FAIL_MSG, e.getMessage());
}
return null;
}
}
六、采用建造者模式创造不同性质的Cache
使用建造者模式的好处,就是我们只需要使用CacheBuilder 去设置cache的属性,例如使用如下的格式,我们就能创建一个LRUCache<Object, Object>的 缓存,如果创建LFUCache的缓存,那么可以在newCache时传LFUCache.class。
LRUCache<Object, Object> lruCache = CacheBuilder
.newCacheBuilder()
.newCache(LRUCache.class)
.initTimeout(1)
.build();
使用ThreadLocal存放builder和CacheProp,保证每个线程的CacheProp是不同的。
package com.bing.sh.core.cache.builder;
import com.bing.sh.core.cache.AbstractCache;
import com.bing.sh.core.cache.factory.CacheFactory;
import org.springframework.util.Assert;
/**
* 采用建造者模式
* CacheBuilder.newCache(LRUCache.class).setCapacity(16).setTimeOut(60L).build()
*/
public class CacheBuilder {
private static ThreadLocal<Object> propThreadLocal = new ThreadLocal<>();
private static ThreadLocal<CacheBuilder> cacheBuilderThreadLocal = new ThreadLocal<>();
private static final String INIT_TIP = "pls init cache first";
public static CacheBuilder newCacheBuilder() {
cacheBuilderThreadLocal.set(new CacheBuilder());
return cacheBuilderThreadLocal.get();
}
public CacheBuilder newCache(Class<? extends AbstractCache> cache) {
if (propThreadLocal.get() == null) {
CacheProp<Object, Object> cacheProp = new CacheProp<>((Class<? extends AbstractCache<Object, Object>>) cache, 0);
propThreadLocal.set(cacheProp);
}
if (cacheBuilderThreadLocal.get() == null) {
cacheBuilderThreadLocal.set(new CacheBuilder());
}
return cacheBuilderThreadLocal.get();
}
public CacheBuilder newCache(Class<? extends AbstractCache> clazz, int capacity, long timeout) {
if (propThreadLocal.get() == null) {
CacheProp<Object, Object> cacheProp = new CacheProp<>((Class<? extends AbstractCache<Object, Object>>) clazz, capacity, timeout);
propThreadLocal.set(cacheProp);
}
if (cacheBuilderThreadLocal.get() == null) {
cacheBuilderThreadLocal.set(new CacheBuilder());
}
return cacheBuilderThreadLocal.get();
}
public CacheBuilder initCapacity(int capacity) {
Object object = propThreadLocal.get();
Assert.notNull(object, INIT_TIP);
CacheProp<Object, Object> cacheProp = (CacheProp) object;
cacheProp.setCapacity(capacity);
propThreadLocal.set(cacheProp);
return cacheBuilderThreadLocal.get();
}
public CacheBuilder initTimeout(long timeout) {
Object object = propThreadLocal.get();
Assert.notNull(object, INIT_TIP);
CacheProp<Object, Object> cacheProp = (CacheProp) object;
cacheProp.setTimeout(timeout);
propThreadLocal.set(cacheProp);
return cacheBuilderThreadLocal.get();
}
public <T, V> T build() {
Object object = propThreadLocal.get();
CacheProp<Object, Object> cacheProp = (CacheProp) object;
Assert.notNull(cacheProp, INIT_TIP);
AbstractCache<T, V> cache = (AbstractCache<T, V>) CacheFactory.getCache(cacheProp.getCacheClazz(), cacheProp.getCapacity(), cacheProp.getTimeout());
cacheBuilderThreadLocal.remove();
propThreadLocal.remove();
return (T) cache;
}
}
七、Cache 测试
设置100万组线程,往LRUCache写入数据,观察打印结果:
@Test
public void testThreadLRUCache() {
int threadSize = 1000000;
ThreadPoolExecutor threadPoolExecutor = ThreadPoolUtil.getThreadPool();
LRUCache<Integer, Integer> lruCache = CacheBuilder.newCacheBuilder().newCache(LRUCache.class).build();
long start = System.currentTimeMillis();
for (int i = 0; i < threadSize; i++) {
final int index = i;
threadPoolExecutor.execute(() -> {
lruCache.put(index, index);
});
long size = lruCache.size();
int capacity = lruCache.capacity();
if (size > capacity) {
logger.info("{} cache is full", index);
}
}
logger.info("it takes {} millis", System.currentTimeMillis() - start);
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
logger.error(e.getMessage());
}
}
用同样的方法测试LFUCache和FIFOCache,观察结果。
Hutool参考文档
google guava