完了!我说我没有了解过LRU,面试官让我回去等通知

349 阅读8分钟

一、前言

大家好,我是你们的Boom弟弟,天不怕,地不怕,就怕你不进来。

本篇文章记录了LRU缓存淘汰算法的学习过程,缓存在我们的日常工作中是随处可见,缓存淘汰算法还是有必要了解一下的。

悄悄话: 面试中经常会要你手写LRU!!!所以学不学,您看着办

那接下来我们就冲冲冲!!!

image.png

二、LRU简介

缓存数据是基于内存存储的,所以CRUD操作都非常快,而内存的空间又是非常宝贵的,当缓存数据到达一定容量。想当然的会淘汰一部分数据,而这个淘汰策略就是所谓的缓存淘汰算法。

缓存淘汰算法有很多种,最经典的当数LRU、LFU

LRU算法: 全称是 Least Recently Used,即最近最少访问。也就是说我们认为最近使用过的数据是有用的,很久都没用过的数据是无用的,内存满了就优先删那些很久没用过的数据。

用一句话概括就是喜新厌旧,它的关注点在于缓存命中的时间。

算法逻辑如下:

  1. 通过一个双向链表维护缓存的数据(K-V),根据时间的先后顺序,链表的每个结点都维护了一对前驱指针和后继指针。
  2. 每当发生put操作时,数据插入链表的尾部。即越靠近尾部的数据越新,越靠近头部的数据越老。
  3. 每当发生get操作时,遍历链表找到对应的结点,即命中了缓存,此时,会把改结点的数据移动到链表的尾部(即最近被访问的数据)。
  4. 当链表的容量达到我们设置的最大容量时,而此时又有数据发生put操作,则会先移除掉链表头部的老数据(即最少被访问的数据)。

博人传.jpg

注意 前方高能!!!

虽然put操作的时间复杂度为O(1)。 但是上面的第3步就有点问题,get操作遍历链表? 时间复杂度不是 O(n) 吗? 这好像不太符合缓存“”的特性。那能不能把查询的时间复杂度再提升一下呢?

当然有,我的宝,那就是再引入一个哈希表的结构,即空间换时间的思想!!!

结构如下:

  1. 哈希表的key为链表中对应结点的key,。

  2. 哈希表的value指向链表中对应的结点。

咦,这样查询的时间复杂度是不是就变为O(1)了,相当符合我们缓存“快”的特性。

有了理论基础之后,我们来手写一个LRU算法

PS:理论搞清楚之后,写起来其实不难的。 image.png

三、手写LRU

import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 缓存淘汰算法--LRU--最近最少使用
 * 喜新厌旧
 */
public class LRUTest {

    // 双向链表的最大容量,当达到这个容量时,会移除最少使用的数据
    private final int maxlen=20;

    // 维护尾结点,方便获取
    private LRULinked startLRULinked;

    // 维护尾结点,方便获取
    private LRULinked endLRULinked;

    // hash表---用来提升查询的速度----时间复杂度O(1)
    private Map<String, LRULinked> linkedHashMap = new ConcurrentHashMap<>();


    /**
     * 缓存的put操作
     * @param key
     * @param data
     */
    public synchronized void put(String key, int data){
        // 去hash表中判断缓存中是否存在该Key (第二个参数1的意思是控制台打印而已,可以略)
        LRULinked dataLru = get(key, 1);
        if (dataLru!=null){
            // 如果存在,则更新对应缓存的data, 同时更新链表的结点,以及hash表
            dataLru.data=data;
            linkedHashMap.put(key, dataLru);
            System.out.println("key: "+key+"命中了缓存, data:"+data+", 触发缓存更新");
            return;
        }

        //当达到最大容量, 移除头结点
        int size = linkedHashMap.size();
        System.out.println("----当前链表长度----"+ size);
        if (size==maxlen){
            try {
                System.err.println("链表达到最大长度, 移除头结点, key: "+startLRULinked.key+", value:"+startLRULinked.data);
                remove(startLRULinked);
            }catch (Exception e){
                e.printStackTrace();
            }
        }

        //如果头结点和尾结点都为null 则说明该链表是一个空链表 直接插入即可  此时头结点就是尾结点
        if (startLRULinked==null && endLRULinked==null){
            endLRULinked = startLRULinked = new LRULinked(key, data, null, null);
        }else {
            // 反之, 插入到链表的尾部,此时尾结点变为新插入的结点,新尾结点的前驱指针指向旧尾结点, 旧尾结点的后继指针指向新尾结点
            LRULinked oldEndLRULinked = endLRULinked;
            LRULinked lruLinked = new LRULinked(key, data, oldEndLRULinked, null);
            oldEndLRULinked.next = lruLinked;
            endLRULinked = lruLinked;
        }
        linkedHashMap.put(key, endLRULinked);
    }


    /**
     * 移除缓存remove操作
     * @param lruLinked
     * @return
     */
    public synchronized LRULinked remove(LRULinked lruLinked){
        if (lruLinked==null){
            return null;
        }
        // 先移除hash表的缓存
        linkedHashMap.remove(lruLinked.key);

        //获取该结点的前驱结点和后继结点
        LRULinked next = lruLinked.next;
        LRULinked pre = lruLinked.pre;
        //如果后继结点不等于null  则后继结点的前驱结点指向 该结点的前驱结点
        if (next!=null){
            next.pre = pre;
        }else {
            //如果后继结点为null 则说明该结点就是尾结点  则该结点的前驱结点充当尾结点
            endLRULinked=pre;
        }

        //和上面同理
        if (pre!=null){
            pre.next = next;
        }else {
            startLRULinked=next;
        }
        return lruLinked;
    }


