发布于 

what is lru

what is LRU

LRU算法,全程是:Least Recently Used。最近最少使用算法。

这个算法(淘汰算法)的思想是:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。所以,当指定的空间已存满数据时,应当把最久没有被访问到的数据淘汰

LRU实现

leetcode-146-LRU缓存

数组方案

如果我们之前完全没有接触过LRU算法,仅仅知道算法的思路。用数组应该是我们最容易想到的第一个方案。

假设我们又一个定长的数组,数组中的元素都有一个标记。这个标记可以是时间戳、或者是一个自增的数字

  • 时间戳比较容易。

    • 当我们放入一位元素之后,判断数组是否满了,遍历数组找到时间戳最小的一个删除。
    • 每访问一个元素,就要更新其时间戳。
  • 如果我们使用的是一个自增数字。

    • 每放入一个元素,就把数组中已经存在的数据标记更新一下,进行自增。当数组满了后,就将标记数字最大的元素删除。
    • 每访问一个元素,就将被访问的元素的数字置为 0 。

但是这种方案的弊端也很明显:需要不停地维护数组中元素的标记

你还有其他更优的方案吗?

链表方案

最近最少使用:需要一个有序的结构。假设我们采用链表来实现。

  • 插入一个元素,我们追加到数组的末尾(保证尾部的数据都是最近被访问的数据)
    1. 遍历数组,如果数组中存在这个元素,删除当前位置,直接追到到末尾。
    2. 如果是新元素:
      • 如果此时缓存未满,直接追到到末尾。
      • 如果此时缓存满了,就删除链表的的头部元素,然后再在末尾追加。
  • 访问一个元素,我们就把被访问到的元素移动到数组的末尾。
  • 当数组满了之后,我们直接从头部移除元素就可以了。

但是这个方案的时间复杂度还是O(n)。你能不能给一个查询和插入的时间复杂度都是O(1)的解决方案?

双向链表+哈希表方案

为了实现插入和查询的时间复杂度都是O(1),我们需要满足下面三个条件的数据结构:

  • 首先这个数据结构必须是有时序的,以区分最近使用的和很久没有使用的数据,当容量满了之后,要删除最久未使用的那个元素。
  • 要在这个数据结构中快速找到某个 key 是否存在,并返回其对应的 value。
  • 每次访问这个数据结构中的某个 key,需要将这个元素变为最近使用的。也就是说,这个数据结构要支持在任意位置快速插入和删除元素。

我们思考下:

查询要快,哈希表可以满足。但是哈希表的数据是无序的。有序我们可以考虑链表来实现,并且链表支持快速的插入、删除操作,但是查询慢。

所以我们让哈希表和链表结合一下,形成一个新的数据结构:哈希链表,LinkedHashMap

我们画一张图:感受下:

LinkedHashMap

接下来我们就基于LinkedHashMap来深入探讨2个问题。

  1. 为什么要用双向链表呢?单链表可以吗?
  2. 为什么链表还需要存储key呢?只存储value不可以吗?

为什么要双向链表呢?

假设我们要删除一个元素,删除一个元素,我们可以通过hash表通过O(1)来定位到元素。我们得到了要删除的元素,但是此时我们需要维护链表的结构,我们还需要此元素的前驱节点的指针。这样才满足用O(1)的时间复杂度来完成删除操作。

为什么链表的节点要存储key呢?

此时我们顺利删除了链表中的节点,但是我们还需要维护hash表的元素呀,我们需要通过key来定位hash表的元素。

Java-LinkedHashMap方案

经过我们前面的讨论,我们直接给出java的实现版本。

class LRUCache extends LinkedHashMap<Integer, Integer>{

private Integer capacity;

public LRUCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}

public int get(int key) {
return getOrDefault(key, -1);

}

public void put(int key, int value) {
super.put(key, value);
}

@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}

/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache obj = new LRUCache(capacity);
* int param_1 = obj.get(key);
* obj.put(key,value);
*/

LRU的应用

LRU-Mysql

LRU在mysql的应用,最直接的体现就是Buffer Pool(缓冲池)。

为了减少磁盘的IO,我们知道Mysql的是页来存储的,默认一页是16kb,进行一次IO就是加载16kb的数据到缓冲池中,这个缓冲池的默认大小是128MB。

但是在Mysql中并不是简单的使用了LRU算法。

  • Mysql当中有一个预读的功能,会读取到并不需要的页,而浪费了宝贵的内存资源。
  • 还有一个场景是Mysql在全表扫描的时候,有可能把整个缓冲池的缓冲页都换了一遍。影响缓存的命中率。

解决方案是,Mysql把 LRU 链表分为两截,一截里面放的是热数据,一截里面放的是冷数据。

这部分在其他篇幅去展开讲。

LRU-Redis

LRU是淘汰算法,那肯定有我们redis的事情啦。我们先介绍下redis的淘汰策略:

在redis4.0之前,有6种内存淘汰机制。在4.0之后增加了2种,总共有8种淘汰。

  1. 默认的淘汰策略是:不进行数据淘汰
  2. 带有过期时间的淘汰策略:radom、ttl、lru、lfu
  3. allKey的淘汰策略:random、lru、lfu

关于LRU的更多知识,我们可以参考redis官网:redis-LRU介绍

image-20220529163650563

从上面的官网介绍:我们可以得知redis中的LRU不是严格LRU淘汰机制。因为精准的LRU算法需要消耗更多的内存。

redis是这样做的:Redis 会尝试执行一个近似的LRU算法,通过采样一小部分键,然后在采样键中回收最适合的那个,也就是最久没有被访问的那个(with the oldest access time)

从 Redis 3.0 开始,该算法得到了改进,同时也采用了一个良好的候选池进行驱逐。这提高了算法的性能,使其能够更接近真实 LRU 算法的行为。maxmemory-samples 5

redis-lru

这是redis官网介绍,使用严格LRU和非严格LRU算法,redis内存分布图

  • 浅灰色带是被驱逐的对象。
  • 灰色带是未被驱逐的对象。
  • 绿色带是添加的对象。

如您所见,与 Redis 2.8 相比,Redis 3.0 的 5 个样本做得更好,但大多数最新访问的对象仍由 Redis 2.8 保留。在 Redis 3.0 中使用 10 的样本大小,该近似值非常接近 Redis 3.0 的理论性能。