【若川视野 x 源码共读】第29期 | quick-lru

750 阅读12分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。LRU是什么?有什么用?读完本文你将了解LRU的概念、原理和执行过程并掌握quick-lru的具体实现,代码调试方法,读源码的技巧等。

1.LRU是什么?

LRU是Least(最少) Recently (最近)Used的缩写,即最近最少使用。
PS:副词修饰形容词,副词翻译在前面
LRU是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。

最早接触LRU这个词是在读大三学习《计算机操作系统》这门课的时候,LRU是操作系统内存管理的一种算法,书毕业时就卖给学弟了,先借助百度回顾一下:

2.操作系统中的LRU

百度百科的介绍:

https://baike.baidu.com/item/LRU/1269842?fr=aladdin

上文中涉及到几个图可以方便我们了解LRU的思想:

2.1第一个图

如上图所示,存储空间只能放3个页,LRU算法执行过程如下:

(1)当引用页面7时,没命中(没在存储空间中),存储空间有位置,放进去;

(2)当引用页面0时,没命中(没在存储空间中),存储空间有位置,放进去;

(3)当引用页面1时,没命中(没在存储空间中),存储空间有位置,放进去;

(4)当引用页面2时,没命中(没在存储空间中),存储空间没有位置(专业术语叫“缺页”),由于7是最近没有用的,把它置换出去,2放进去;

(5)当引用页面0时,命中了(在存储空间中),页面0属于最近被使用的;

(6)当引用页面3时,没命中(没在存储空间中),存储空间没有位置,页面0和2是最近被使用的,页面1是最近没被使用的,把它置换出去,3放进去。其他以此类推...

2.2 第二个图

可利用一个特殊的栈来保存当前使用的各个页面的页面号。每当进程访问某页面时,便将该页面的页面号从栈中移出,将它压入栈顶。因此,栈顶始终是最新被访问页面的编号,而栈底则是最近最久未使用页面的页面号。则LRU算法执行过程如下:

(1)引用页面4时,没命中,存储空间有位置,放进去;

(2)引用页面7时,没命中,存储空间有位置,放进去,7是最近被访问的,放在栈顶;

(3)引用页面0时,没命中,存储空间有位置,放进去,0是最近被访问的,放在栈顶;

(4)引用页面7时,命中了,7是最近被访问的,放在栈顶;

(5)引用页面1时,没命中,存储空间有位置,放进去,1是最近被访问的,放在栈顶;

(6)引用页面0时,命中了,0是最近被访问的,放在栈顶;

(7)引用页面1时,命中了,1是最近被访问的,放在栈顶;

(8)引用页面2时,没命中,存储空间有位置,放进去,2是最近被访问的,放在栈顶;

(9)引用页面1时,命中了,1是最近被访问的,放在栈顶;

(10)引用页面2时,命中了,1是最近被访问的,放在栈顶;

(11)引用页面1时,没命中,存储空间没位置(缺页了),把栈底的4(最近最不经常访问的)置换出去,6放进去,6是最近被访问的,放在栈顶;

2.3 思考总结

经过上面两次模拟LRU算法的执行过程我们发现你采用不同的数据结构那么在具体实现上就会有差异,但是无论怎么做都避不开以下两个问题:

1.怎么知道访问的页面在不在存储空间中,应该用什么数据结构?
2.怎么标识谁是最近被访问的,应该用什么数据结构?如何设计?

如果我们自己写一个LRU算法,如何实现呢?刚好算法刷题网站力扣中有一道手撕LRU的中等难度题目:

3.LeetCode LRU

题目在力扣上很容易找到(leetcode-cn.com/problems/lr…),这里选了两个不错的题解,可以对照题解学习一下,:

参考题解:
解法1https://leetcode-cn.com/problems/lru-cache/solution/by-smooth-b-9bh4/
解法2https://leetcode-cn.com/problems/lru-cache/solution/bu-yong-yu-yan-nei-jian-de-map-gua-dang-feng-zhuan/

