面试官说,听说你了解Redis,手写一个LRU算法吧

LRU是什么

缓存命中率是缓存系统的非常重要指标,如果缓存系统的缓存命中率过低,将会导致查询回流到数据库,导致数据库的压力升高。

LRU(Least Recently Used) 即最近最少使用的数据需要被淘汰,属于典型的内存淘汰机制。也就是说,内存中淘汰那些最近最少使用的数据。

LRU算法实现思路

根据LRU算法的理念,我们需要:
一个参数来作为容量阈值

一种数据结构来存储数据,同时希望插入、读取、删除操作的时间复杂度都是O(1)。

所以,我们用到的数据结构是:Hashmap+双向链表
1.利用hashmap的get、put方法O(1)的时间复杂度,快速取、存数据。
2.利用doublelinkedlist的特征(可以访问到某个节点之前和之后的节点),实现O(1)的新增和删除数据。

如下图所示:

面试官说,听说你了解Redis,手写一个LRU算法吧_第1张图片

LRU的简单实现

节点node,存放key、val值、前节点、后节点

class Node{

public int key;

public int val;

public Node next;

public Node previous;

public Node() {

}

public Node(int key, int val) {

this.key = key;

this.val = val;

}

}

双向链表,属性有size、头节点、尾节点。
提供api:

  • addFirst(): 头插法入链表
  • remove(): 删除最后一个节点
  • remove(Node node):删除特定节点
  • size():获取链表长度

class DoubleList{

private int size;

private Node head;

private Node tail;

public DoubleList() {

this.head = new Node();

this.tail = new Node();

size = 0;

head.next = tail;

tail.previous = head;

}

public void addFirst(Node node){

Node temp = head.next;

head.next = node;

node.previous = head;

node.next = temp;

temp.previous = node;

size++;

}

public void remove(Node node){

if(null==node|| node.previous==null|| node.next==null){

return;

}

node.previous.next = node.next;

node.next.previous = node.previous;

node.next=null;

node.previous=null;

size--;

}

public void remove(){

if(size<=0) return;

Node temp = tail.previous;

temp.previous.next = temp.next;

tail.previous = temp.previous;

temp.next = null;

temp.previous=null;

size--;

}

public int size(){

return size;

}

}

LRU算法实现类
API

  • get(int key): 为null返回-1
  • put(int key, int value)
    • 若map中有,删除原节点,增加新节点
    • 若map中没有,map和链表中新增新数据。

public class LRUCache {

Map map;

DoubleList cache;

int cap;

public LRUCache(int cap) {

map = new HashMap<>();

cache = new DoubleList();

this.cap = cap;

}

public int get(int key){

Node node = map.get(key);

return node==null? -1:node.val;

}

public void put(int key, int val){

Node node = new Node(key,val);

if(map.get(key)!=null){

cache.remove(map.get(key));

cache.addFirst(node);

map.put(key,node);

return;

}

map.put(key,node);

cache.addFirst(node);

if(cache.size()>cap){

cache.remove();

}

}

public static void main(String[] args) {

//test, cap = 3

LRUCache lruCache = new LRUCache(3);

lruCache.put(1,1);

lruCache.put(2,2);

lruCache.put(3,3);

//<1,1>来到链表头部

lruCache.put(1,1);

//<4,4>来到链表头部, <2,2>被淘汰。

lruCache.put(4,4);

}

}

LRU应用场景

  • 底层的内存管理,页面置换算法
  • 一般的缓存服务,memcache\redis之类
  • 部分业务场景

LRU 算法劣势在于对于偶发的批量操作,比如说批量查询历史数据,就有可能使缓存中热门数据被这些历史数据替换,造成缓存污染,导致缓存命中率下降,减慢了正常数据查询。

扩展

1.LRU-K

LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。

相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。

LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。

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.MySQL InnoDB LRU

将链表拆分成两部分,分为热数据区,与冷数据区。

  1. 访问数据如果位于热数据区,与之前 LRU 算法一样,移动到热数据区的头结点。
  2. 插入数据时,若缓存已满,淘汰尾结点的数据。然后将数据插入冷数据区的头结点。
  3. 处于冷数据区的数据每次被访问需要做如下判断:

若该数据已在缓存中超过指定时间,比如说 1 s,则移动到热数据区的头结点。

若该数据存在在时间小于指定的时间,则位置保持不变。

对于偶发的批量查询,数据仅仅只会落入冷数据区,然后很快就会被淘汰出去。热门数据区的数据将不会受到影响,这样就解决了 LRU 算法缓存命中率下降的问题。

 

你可能感兴趣的:(架构,java,程序人生,面试,开发语言)