    /**
     * 获取缓存数据的get操作
     * @param key
     * @param confirmIsExit
     * @return
     */
    public synchronized LRULinked get(String key, int confirmIsExit){
        if (endLRULinked==null && startLRULinked==null)
            return null;

        //去hash表查询对应key的数据 判断缓存是否存在
        LRULinked lruLinked = linkedHashMap.get(key);
        if (confirmIsExit!=1){
            printAllPoint();
            //命中缓存之后,把该结点的数据移动到尾结点(即最近访问的数据)
            if (lruLinked!=null){
                System.err.println("key: "+key+"命中缓存, data: "+ lruLinked.data);
                LRULinked next = lruLinked.next;
                LRULinked pre = lruLinked.pre;
                if (next!=null)
                    next.pre=pre;
                if (pre!=null)
                    pre.next=next;
                linkedHashMap.remove(key);
                put(lruLinked.key, lruLinked.data);
            }
        }
        return lruLinked;
    }

    public void printAllPoint(){
        if (endLRULinked==null && startLRULinked==null)
            return ;

        LRULinked lruLinkedParam = endLRULinked;
        while (true){
            if (lruLinkedParam.pre==null){
                System.out.println("key: "+lruLinkedParam.key+"---value: "+lruLinkedParam.data+"");
                return;
            }else {
                System.out.print("key: "+lruLinkedParam.key+"---value: "+lruLinkedParam.data+", ");
            }
            lruLinkedParam = lruLinkedParam.pre;
        }
    }

    /**
     * 模拟LRU缓存淘汰策略
     * 1. 开一个查询线程, 随机查询【1-30】
     * 2. 开一个插入线程,随机插入【1-30】
     * 3. 设置链表长度,当长度超过N时,从头结点依次踢出。
     * 4. 当缓存命中,把命中的结点移动到尾部插入
     * @param args
     */
    public static void main(String[] args) {
        LRUTest test = new LRUTest();

        Thread findThread = new Thread("查询线程") {
            @Override
            public void run() {
                Random random = new Random();
                while (true){
                    System.out.println("开始新一轮查询了....");
                    int num = random.nextInt(30)+1;
                    test.get(num+"", 0);
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        Thread addThread = new Thread("插入线程") {
            @Override
            public void run() {
                Random random = new Random();
                while (true){
                    System.out.println("开始新一轮插入了....");
                    int num = random.nextInt(30)+1;
                    test.put(num+"", random.nextInt(30)+1);
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        findThread.start();
        addThread.start();
    }


    /**
     * 双向链表结点
     */
    class LRULinked{
        // 缓存的key
        private String key;
        // 缓存的data
        private int data;

        // 前驱结点
        private LRULinked pre;

        // 后继结点
        private LRULinked next;

        public LRULinked(String key, int data, LRULinked pre, LRULinked next) {
            this.key = key;
            this.data = data;
            this.pre = pre;
            this.next = next;
        }
    }
}

四、LRU的应用

Redis中的应用

这就要聊到Redis内存淘汰机制

在redis.conf中有一行参数用来配置内存淘汰策略的

maxmemory-policy volatile-LRU

  • volatile-LRU:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰

  • volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰

  • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰

  • allkeys-LRU:从数据集中挑选最近最少使用的数据淘汰

  • allkeys-random:从数据集中任意选择数据淘汰

  • no-enviction(驱逐):禁止驱逐数据,新写入操作会报错

可以看得出来如果使用了 volatile-LRU策略,则将会使用LRU数据结构来对内存中的数据进行清理。

Mysql中的应用

Mysql中Innodbbuffer pool也是用来加速查询的缓存,当buffer pool的容量被占满时,也需要淘汰数据,其中数据的淘汰也是基于LRU算法的。

所有从磁盘上读取的数据首先都会缓存在buffer pool中。当对存放大量冷数据的表进行查询时,会在短时间内将大量冷数据加载到buffer pool中,如果buffer pool被占满之后就会根据LRU算法淘汰数据,可能就把之间的热点数据淘汰了,从而导致的缓存命中率下降。

为了避免因为冷数据表的查询导致热点数据被淘汰的问题,Mysql对LRU算法进行了改进,将buffer pool分成young和old两个区域,所有数据被加载进buffer pool时都是先放在old区,当在old区待足够长的时间或被访问次数达到阈值时数据才会被放到young区。数据淘汰也是优先从old区淘汰。这样就能避免大量冷数据加载导致的热点数据淘汰问题。

五、总结

  • LRU缓存机制的核心:双向链表(保证元素有序,且能快速的插入和删除)+hash表(可以快速查询)

  • 为什么使用双向链表?因为:对于删除操作,使用双向链表,我们可以在O(1)的时间复杂度下,找到被删除节点的前节点。

  • 为什么要在链表中同时存键值,而不是只存值? 因为:当缓存容量满了之后,我们不仅要在双向链表中删除最后一个节点(即最久没有使用的节点),还要把hash表中对应的数据删除。如果尾结点中没有key,只有data的话,是没办法去hash表中找对对应的数据的。