【源码阅读】lru-cache

337 阅读5分钟

前言

阅读源码的分支是 7.17.0

源码传送门:github.com/isaacs/node…

LRU 算法介绍

LRU(Least Recently Used)即最近最少使用,是一种页面置换算法。根据历史访问记录来淘汰数据,数据最近被访问,那么将来被访问的几率就更高;可用于缓存页面、缓存接口等。

实现

使用双向链表实现插入删除和 Map 实现查找

class Node {
    key = null
    value = null
    prev = null
    next = null

    constructor (key, value) {
        this.key = key
        this.value = value
        this.prev = null
        this.next = null
    }
}

class LRUCache {
    head = null
    tail = null
    map = null
    capacity = 0
    size = 0

    constructor (capacity) {
        this.head = new Node(0, 0)
        this.tail = new Node(0, 0)
        
        this.head.next = this.tail
        this.tail.prev = this.head
        
        this.map = new Map()
        this.capacity = capacity
    }

    /* 链表尾部增加节点 */
    _addLast = (node) => {
        node.prev = this.tail.prev
        node.next = this.tail
        this.tail.prev.next = node
        this.tail.prev = node

        ++this.size
    }

    /* 删除节点 */
    _remove = (node) => {
        node.prev.next = node.next
        node.prev.prev = node.prev

        --this.size
    }

    /* 删除第一个节点 */
    _removeFirst = () => {
        if (this.head.next === this.tail) return null

        const first = this.head.next
        this._remove(first)
        return first
    }

    /* 将某个 key 提升为最近使用的 */
    _makeRecently = (key) => {
        const node = this.map.get(key)

        this._remove(node)
        this._addLast(node)
    }

    /* 添加最近使用的元素 */
    _addRecently = (key, value) => {
        const node = new Node(key, value)

        this._addLast(node)
        this.map.set(key, node)
    }

    /* 删除某一个 key */
    _deleteKey = (key) => {
        const node = this.map.get(key)

        this._remove(node)
        this.map.delete(key)
    }

    /* 删除最久未使用的元素 */
    _removeLeastRecently = () => {
        const node = this._removeFirst()

        this.map.delete(node.key)
    }

    /* 链表长度 */
    getSize = () => {
        return this.size
    }

    get = (key) => {
        if (!this.map.has(key)) return -1

        this._makeRecently(key)
        return this.map.get(key).value
    }

    put = (key, value) => {
        if (this.map.has(key)) {
            this._deleteKey(key)
            this._addRecently(key, value)
            return
        }

        if (this.capacity === this.getSize()) {
            this._removeLeastRecently()
        }
        this._addRecently(key, value)
    }
}

lru-cache 介绍

一个可以在跨平台使用且关于LRU实现的缓存对象,不侧重于 TTL 缓存;在 v7 版本后,是 JavaScript 中性能高的 LRU 实现之一。

基础用法

import LRUCache from 'lru-cache'

const cache = new LRUCache({ max: 500 })
cache.set('key', 'value')
cache.get('key')

源码

使用 class 语法糖,在 constructor 函数中,对一系列内部变量进行初始化及参数的校验。

// UintArray = Uint8Array \ Unit16Array \ Unit32Array \ ZeroArray
// constructor 函数
this.keyList = new Array(max).fill(null)
this.valList = new Array(max).fill(null)
this.next = new UintArray(max)
this.prev = new UintArray(max)
this.initialFill = 1
  1. 缓存键、值分别使用数组构造函数初始化存储。个人理解是相对于使用字面量更节省内存,直接使用数组下标来改变数组的元素,而不是使用 push
  2. 每个节点指向前节点和后节点的 “指针” 分别使用 n 位无符号整数数组存储(一次性分配连续内存块;避免在存储量级大数据时,增加下标内存动态分配造成的消耗)
  3. 不使用 哈希表 和 双端队列 实现,并且键、值、值的上节点、值的下节点分别用数组存储

set(key, value, [{ size, sizeCalculation, ttl, noDisposeOnSet, start }])

加入一个缓存数据。分为两种情况:缓存对象里面已存在相同 key 、增加新缓存数据。