解释一下解法1中说到的map的特点:

Map 这个 ES6 新增数据结构是 有序的,即不仅可以存储键值对(关键字-值),
还可以通过 delete 和 set 的 API 对数据进行更新,最后 set 的数据默认在 Map 的末尾

测试代码如下:

let map = new Map()
map.set('a', 1)
map.set('b', 2)
console.log(map)
map.delete('a')
map.set('a', 1)
console.log(map)

代码运行结果:

看懂这两个题解,我们自己写一个LRU应该不费劲了,但是“懒惰”的程序员总喜欢用现成的,下面介绍的是一个用JS写的npm包:quick-lru

4.quick-lru源码解读

github地址:github.com/sindresorhu…

4.1quick-lru介绍

官方readme文件介绍:简单的LRU缓存,当您需要缓存某些内容并限制内存使用时,非常有用。灵感来自hashlru算法,但是使用Map来支持任何类型的键,而不仅仅是字符串,而且值可以是undefined。

安装和使用:

npm install quick-lru
import QuickLRU from 'quick-lru';

const lru = new QuickLRU({maxSize: 1000});

lru.set('🦄', '🌈');

lru.has('🦄');
//=> true

lru.get('🦄');
//=> '🌈'

API介绍:

4.2源码解析

先看一下整体的结构(省略所有方法的实现细节):

export default class QuickLRU extends Map {
  constructor(options = {}) {}
  _emitEvictions(cache) {}
  _deleteIfExpired(){}
  _getOrDeleteIfExpired(){}
  _getItemValue(){}
  _peek(){}
  _set(){}
  _moveToRecent(){}
  * _entriesAscending() {}
  get(key) {}
  set(key, value, {maxAge = this.maxAge} = {}) {}
  has(key) {}
  peek(key) {}
  delete(key) {}
  clear() {}
  resize(newSize) {}
  * keys() {}
  * values() {}
  * entriesDescending() {}
  * entriesAscending() {}
  get size() {}
  entries() {}
  forEach(callbackFunction, thisArgument = this) {}
  get [Symbol.toStringTag]() {}
}

QuickLRU继承了Map了,有constructor构造方法,_开头的私有方法,还有调用私有方法的实例方法,下面分析一下这些方法。

4.2.1 构造方法constructor

constructor(options = {}) {
  super();

  if (!(options.maxSize && options.maxSize > 0)) {
    throw new TypeError('`maxSize` must be a number greater than 0');
  }

  if (typeof options.maxAge === 'number' && options.maxAge === 0) {
    throw new TypeError('`maxAge` must be a number greater than 0');
  }

  // TODO: Use private class fields when ESLint supports them.
  this.maxSize = options.maxSize;
  this.maxAge = options.maxAge || Number.POSITIVE_INFINITY;
  this.onEviction = options.onEviction;
  this.cache = new Map();
  this.oldCache = new Map();
  this._size = 0;
}

构造方法做初始化工作,默认的options参数是空对象。判断是否定义了大于0的maxSize,如果没有传或者不大于0则报错;判断是否定义了maxAge,如果定义了但是却等于0则报错;接着是根据options初始化属性。

思考:

笔者认为对maxSize的要求不应该仅限于是大于0的number,而应该是正整数。所以在判断时应该严格判断一下或者在给this.maxSize赋值时paseInt一下。

4.2.2 _emitEvictions(cache)从cache中移除所有元素

_emitEvictions(cache) {
  if (typeof this.onEviction !== 'function') {
    return;
  }

  for (const [key, item] of cache) {
    this.onEviction(key, item.value);
  }
}

从cache缓存参数中移除所有元素会调用此方法,旨在触发用户传的onEviction方法。如果onEviction不是函数则返回,否则对cache中的每一个元素触发一次onEviction方法。

调用_emitEvictions()方法的有_set()方法和resize()方法,具体调用时机见这两个方法的分析。

