4

缓存算法(页面置换算法)之LRU算法

背景

由一道算法题引起的思考。
之前在leetcode刷题的时候遇到这道题(题目来源LRU缓存机制

题目:

运用你所掌握的数据结构,设计和实现一个  LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。

获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
写入数据 put(key, value) - 如果密钥已经存在,则变更其数据值;如果密钥不存在,则插入该组「密钥/数据值」。
当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

示例:

LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );

cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回  1
cache.put(3, 3);    // 该操作会使得密钥 2 作废
cache.get(2);       // 返回 -1 (未找到)
cache.put(4, 4);    // 该操作会使得密钥 1 作废
cache.get(1);       // 返回 -1 (未找到)
cache.get(3);       // 返回  3
cache.get(4);       // 返回  4

这题大家不妨先思考一下,后面我再讲解一下我的解法。

LRU(Least Recently Used:最近最少使用)

最近最少使用策略,不难从字面去理解,就是当一个页面最近最少使用,那么当内存溢出,缓存中淘汰的就是最近最少使用的页面。
我们来画个图理解一下:
image

  1. 假设缓存容量为3
  2. 依次访问a,b,c三个页面
  3. 访问新页面d,由于缓存容量已经满了,而a是最近最少使用,因此淘汰a,缓存d
  4. 访问老页面b
  5. 因为b已经在缓存中,因此将b挪到最近使用的位置
  6. 访问新页面e,在第五步中c变成了最近最少使用的位置,因此淘汰c,缓存e。

相信大家看完这个图之后对LRU算法有了一个初步的了解

维护一个队列,最近访问的在最底层,最近最少访问的在最上层。
当队列满了,有新的元素进队时,将最上层的元素出队列,将新元素放到队尾。
当访问已经存在于队列的中的老元素,将老元素放到队列的最后一位,其他元素往前补位。

既然如此,那我们接着来看一开始的算法题:

/**
 * @param {number} capacity
 */
var LRUCache = function(capacity) {
    // 维护一个堆栈来进行缓存,最近使用的在最后面,最久没使用的在第一个
    this.cacheMap = new Map();
    this.capacity = capacity;
};

/** 
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function(key) {
    if(this.cacheMap.has(key)){
        // 有命中,更改该值在堆栈中的顺序
        let temp = this.cacheMap.get(key);
        this.cacheMap.delete(key);
        this.cacheMap.set(key, temp);
        return temp;
    }
    else{
        return -1;
    }
};

/** 
 * @param {number} key 
 * @param {number} value
 * @return {void}
 */
LRUCache.prototype.put = function(key, value) {
    if(this.cacheMap.has(key)){
        // 命中,改变顺序即可
        this.cacheMap.delete(key);
        this.cacheMap.set(key, value)
    }
    else{
        // 没命中
        if(this.cacheMap.size >= this.capacity){
            // 堆栈已满,清除第一个数据
            // map的keys函数返回一个迭代器,然后用一次next就能获取第一个元素
            let firstKey = this.cacheMap.keys().next().value;
            this.cacheMap.delete(firstKey);
            this.cacheMap.set(key, value)
        }
        else{
            // 堆栈未满,存数据
            this.cacheMap.set(key, value)
        }
    }
};

本解法用map构建了一个堆栈的数据结构,大家也可以用array或者object,只是map键值对的映射关系比较直观。
然后解题思路就是上面分析那样,比较简单,加上注释,相信大家能看懂,再此就不逐行解释了。
贴上提交记录
image.png

vue-router中keep-alive的应用

vue-router中的keep-alive是通过在vue-router进行组件注册时,配置项中的meta.keepAlive设置为true即可实现组件页面的缓存,如:

{
    path: '/main',
    name: '首页',
    component: Main,
    meta:{
        keepAlive:true
    }
}

vue-router中的keep-alive的使用在本文中就不展开阐述了,有兴趣的同学可自行查阅。

在 vue 2.5.0 版本中,keep-alive新增了max属性,意思是指缓存的组件页面是有限数量的,当缓存中的数量已经达到了max,那么当访问新的组件页面时,就要淘汰老的一个,而淘汰或者缓存机制的算法就是本文所说的LRU算法。

先上keep-alive的源码:源码地址

/* @flow */

import { isRegExp, remove } from 'shared/util'
import { getFirstComponentChild } from 'core/vdom/helpers/index'

type VNodeCache = { [key: string]: ?VNode };

function getComponentName(opts: ?VNodeComponentOptions): ?string {
    return opts && (opts.Ctor.options.name || opts.tag)
}

function matches(pattern: string | RegExp | Array<string>, name: string): boolean {
    if (Array.isArray(pattern)) {
        return pattern.indexOf(name) > -1
    } else if (typeof pattern === 'string') {
        return pattern.split(',').indexOf(name) > -1
    } else if (isRegExp(pattern)) {
        return pattern.test(name)
    }
    /* istanbul ignore next */
    return false
}

function pruneCache(keepAliveInstance: any, filter: Function) {
    const { cache, keys, _vnode } = keepAliveInstance
    for (const key in cache) {
        const cachedNode: ?VNode = cache[key]
        if (cachedNode) {
            const name: ?string = getComponentName(cachedNode.componentOptions)
            if (name && !filter(name)) {
                pruneCacheEntry(cache, key, keys, _vnode)
            }
        }
    }
}

