- 背景 边学变笔记,本文不会针对某个缓存技术进行深入的解密,仅仅是一个简要解析,后续文章会针对某个缓存技术进行深入解密。
缓存的使用历程:
无缓存
应用的初步阶段,一般都是没有缓存,一般开发过程中,第一步一般都没有使用缓存的,而是直接查库。
在DB性能完全没有任何的负担的时候,无须考虑缓存。因为维护缓存也会带来额外的负担,增加编程难度,提高缓存的维护难度。
在流量不大的时候,查数据库或者读取文件是最为方便,也能完全满足我们的业务要求
进程内部缓存
无论是因为流量原因,还是别的原因,当我们需要提供缓存功能的时候,一般我们都会有这几种选择供我们选择:
- java中自带的HashMap或者ConcurrentHashMap
- LRUHashMap
- Guava cache
- Caffeine
HashMap
下面就是使用HashMap作为一个的样例;
这种做法就有个问题HashMap无法进行数据淘汰,内存会无限制的增长。
但并不是说HashMap不能作为缓存,我们一般可以使用HashMap作为缓存一些静态变量(不需要淘汰的数据),比如在一些反射框架中可以缓存一些类的属性信息:Method,field。毕竟每次都用反射那也是比较损耗性能。
public class HashMapCache {
private static HashMap<String, String> hashMap = new HashMap<>();
private static UserService service = new UserService();
public static String getUser(String name) {
String user = hashMap.get(name);
if (user == null) {
user = service.get(name);
hashMap.put(name, user);
}
return user;
}
static class UserService{
/**
* 模拟DB
*/
String get(String name) {
System.out.println("load user from db");
return "name : " + name;
}
}
public static void main(String[] args) {
getUser("aaa");
//此处从缓存中获取数据
getUser("aaa");
}
}
HashMap升级 LRUHashMap
既然不能让缓存无限制的增长,所以就必须存在缓存减少:缓存淘汰。
那么问题来了:如何淘汰缓存,随机淘汰?列举下常见的三种FIFO,LRU,LFU(还有一些ARC,MRU感兴趣的可以自行搜索):这三种,实现成本是一个比一个高,同样的命中率也是一个比一个好。
FIFO :先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。
LRU:最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在1个小时的前59分钟访问了1万次(可见这是个热点数据),再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
LFU:最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了LRU不能处理时间段的问题。
下面是一个简易LRU的实现Demo:
public class LRUHashMap extends LinkedHashMap {
private final int max;
private Object lock;
public LRUHashMap(int max, Object lock) {
this.max = max;
this.lock = lock;
}
/**
* 重写LinkedHashMap的removeEldestEntry方法即可,在Put的时候判断,如果为true,就会删除最老的
*
* @param eldest
* @return
*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > max;
}
public Object getValue(Object key) {
synchronized (lock) {
return get(key);
}
}
public void putValue(Object key, Object value) {
synchronized (lock) {
put(key, value);
}
}
public boolean removeValue(Object key) {
synchronized (lock) {
return remove(key) != null;
}
}
public boolean removeAll() {
clear();
return true;
}
//Test
static class UserService {
static Object lock1 = new Object();
private static LRUHashMap lruHashMap = new LRUHashMap(2, lock1);
private static HashMapCache.UserService service = new HashMapCache.UserService();
public static String getUser(String name) {
String user = (String) lruHashMap.getValue(name);
if (user == null) {
user = (String) service.get(name);
lruHashMap.put(name, user);
}
return user;
}
public static void main(String[] args) {
System.out.println(getUser("aaa"));
System.out.println(getUser("bbb"));
System.out.println(getUser("ccc"));
System.out.println("---------此处从缓存中获取数据---------");
System.out.println(getUser("bbb"));
System.out.println(getUser("ccc"));
//a 缓存已经被删除,所以会重新加载DB
System.out.println(getUser("aaa"));
}
/**
* 模拟DB
*/
String get(String name) {
System.out.println("load user from db");
return "name : " + name;
}
}
}
输出结果:
load user from db
name : aaa
load user from db
name : bbb
load user from db
name : ccc
load user from db
---------此处从缓存中获取数据---------
name : bbb
name : ccc
load user from db
name : aaa
LRUMap,用来进行缓存数据的淘汰,但是有几个问题:
- 锁竞争严重,可以看见代码中,Lock是全局锁,在方法级别上面的,当调用量较大时,性能必然会比较低。
- 不支持过期时间
- 不支持自动刷新
Guava cache
谷歌提供了Guava cache,在Guava cache中你可以如下面的代码一样,轻松使用:
public class GuaveCache {
public static void main(String[] args){
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(100)
//写之后30ms过期
.expireAfterWrite(30L, TimeUnit.MILLISECONDS)
//访问之后30ms过期
.expireAfterAccess(30L, TimeUnit.MILLISECONDS)
//20ms之后刷新
.refreshAfterWrite(20L, TimeUnit.MILLISECONDS)
//开启weakKey key 当启动垃圾回收时,该缓存也被回收
.weakKeys()
.build(createCacheLoader());
try {
System.out.println(cache.get("key1"));
System.out.println(cache.get("key2"));
cache.put("key1", "key1-aaa");
cache.put("key2", "key2-bbb");
Thread.sleep(10);
System.out.println("key1 >>> " + cache.get("key1"));
System.out.println("key2 >>> " + cache.get("key2"));
Thread.sleep(40);
System.out.println("key1 >>> " + cache.get("key1"));
System.out.println("key2 >>> " + cache.get("key2"));
} catch (Exception e) {
e.printStackTrace();
}
}
public static CacheLoader<String, String> createCacheLoader() {
return new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
return "default";
}
};
}
}
输出结果:
default
default
key1 >>> key1-aaa
key2 >>> key2-bbb
key1 >>> default
key2 >>> default
Guava cache锁竞争
Guava cache在设计实现上,
LocalCache
的并发策略和concurrentHashMap
的并发策略是一致的,也是根据分段锁来提高并发能力,分段锁可以很好的保证 并发读写的效率。因此,该map支持非阻塞读和不同段之间并发写。。在Guava根据一定的算法进行分段,这里要说明的是,如果段太少那竞争依然很严重,如果段太多会容易出现随机淘汰,比如大小为100的,给他分100个段,那也就是让每个数据都独占一个段,而每个段会自己处理淘汰的过程,所以会出现随机淘汰。在guava cache中通过如下代码,计算出应该如何分段。
segmentCount就是最后的分段数,其保证了每个段至少10个Entry。如果没有设置concurrencyLevel这个参数,那么默认就会是4,最后分段数也最多为4(例如size为100,会分为4段,每段最大的size是25),
/**
* Find the lowest power-of-two segmentCount that exceeds concurrencyLevel,
* unless maximumSize/Weight is specified in which case ensure that each segment gets at least 10 entries.
* The special casing for size-based eviction is only necessary because that eviction happens per segment instead of globally,
* so too many segments compared to the maximum size will result in random eviction behavior.
*
*/
int segmentShift = 0;
int segmentCount = 1;
while (segmentCount < concurrencyLevel
&& (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
++segmentShift;
segmentCount <<= 1;
}
Guava cache TTL
Guava cache 对比 LRUMap 有两种过期时间,在guava cache中对于过期的Entry并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁。
个是写后多久过期expireAfterWrite;
一个是读后多久过期expireAfterAccess。
每个Segment中维护了两个队列:
Queue<ReferenceEntry<K, V>> writeQueue; writeQueue维护了写队列,队头代表着写得早的数据,队尾代表写得晚的数据。
Queue<ReferenceEntry<K, V>> accessQueue; accessQueue维护了访问队列,和LRU一样,用来进行访问时间的淘汰,如果当这个Segment超过最大容量,就会把accessQueue这个队列的第一个元素进行淘汰。
- Guava cache的expireEntries过程,会对两个队列一次进行peek操作,如果过期就进行删除。一般处理过期Entries可以在我们的put操作的前后,或者读取数据时发现过期了,然后进行整个Segment的过期处理,又或者进行二次读lockedGetOrLoad操作的时候调用。
void expireEntries(long now) {
drainRecencyQueue();
ReferenceEntry<K, V> e;
while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
throw new AssertionError();
}
}
while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
throw new AssertionError();
}
}
}
Guava cache removalListener
在guava cache中,当有数据被淘汰时,但是你不知道他到底是过期,还是被驱逐,还是因为虚引用的对象被回收?这个时候你可以调用这个方法removalListener(RemovalListener listener)添加监听器进行数据淘汰的监听,可以打日志或者一些其他处理,可以用来进行数据淘汰分析。
Caffeine
Caffeine缓存和其他缓存的一些比较图(图片扒自网络):看着图片性能🐂了不止一点点啊
Caffeine cache实现了W-TinyLFU(LFU+LRU算法的变种)。
Caffeine 性能分析
Guava cache的过期处理逻辑为了避免后台进程扫描数据造成🔐等待,采用的方式是在读写操作中进行过期逻辑处理,所以每一次的操作都能进行一次过期处理。当然其读写性能会受到一定影响。
Caffeine cache性能优于Guava cached主要是因为在caffeine,对这些事件的操作是通过异步操作,他将事件提交至队列,这里的队列的数据结构是RingBuffer,(对于读操作比写操作更加频繁,进一步减少竞争,其为每个线程配备了一个RingBuffer)。然后通过会通过默认的ForkJoinPool.commonPool(),或者自己配置线程池,进行取队列操作,然后在进行后续的淘汰,过期操作。
Caffeine 淘汰策略
Caffeine cache所有的数据都在ConcurrentHashMap中,这个和guava cache不同,guava cache是自己实现了个类似ConcurrentHashMap的结构。在caffeine中有三个记录引用的LRU队列:
Caffeine数据结构
Eden队列:在caffeine中规定只能为缓存容量的%1,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。伊甸区,最舒服最安逸的区域,在这里很难被其他数据淘汰。
Probation队列:叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为 = size-eden-protected。
Protected队列:在这个队列中,暂时不会被淘汰。如果Probation队列没有数据了或者Protected数据满了,也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要把Probation访问一次之后,就会提升为Protected队列。这个有效大小为(size减去eden) X 80%。 如果size =100,就会是79。
三个队里的数据交换:
- 所有的新数据都会进入Eden。
- Eden满了,淘汰进入Probation。
- 如果在Probation中访问了其中某个数据,则这个数据升级为Protected。
- 如果Protected满了又会继续降级为Probation。
数据的淘汰过程:
对于发生数据淘汰的时候,会从Probation中进行淘汰,会把这个队列中的数据队头称为受害者,这个队头肯定是最早进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,但是在这里只能叫他受害者,这个队列是缓刑队列,代表马上要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者做PK,通过我们的Count-Min Sketch中的记录的频率数据有以下几个判断:
如果攻击者大于受害者,那么受害者就直接被淘汰。
如果攻击者<=5,那么直接淘汰攻击者。
其他情况,随机淘汰。