4.2.3 _deleteIfExpired(key, item)删除过期元素

_deleteIfExpired(key, item) {
  if (typeof item.expiry === 'number' && item.expiry <= Date.now()) {
    if (typeof this.onEviction === 'function') {
      this.onEviction(key, item.value);
    }

    return this.delete(key);
  }

  return false;
}

检查过期时间,如果过期时间为数字并且小于等于当前时间说明元素已经过期可以删除;检查用户是否传onEviction函数了,如果传了则调用;最后调用delete()方法删除元素。

_deleteIfExpired()在主要在那些迭代函数中调用,如 entriesDescending(),_entriesAscending() 等。

4.2.4 _getOrDeleteIfExpired(key, item)获取元素或者删除过期元素

_getOrDeleteIfExpired(key, item) {
  const deleted = this._deleteIfExpired(key, item);
  if (deleted === false) {
    return item.value;
  }
}

获取或者删除过期元素,检查元素是否过期,如果过期则删除元素,如果没有过期则返回元素。

4.2.5 _getItemValue获取元素的值

_getItemValue(key, item) {
  return item.expiry ? this._getOrDeleteIfExpired(key, item) : item.value;
}

用于获取元素的值,先判断是否为元素设置了过期时间,如果设置了则检查是否过期否则返回元素的值。

4.2.6 _peek(key, cache)获取元素的值

_peek(key, cache) {
  const item = cache.get(key);

  return this._getItemValue(key, item);
}

通过调用_getItemValue实现的。

4.2.7 _set(key, value)设置元素放入cache

_set(key, value) {
  this.cache.set(key, value);
  this._size++;

  if (this._size >= this.maxSize) {
    this._size = 0;
    this._emitEvictions(this.oldCache);
    this.oldCache = this.cache;
    this.cache = new Map();
  }
}

设置元素的这个方法比较重要,涉及到cache和oldCache。二者之间如何配合使用呢?设置元素的时候会优先向cache中设置,然后检查_size是否大于等于maxSize,如果是则需要先调用_emitEvictions移除oldCache中所有的元素然后将oldCache赋值为cache,最后cache更新为一个新的Map.

4.2.8 _moveToRecent(key, item)

_moveToRecent(key, item) {
  this.oldCache.delete(key);
  this._set(key, item);
}

把元素设置为最近使用的。先从oldCache中删除这个元素,然后调用_set放入cache中。

4.2.9 * _entriesAscending()迭代元素

* _entriesAscending() {
  for (const item of this.* _entriesAscending()) {
    const [key, value] = item;
    if (!this.cache.has(key)) {
      const deleted = this._deleteIfExpired(key, value);
      if (deleted === false) {
        yield item;
      }
    }
  }

  for (const item of this.cache) {
    const [key, value] = item;
    const deleted = this._deleteIfExpired(key, value);
    if (deleted === false) {
      yield item;
    }
  }
}

是一个生成器函数,优先返回oldCache中没过期的元素,其次是cache中没过期的元素。

4.2.10 get(key)获取元素

get(key) {
  if (this.cache.has(key)) {
    const item = this.cache.get(key);

    return this._getItemValue(key, item);
  }

  if (this.oldCache.has(key)) {
    const item = this.oldCache.get(key);
    if (this._deleteIfExpired(key, item) === false) {
      this._moveToRecent(key, item);
      return item.value;
    }
  }
}

获取元素,优先从cache中获取,如果cache中不存在则从oldCache中获取,但是要将这个元素移入到cache中。

4.2.11 set(key, value, {maxAge = this.maxAge} = {})设置元素

set(key, value, {maxAge = this.maxAge} = {}) {
  const expiry =
        typeof maxAge === 'number' && maxAge !== Number.POSITIVE_INFINITY ?
        Date.now() + maxAge :
  undefined;
  if (this.cache.has(key)) {
    this.cache.set(key, {
      value,
      expiry
    });
  } else {
    this._set(key, {value, expiry});
  }
}