function pruneCacheEntry(
    cache: VNodeCache,
    key: string,
    keys: Array<string>,
    current?: VNode
) {
    const cached = cache[key]
    if (cached && (!current || cached.tag !== current.tag)) {
        cached.componentInstance.$destroy()
    }
    cache[key] = null
    remove(keys, key)
}

const patternTypes: Array<Function> = [String, RegExp, Array]

export default {
    name: 'keep-alive',
    abstract: true,

    props: {
        include: patternTypes,
        exclude: patternTypes,
        max: [String, Number]
    },

    created() {
        this.cache = Object.create(null)
        this.keys = []
    },

    destroyed() {
        for (const key in this.cache) {
            pruneCacheEntry(this.cache, key, this.keys)
        }
    },

    mounted() {
        this.$watch('include', val => {
            pruneCache(this, name => matches(val, name))
        })
        this.$watch('exclude', val => {
            pruneCache(this, name => !matches(val, name))
        })
    },

    render() {
        const slot = this.$slots.default
        const vnode: VNode = getFirstComponentChild(slot)
        const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
        if (componentOptions) {
            // check pattern
            const name: ?string = getComponentName(componentOptions)
            const { include, exclude } = this
            if (
                // not included
                (include && (!name || !matches(include, name))) ||
                // excluded
                (exclude && name && matches(exclude, name))
            ) {
                return vnode
            }

            const { cache, keys } = this
            const key: ?string = vnode.key == null
                // same constructor may get registered as different local components
                // so cid alone is not enough (#3269)
                ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
                : vnode.key
            if (cache[key]) {
                vnode.componentInstance = cache[key].componentInstance
                // make current key freshest
                remove(keys, key)
                keys.push(key)
            } else {
                cache[key] = vnode
                keys.push(key)
                // prune oldest entry
                if (this.max && keys.length > parseInt(this.max)) {
                    pruneCacheEntry(cache, keys[0], keys, this._vnode)
                }
            }

            vnode.data.keepAlive = true
        }
        return vnode || (slot && slot[0])
    }
}

从源码可以看出,先是从props中接受一个max的参数,为缓存的最大值。
然后在created生命周期中实例化了一个cache对象,用作缓存。
当访问组件页面时,调用pruneCache方法,先是通过getComponentName方法获取组件数据,然后通过pruneCacheEntry进行缓存,而该缓存的策略就是通过LRU算法实现的。

其他应用

  1. 浏览器缓存淘汰策略
  2. redis中的缓存淘汰策略
  3. 其他。。。

这部分的实现感兴趣的同学可自行搜索。

其他缓存策略

最佳置换算法(OPT)

这是一种理想情况下的页面置换算法,但实际上是不可能实现的。该算法的基本思想是:发生缺页时,有些页面在内存中,其中有一页将很快被访问(也包含紧接着的下一条指令的那页),而其他页面则可能要到10、100或者1000条指令后才会被访问,每个页面都可以用在该页面首次被访问前所要执行的指令数进行标记。最佳页面置换算法只是简单地规定:标记最大的页应该被置换。这个算法唯一的一个问题就是它无法实现。当缺页发生时,操作系统无法知道各个页面下一次是在什么时候被访问。虽然这个算法不可能实现,但是最佳页面置换算法可以用于对可实现算法的性能进行衡量比较。

先进先出置换算法(FIFO)

最简单的页面置换算法是先入先出(FIFO)法。这种算法的实质是,总是选择在主存中停留时间最长(即最老)的一页置换,即先进入内存的页,先退出内存。理由是:最早调入内存的页,其不再被使用的可能性比刚调入内存的可能性大。建立一个FIFO队列,收容所有在内存中的页。被置换页面总是在队列头上进行。当一个页面被放入内存时,就把它插在队尾上。

最近最少使用算法(LFU: Least Frequently Used)

它是基于如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小的思路。
乍然一看,好像LFU跟LRU没什么不同,其实,最核心的判断就不同了。
LRU的淘汰规则是基于访问时间,而LFU是基于访问次数的。
举个例子🌰:
假设缓存大小为3,数据访问序列为

set(2,2)
set(1,1)
get(2)
get(1)
get(2)
set(3,3)
set(4,4)

则在set(4,4)时对于LFU算法应该淘汰(3,3),而LRU应该淘汰(1,1)。
因为根据LFU的核心,在堆栈满载之后,1访问了1次,2访问了2次,虽然3是最后才加进来的,但是访问次数为0,最少访问,所以LFU淘汰的是(3,3)。

总结

LRU算法实现虽然简单,并且在大量频繁访问热点页面时十分高效,但同样也有一个缺点,就是如果该热点页面在偶然一个时间节点被其他大量仅访问了一次的页面所取代,那自然造成了浪费。

为了解决上述问题,我会在下篇文章为大家介绍LRU-K和2Q算法。LRU进阶之LRU-K和2Q

参考文章:
https://www.cnblogs.com/dolphin0520/p/3749259.html
https://juejin.im/post/5e8b3085f265da47c15cb8bb
https://baike.baidu.com/item/页面置换算法/7626091?fr=aladdin


TheWalkingFat
522 声望32 粉丝