1 什么是Cache
1.1 概念
Cache,即高速缓存,是介于CPU和内存之间的高速小容量存储器。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近CPU的频率。
当CPU发出内存访问请求时,会先查看 Cache 内是否有请求数据。如果存在(命中),则直接返回该数据;如果不存在(失效),再去访问内存 —— 先把内存中的相应数据载入缓存,再将其返回处理器。
提供“高速缓存”的目的是让数据访问的速度适应CPU的处理速度,通过减少访问内存的次数来提高数据存取的速度。
如今高速缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和硬盘之间也有Cache(磁盘缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。
1.2 原理
Cache 技术所依赖的原理是”程序执行与数据访问的局部性原理“,这种局部性表现在两个方面:
时间局部性:如果程序中的某条指令一旦执行,不久以后该指令可能再次执行,如果某数据被访问过,不久以后该数据可能再次被访问。
空间局部性:一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令或数据通常是顺序存放的。
时间局部性是通过将近来使用的指令和数据保存到Cache中实现。空间局部性通常是使用较大的高速缓存,并将 预取机制 集成到高速缓存控制逻辑中来实现。
1.3 替换策略
Cache的容量是有限的,当Cache的空间都被占满后,如果再次发生缓存失效,就必须选择一个缓存块来替换掉。常用的替换策略有以下几种:
- 随机算法(Rand):随机法是随机地确定替换的存储块。设置一个随机数产生器,依据所产生的随机数,确定替换块。这种方法简单、易于实现,但命中率比较低。
- 先进先出算法(FIFO, First In First Out):先进先出法是选择那个最先调入的那个块进行替换。当最先调入并被多次命中的块,很可能被优先替换,因而不符合局部性规律。这种方法的命中率比随机法好些,但还不满足要求。
- 最久未使用算法(LRU, Least Recently Used):LRU法是依据各块使用的情况, 总是选择那个
最长时间未被使用的块替换。这种方法比较好地反映了程序局部性规律。 - 最不经常使用算法(LFU, Least Frequently Used):将最近一段时期内,
访问次数最少的块替换出Cache。
2 LRU 介绍
对一个Cache的操作无非三种:插入(insert)、替换(replace)、查找(lookup)。
为了能够快速删除最久没有访问的数据项和插入最新的数据项,我们使用 双向链表 连接Cache中的数据项,并且保证链表维持数据项从最近访问到最旧访问的顺序。
- 插入:当Cache未满时,新的数据项只需插到双链表头部即可。时间复杂度为O(1)O(1).
- 替换:当Cache已满时,将新的数据项插到双链表头部,并删除双链表的尾结点即可。时间复杂度为O(1)O(1).
- 查找:每次数据项被查询到时,都将此数据项移动到链表头部。
经过分析,我们知道使用双向链表可以保证插入和替换的时间复杂度是O(1)O(1),但查询的时间复杂度是O(n)O(n),因为需要对双链表进行遍历。为了让查找效率也达到O(1)O(1),很自然的会想到使用 hashtable。
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。

2.1 LRU实现(hashtable+双向链表)
- 代码
import java.util.Hashtable;
//146. LRU Cache
public class LRUCache {
private int capacity;
private Hashtable<Integer,Node> table=new Hashtable<>();
//head和tail的作用是:标记双向链表的头和尾,不记录实际意义上的数据
private Node head;
private Node tail;
public LRUCache(int capacity) {
this.capacity=capacity;
head=new Node(-1,-1);
tail=new Node(-1,-1);
head.next=tail;
head.pre=null;
tail.pre=head;
tail.next=null;
}
public int get(int key) {
Node n=table.get(key);
if(n!=null){
moveToHead(n);
return n.value;
}
return -1;
}
public void put(int key, int value) {
Node n=table.get(key);
if(n!=null){
n.value=value;
moveToHead(n);
}else {
n=new Node(key,value);
if(table.size()>=capacity){
//注意这里
Node nn=popTail();
table.remove(nn.key);
}
putHead(n);
table.put(key,n);
}
}
private void remove(Node node){
Node pre=node.pre;
Node next=node.next;
pre.next=next;
next.pre=pre;
}
private void putHead(Node node){
node.pre=head;
node.next=head.next;
head.next.pre=node;
head.next=node;
}
private void moveToHead(Node node){
remove(node);
putHead(node);
}
private Node popTail(){
Node n=tail.pre;
remove(n);
return n;
}
class Node{
int key;
int value;
Node pre;
Node next;
public Node(int key ,int value){
this.key=key;
this.value=value;
}
}
}
2.2 LRU实现(LinkedHashMap)
LinkedHashMap底层就是用的HashMap+双链表实现的,而且本身已经实现了按照访问顺序的存储。此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry用于判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素,所以需要重写该方法。即当缓存满后就移除最不常用的数。
public class LRUCache {
private static final float hashLoadFactory = 0.75f;
private LinkedHashMap<Integer,Integer> map;
private int cacheSize;
public LRUCache(int cacheSize) {
this.cacheSize = cacheSize;
int capacity = (int)Math.ceil(cacheSize / hashLoadFactory) + 1;
map = new LinkedHashMap<Integer,Integer>(capacity, hashLoadFactory, true){
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > cacheSize;
}
};
}
public synchronized Integer get(Integer key) {
return map.getOrDefault(key,-1);
}
public synchronized void put(Integer key, Integer value) {
map.put(key, value);
}
public synchronized void clear() {
map.clear();
}
public synchronized int usedSize() {
return map.size();
}
public void print() {
for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
System.out.print(entry.getValue() + "--");
}
System.out.println();
}
}
3 扩展
3.1 LRU-K
LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
数据第一次被访问时,加入到历史访问列表,如果数据在访问历史列表中没有达到K次访问,则按照一定的规则(FIFO或者LRU)淘汰;当访问历史队列中的数据访问次数达到K次后,将数据从历史队列中删除,并移到缓存队列中,缓存数据,缓存队列重新按照时间排序;缓存数据队列中被再次访问后,重新排序,需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即“淘汰倒数K次访问离现在最久的数据”。
LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。
3.2 two queue
Two queues(以下使用2Q代替)算法类似于LRU-2,不同点在于2Q将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。
新访问的数据插入到FIFO队列中,如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;如果数据在FIFO队列中再次被访问到,则将数据移到LRU队列头部,如果数据在LRU队列中再次被访问,则将数据移动LRU队列头部,LRU队列淘汰末尾的数据。
3.3 Multi Queue(MQ)
MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是:优先缓存访问次数多的数据。详细的算法结构图如下,Q0,Q1…Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
新插入的数据放入Q0,每个队列按照LRU进行管理,当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列中删除,加入到高一级队列的头部;为了防止高优先级数据永远不会被淘汰,当数据在指定的时间里没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;需要淘汰数据时,从最低一级队列开始按照LRU淘汰,每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部。如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列头部。Q-history按照LRU淘汰数据的索引。
MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。
3.4 LRU算法对比

参考
https://blog.youkuaiyun.com/maoyeqiu/article/details/50452870
https://blog.youkuaiyun.com/elricboa/article/details/78847305
本文详细介绍了Cache的基本概念,包括其作用、原理及替换策略。深入探讨了LRU算法,包括其原理、实现方式(如双向链表和哈希表结合、使用LinkedHashMap)及其优缺点。同时,还介绍了LRU-K、twoqueue和MultiQueue等算法,对比分析了各种算法的特点。
5918

被折叠的 条评论
为什么被折叠?