增加新缓存数据
// set add
set(
    k,
    v,
    {
      ttl = this.ttl,
      start,
      noDisposeOnSet = this.noDisposeOnSet,
      size = 0,
      sizeCalculation = this.sizeCalculation,
      noUpdateTTL = this.noUpdateTTL,
    } = {}
  ) {
    // 数据项超过给定的总体积时删除
    size = this.requireSize(k, v, size, sizeCalculation)
    if (this.maxEntrySize && size > this.maxEntrySize) {
      this.delete(k)
      return this
    }
    // 获取可插入的索引
    index = this.newIndex()
    this.keyList[index] = k
    this.valList[index] = v
    this.keyMap.set(k, index)
    this.next[this.tail] = index
    this.prev[index] = this.tail
    this.tail = index
    this.size++
    this.addItemSize(index, size)
    noUpdateTTL = false
    // ...
    
newIndex() {
    if (this.size === 0) {
      return this.tail
    }
    // 设置的最大个数已满
    if (this.size === this.max && this.max !== 0) {
      // 收回最近最少使用的内部ID
      return this.evict(false)
    }
    // 重复利用被删除的内部ID
    if (this.free.length !== 0) {
      return this.free.pop()
    }
    return this.initialFill++
}

evict(free) {
    const head = this.head
    const k = this.keyList[head]
    const v = this.valList[head]
    if (this.isBackgroundFetch(v)) {
      v.__abortController.abort(new Error('evicted'))
    } else {
      this.dispose(v, k, 'evict')
      if (this.disposeAfter) {
        this.disposed.push([v, k, 'evict'])
      }
    }
    this.removeItemSize(head)
    // if we aren't about to use the index, then null these out
    if (free) {
      this.keyList[head] = null
      this.valList[head] = null
      this.free.push(head)
    }
    this.head = this.next[head]
    this.keyMap.delete(k)
    this.size--
    return head
  }
  1. newIndex 方法是获取新增数据的数组填充下标。当已有数据个数等于初始化最大数据个数时,进入到 evict
  2. evict 方法获取最近最少使用的数据数组下标。首先使用 isBackgroundFetch 判断是否该数据正在从后台获取,如果是中止并抛出事件(前提是已使用 cache.fetch)。否则调用 dispose,这是删除数据项时的回调函数,可由用户覆盖。而 disposeAfter 则是在完全删除数据项后调用
  3. free.pop 的一种情况是 delete 时使用栈回收相关数组的索引下标,后续 set 等操作就可以从中获取
isBackgroundFetch(p) {
    return (
      p &&
      typeof p === 'object' &&
      typeof p.then === 'function' &&
      Object.prototype.hasOwnProperty.call(
        p,
        '__staleWhileFetching'
      ) &&
      Object.prototype.hasOwnProperty.call(p, '__returned') &&
      (p.__returned === p || p.__returned === null)
    )
  }

Object.prototype.hasOwnPropertyisBackgroundFetch 参数可能是使用 Object.create(null) 创建或者 hasOwnProperty 被覆盖。

// set
this.next[this.tail] = index
this.prev[index] = this.tail
this.tail = index
  1. next 存储指向下个节点的指针;ID=0(理解成数组的第一个元素)对应的是 valList 第一个元素的下个节点 ID(理解成数组下标索引),其它的以此类推。最后一个节点的下个节点 ID 指向 0
  2. prev 索引0 存放的是 valList 第一个元素的上节点 ID=0,恒为 0;索引2 是 valList 第二个元素的上节点 ID,指向第一个元素的下标,为0
  3. tail 指向最近最多使用的数据项索引
缓存对象里面已存在相同 key
// set update
this.moveToTail(index)
const oldVal = this.valList[index]
if (v !== oldVal) {
if (this.isBackgroundFetch(oldVal)) {
  oldVal.__abortController.abort(new Error('replaced'))
} else {
  // 重复 set 时调用 dispose
  if (!noDisposeOnSet) {
    this.dispose(oldVal, k, 'set')
    if (this.disposeAfter) {
      this.disposed.push([oldVal, k, 'set'])
    }
  }
}
this.removeItemSize(index)
this.valList[index] = v
this.addItemSize(index, size)

// moveToTail
if (index !== this.tail) {
  // 更新值的索引位于头部
  if (index === this.head) {
    this.head = this.next[index]
  } else {
    // 更新当前节点上节点的 next 指针、下节点的 prev 指针
    this.connect(this.prev[index], this.next[index])
  }
  // 更新当前节点的 next 指针、prev 指针
  this.connect(this.tail, index)
  this.tail = index
}

connect(p, n) {
  this.prev[n] = p
  this.next[p] = n
}

get(key, { updateAgeOnGet, allowStale } = {}) => value

获取缓存数据项

const index = this.keyMap.get(k)
if (index !== undefined) {
  const value = this.valList[index]
  const fetching = this.isBackgroundFetch(value)
  // 当前分支 isStale 方法总返回 false
  if (this.isStale(index)) {
    if (!fetching) {
      if (!noDeleteOnStaleGet) {
        this.delete(k)
      }
      return allowStale ? value : undefined
    } else {
      return allowStale ? value.__staleWhileFetching : undefined
    }
  } else {
    if (fetching) {
      return undefined
    }
    this.moveToTail(index)
    if (updateAgeOnGet) {
      this.updateItemAge(index)
    }
    return value
  }
}
  1. fetching = true 正在获取中的且可认为还没过期,返回 undefined

fetch 方法阅读待续...

参考资料

LRU 缓存淘汰算法设计

深入V8 - js数组的内存是如何分配的

有错误或者可以改进的地方请不吝赐教