用于设置元素,首先设置到期日期,判断参数maxAge是否为number类型并且不是无穷大则将到期日期设置为当前时间加上maxAge;然后判断cache中是否已经存在元素如果已经存在则更新否则调用_set设置元素。

4.2.12 has(key)是否含有元素

has(key) {
  if (this.cache.has(key)) {
    return !this._deleteIfExpired(key, this.cache.get(key));
  }

  if (this.oldCache.has(key)) {
    return !this._deleteIfExpired(key, this.oldCache.get(key));
  }

  return false;
}

优先在cache中搜索,其次是oldCache。有对应元素,还要看有没有过期。

4.2.13 peek(key)获取元素

peek(key) {
  if (this.cache.has(key)) {
    return this._peek(key, this.cache);
  }

  if (this.oldCache.has(key)) {
    return this._peek(key, this.oldCache);
  }
}

获取元素但是不标记为最近使用,通过调用_peek()方法实现,也是优先从cache中取其次是oldCache。

4.2.14 delete(key)

delete(key) {
  const deleted = this.cache.delete(key);
  if (deleted) {
    this._size--;
  }

  return this.oldCache.delete(key) || deleted;
}

删除元素,优先从cache中删除,其次是oldCache。

4.2.15 clear()清空缓存

clear() {
  this.cache.clear();
  this.oldCache.clear();
  this._size = 0;
}

将cache和oldCache都清除,_size重置为0。

4.2.16 resize(newSize)调整缓存大小

resize(newSize) {
  if (!(newSize && newSize > 0)) {
    throw new TypeError('`maxSize` must be a number greater than 0');
  }

  const items = [...this._entriesAscending()];
  const removeCount = items.length - newSize;
  if (removeCount < 0) {
    this.cache = new Map(items);
    this.oldCache = new Map();
    this._size = items.length;
  } else {
    if (removeCount > 0) {
      this._emitEvictions(items.slice(0, removeCount));
    }

    this.oldCache = new Map(items.slice(removeCount));
    this.cache = new Map();
    this._size = 0;
  }

  this.maxSize = newSize;
}

首先检查参数是否是大于0的数,否则抛出错误。调用this._entriesAscending()获取所有的元素,比较元素数目和newSize的大小,二者做差值。如果小于0说明,说明newSize大,保留items都存进cache中,oldCache清空。如果差值大于0说明newSize小,要删除元素,则把剩余元素放入oldCache,清空cache。

4.2.17* keys()和* values()获取key和value

* keys() {
  for (const [key] of this) {
    yield key;
  }
}

* values() {
  for (const [, value] of this) {
    yield value;
  }
}

keys和values都是生成器函数,分别用于获取所有的key和value。类似的生成器函数还有* entriesDescending() 、* entriesAscending() 和entries()等,不在赘述。

4.2.18 size获取缓存大小

get size() {
  if (!this._size) {
    return this.oldCache.size;
  }

  let oldCacheSize = 0;
  for (const key of this.oldCache.keys()) {
    if (!this.cache.has(key)) {
      oldCacheSize++;
    }
  }

  return Math.min(this._size + oldCacheSize, this.maxSize);
}

如果_size为假值则返回oldCache大小,否则检查oldCache中的元素是否在cache中不存在,如果不存在则说明这些元素是cache中没有的,最后也要算到_size+oldCacheSize的总和之中,最后返回这个总和和maxSize二者之间的最大值。

4.2.19 forEach(callbackFunction, thisArgument = this) 迭代元素

forEach(callbackFunction, thisArgument = this) {
  for (const [key, value] of this.entriesAscending()) {
    callbackFunction.call(thisArgument, value, key, this);
  }
}

对每个元素依次调用callbackFunction方法。

4.2.20 get Symbol.toStringTag

