(1)缓存是什么
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
(2)为什么需要缓存
CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(cacheline)并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。
对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。
Cache Line 是 Cache 与 DRAM 同步的最小单位. 典型的虚拟内存页面大小为 4KB,而典型的 Cache line 通常的大小为 32 或 64 字节
(3)缓存淘汰策略分类
缓存的大小是有限的,当缓存被用满时,性需要进行数据清理。哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。
3.1)先进先出策略FIFO(First In,First Out)
思路:
实现思路:
3.2)最近最少频率使用策略(最不经常使用)LFU(Least Frenquently Used)
LRU(The Least Recently Used,最近最久未使用算法)是一种常见的缓存算法,在很多分布式缓存系统(如Redis, Memcached)中都有广泛使用。
思想:最不经常使用策略,在一段时间内,数据被使用频次最少的,优先被淘汰。最少使用(LFU)是一种用于管理计算机内存的缓存算法。主要是记录和追踪内存块的使用次数,当缓存已满并且需要更多空间时,系统将以最低内存块使用频率清除内存.采用LFU算法的最简单方法是为每个加载到缓存的块分配一个计数器。每次引用该块时,计数器将增加一。当缓存达到容量并有一个新的内存块等待插入时,系统将搜索计数器最低的块并将其从缓存中删除;
3.3)最近最久未使用策略LRU(Least Recently Used)
思想:LRU是最近最少使用策略的缩写,是根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。同样,如果一个数据在最近一段时间没有被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最久没有访问的数据最先被置换(淘汰)。
(4)实现LRU
(4.1) 要求
缓存的大小有限,当缓存被用满时,使用LRU缓存淘汰策略,清理的是很久未使用的缓存数据;
(4.2)思路
1》如果此数据之前已经被缓存在链表中了,通过遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
2》如果此数据没有在缓存链表中,可以分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
说明:
如果缓存空间足够大,那么存储的数据也就足够多,通过缓存中命中数据的概率就越大,也就提高了代码的执行速度。这就是空间换时间的设计思想。
对于程序开发来说,时间复杂度和空间复杂度是可以相互转化的。说通俗一点,就是:
-
对于执行的慢的程序,可以通过消耗内存(即构造新的数据结构)来进行优化;
-
而消耗内存的程序,可以通过消耗时间来降低内存的消耗。
(4.3)数据结构
如何设计一个LRU缓存,使得放入和移除都是 O(1) 的;
我们可以总结出 cache 这个数据结构必要的条件:查找快,插入快,删除快,有顺序之分。
因为显然 cache 必须有顺序之分,以区分最近使用的和久未使用的数据;而且我们要在 cache 中查找键是否已存在;如果容量满了要删除最后一个数据;每次访问还要把数据插入到队头。
那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表。
LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。
(4.3.1)基于 HashMap 和 双向链表实现 LRU
4.3.1.1)使用 HashMap :
HashMap 存储 key,这样可以做到 save 和 get key的时间都是 O(1);
而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。
单独使用hashMap,则不太知道哪些数据是最近刚使用过的,所以也不太方便;
注:此中的hashmap后连接的不是一个链表,而是一个节点;和拉链法的hash表有些不太一样;
所以HashMap应该是用开地址法实现的hash表,保证hash表的每个元素都不是一个链表,都只有一个节点;
2)使用双向链表
如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
ps: 链表结构需要存储:链表头head节点,链表tail 节点;即链表结构和链表节点结构分别定义;
存储链表tail节点是为了在缓存满时删除尾节点,由于是双向链表,可以根据尾节点得知尾节点的前一个节点,然后删除尾节点,更新保持的尾即可;
如下所示:
struct DNode {
int data;
struct Node* pre;
struct Node* nxt;
};
Struct DList{
int cur_size; //链表的长度;方便判断缓存是否满;
int max_size; // 缓存的最大的节点个数;
sturct DNode* head;
struct DNode* tail; //方便缓存满时,删除尾部节点;
};
流程:
首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。
如果不存在,需要构造新的节点,并且尝试把节点塞到队头;
如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。
(4.4)伪代码实现
class Node {
public int key, val;
public Node next, prev;
public Node(int k, int v) {
this.key = k;
this.val = v;
}
}
class DoubleList {
private Node head, tail; // 头尾虚节点
private int size; // 链表元素数
public DoubleList() {
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
size = 0;
}
// 在链表头部添加节点 x
public void addFirst(Node x) {
x.next = head.next;
x.prev = head;
head.next.prev = x;
head.next = x;
size++;
}
// 删除链表中的 x 节点(x 一定存在)
public void remove(Node x) {
x.prev.next = x.next;
x.next.prev = x.prev;
size--;
}
// 删除链表中最后一个节点,并返回该节点
public Node removeLast() {
if (tail.prev == head)
return null;
Node last = tail.prev;
remove(last);
return last;
}
// 返回链表长度
public int size() { return size; }
}
// key 映射到 Node(key, val)
HashMap<Integer, Node> map;
// Node(k1, v1) <-> Node(k2, v2)...
DoubleList cache;
int get(int key) {
if (key 不存在) {
return -1;
} else {
将数据 (key, val) 提到开头;
return val;
}
}
void put(int key, int val) {
Node x = new Node(key, val);
if (key 已存在) {
把旧的数据删除;
将新节点 x 插入到开头;
} else {
if (cache 已满) {
删除链表的最后一个数据腾位置;
删除 map 中映射到该数据的键;
}
将新节点 x 插入到开头;
map 中新建 key 对新节点 x 的映射;
}
}
class LRUCache {
// key -> Node(key, val)
private HashMap<Integer, Node> map;
// Node(k1, v1) <-> Node(k2, v2)...
private DoubleList cache;
// 最大容量
private int cap;
public LRUCache(int capacity) {
this.cap = capacity;
map = new HashMap<>();
cache = new DoubleList();
}
public int get(int key) {
if (!map.containsKey(key))
return -1;
int val = map.get(key).val;
// 利用 put 方法把该数据提前
put(key, val);
return val;
}
public void put(int key, int val) {
// 先把新节点 x 做出来
Node x = new Node(key, val);
if (map.containsKey(key)) {
// 删除旧的节点,新的插到头部
cache.remove(map.get(key));
cache.addFirst(x);
// 更新 map 中对应的数据
map.put(key, x);
} else {
if (cap == cache.size()) {
// 删除链表最后一个数据
Node last = cache.removeLast();
map.remove(last.key);
}
// 直接添加到头部
cache.addFirst(x);
map.put(key, x);
}
}
}
评价:
如果按照HashMap和双向链表实现,需要额外的存储存放 next 和 prev 指针和HashMap,牺牲比较大的存储空间;