Symbol.toStringTag 是一个内置 symbol,它通常作为对象的属性键使用,对应的属性值应该为字符串类型,这个字符串用来表示该对象的自定义类型标签。Symbol.toStringTag详见developer.mozilla.org/zh-CN/docs/…

get [Symbol.toStringTag]() {
  return JSON.stringify([...this.entriesAscending()]);
}

4.3调试过程

测试用例是test.js (quick-lru/test.js),以两个测试用例的执行过程来加深对quick-lru的理解,关于如何调试可以参考川哥的文章(juejin.cn/post/703058…)。

4.3.1 第一个测试用例

test('.get() / .set()', t => {
	const lru = new QuickLRU({maxSize: 100});
	lru.set('foo', 1);
	lru.set('bar', 2);
	t.is(lru.get('foo'), 1);
	t.is(lru.size, 2);
});

测试用例执行到lru.set('foo', 1)时会调用set方法,此时key为foo, value为1,由于没有指定maxAge所以maxAge值为Infinity,如下图:

执行set方法时又会调用_set方法,此时cache和oldCache都为空的map ,如下图:

执行_set时, cache执行set,参数key为foo,值为由value和expiry组成的对象,_size加1.

执行lru.set('bar', 2)时,最终也会调用_set方法,设置cache, _size加1值为2,由于maxSize为100,所以接下来的逻辑不再执行。

4.3.2 第二个测试用例

test('.get() - limit', t => {
	const lru = new QuickLRU({maxSize: 2});
	lru.set('1', 1);
	lru.set('2', 2);
	t.is(lru.get('1'), 1);
	t.is(lru.get('3'), undefined);
	lru.set('3', 3);
	lru.get('1');
	lru.set('4', 4);
	lru.get('1');
	lru.set('5', 5);
	t.true(lru.has('1'));
});

当执行到lru.set('2', 2)时,cache中元素个数已经达到maxSize, 所以if语句的条件成立,会执行分支内的语句,如下图所示:

执行if分支语句内的逻辑时,cache中的元素会赋值给oldCache,同时将cache设置为空的Map,如下图所示:

当执行t.is(lru.get('1'), 1)时,调用get方法,首先在cache中查找,由于cache已经为空,所以继续在oldCache中查找,如下图所示:

从oldCache中找到,就说明这个元素被访问了,那么要把它移入到cache中,代表最近访问它了,这是通过_moveToRecent函数实现的,如下图所示:

执行完_moveToRecent函数之后,1对应的元素被移入了cache,此时cache和oldCache中各一个元素:

执行 t.is(lru.get('3'), undefined);会调用get方法,获取3对应的元素,在cache和oldCache中都没有查找到,返回undefined,如下图所示:

执行lru.set('3', 3)时,也会最终调用_set方法,首先将值设置到cache中,此时cache中有两个元素,oldCache中有一个元素, 如下图所示:

_set接着往下执行,此时_size大于maxSize所以oldCache赋值为cache, cache初始化为新的map, 如下图所示:

5.总结

本文首先回顾了LRU的概念,然后结合图示演示了LRU的执行过程,为了加强对LRU的理解又推荐了力扣上的一道编程题,最后结合quick-lru的源码和对其测试用例的调试加深了对LRU的理解。在前面曾经提到,LRU算法要面对两个问题:

1.怎么知道访问的页面(或者数据)在不在存储空间中,应该用什么数据结构?
2.怎么标识谁是最近被访问的,应该用什么数据结构?如何设计?

以quick-lru算法为例回答以上两个问题:

1.使用Map数据结构缓存数据,调用get(key)方法检查key对应的元素是否在Map中;

2.quick-lru使用了两个Map,一个叫cache,另一个叫oldCache;cache用于存储刚刚设置到缓存中的元素和最近被访问的元素;当cache中元素个数超过最大储存限制maxSize时,cache中的全部元素都放到oldCache,cache本身清空;另一方面,当获取元素时优先从cache中获取,其次是oldCache,如果在oldCache中找到则需要把元素移入到cache中。