Go语言进阶之路:手撸一个LRU缓存

一 什么是LRU缓存

LRU(英文:Least Recently Used),中文叫做最近最少使用缓存。也就是说,我们设计一个固定最大容量的缓存,当达到最大容量之后,我们再往缓存里放数据时,会先把最近最少使用的那个元素删除,再放入最新的元素。

 

为什么要设计“固定最大容量的缓存”?在生产环境,每一份内存都是非常宝贵的,如果不设置最大容量上限,元素无休止的增长,总缓存大小势必会达到系统阈值或者内存大小,容易抛出OutOfMemoryError。

二 为什么我们要自己实现LRU缓存

很多语言里面没有开箱即用的LRU缓存工具。包括Java、Python、Go等等。最像LRU缓存的是Java中的LinkedHashMap,它是由HashMap实现的,里面的元素通过链表串起来,但是无法设置最大容量,不断往里面放元素还是会导致它无限制增长。

三 要实现LRU缓存,我们的诉求是什么

从上面的说法,我们得出,我们的LRU缓存需要具备:

  1. 快速存取元素。
  2. 有固定的最大容量,不会无限制增长。
  3. 达到最大容量后,再往缓存里面放入新元素时,会先把最近最少使用的元素删除,再放入新元素。

四 怎么来实现

有了诉求只有,我们的目的很明确,接下来就是如何实现了。

  1. 第一点诉求,快速存取。我们使用map(哈希表、映射)就可以了。
  2. 第二点诉求,固定最大容量。我们给这个map增加capacity(整数)字段来表示最大容量,当map中元素个数达到capacity时,我们要继续往里面放入新元素,则先把最近最少使用的元素删除再放入新元素。
  3. 第三点诉求,删除最近最少使用的元素。怎么找到最近最少使用的元素删除?这个也很简单,参考Java中的LinkedHashMap的做法,把map中的元素使用双向链表串起来,把最近最少使用的元素放在队列尾部,最近最常使用的元素放在头部就可以了。

4.1 设计Entry

我们知道,元素要放入map里面,那我们元素必须具备key和value字段。元素要用双向链表串起来,那元素必须具备pre和next字段。我们的Entry设计如下:

type Entry struct {
  Key   string
  Value interface{}
  pre   *Entry
  next  *Entry
}

4.2 设计缓存

Entry设计完成,我们开始设计缓存。我们自定义缓存类型,里面使用map来存放缓存的元素,capacity就是我们所说的最大容量。

type Cache struct {
  cache    map[string]*Entry
  capacity int
  head     *Entry
  tail     *Entry
}

为什么缓存里面需要head和tail指针?这是因为,我们把元素都用双向链表串起来之后,放入新元素和获取元素时,我们要把最近最常使用的元素放在队列头部,最近最少使用的元素从队列尾部删除,所以需要头尾指针帮我们实现这些操作。

 

我们新增NewCache方法,让外部通过这个方法来创建LRU缓存,同时传入最大容量:

func NewCache(cap int) *Cache {
  return &Cache{cache: make(map[string]*Entry), capacity: cap}
}

同时,我们还要加把锁,防止并发修改的时候出现问题。

var lock sync.Mutex

4.2.1 往缓存中放元素

我们往缓存中放元素,有两种情况需要分析:

  1. 元素在缓存中已经存在了,则把元素的Value更新为我们传入的Value,把元素当做最近最常使用的元素,提到队列头部。
  2. 元素在缓存中不存在,则把元素直接放到队列头部。

第一种情况,元素已经存在

第一种情况很好处理,先判断存不存在,存在的话提到队列头部:

if existVal, exist := cache.cache[key]; exist {
  // 元素存在,下面把元素放到最前面去
  if existVal == cache.head {
    // 元素已经在最前面了,什么都不用做
    return
  }
  // 元素不在最前面,那么当前元素的前一个元素一定不为nil
  existVal.pre.next = existVal.next
  if existVal == cache.tail {
    cache.tail = existVal.pre
  } else {
    existVal.next.pre = existVal.pre
  }
  existVal.pre = nil
  existVal.next = cache.head
  cache.head.pre = existVal
  cache.head = existVal
  return
}

重点看下面那一大段,当元素不在队列最前面时,需要把当前元素剥离,放在队列最前面去。

剥离元素,即,需要把这样:

变成这样:

对应的是这一段代码:

// 元素不在最前面,那么当前元素的前一个元素一定不为nil
existVal.pre.next = existVal.next
if existVal == cache.tail {
  cache.tail = existVal.pre
} else {
  existVal.next.pre = existVal.pre
}
existVal.pre = nil
existVal.next = cache.head

这里需要注意判断当前元素是不是队列最后一个元素,如果是, 需要把tail指针往前移。剥离完成之后,把existVal的next设置为head,head.pre设置为existVal,再把head设置为最新的头部元素,即existVal就可以了。

 

第二种情况,元素不存在

元素不存在的情况很好处理,直接把元素放到队列头部,元素放入缓存就可以了。

但是这里又有两种情况,第一种,当放入新元素之后,缓存元素个数还没达到最大容量,直接放入就好了。也就是下面这种情况。

放入之后,把head设置为当前元素。

对应代码如下:

e := &Entry{Key: key, Value: val, next: cache.head}
if cache.head != nil {
  // head不为空,把当前元素e设置为head的前一个元素
  cache.head.pre = e
}
队列head有变化,head设置为当前元素e
cache.head = e
if cache.tail == nil {
  // 如果tail指针为空,说明队列刚刚就是空的,现在只有e一个元素,tail指针设置为e就可以了
  cache.tail = e
}
// 队列操作完毕,把元素e放入缓存中
cache.cache[key] = e

if len(cache.cache) <= cache.capacity {
  // 元素e放入缓存中后,元素个数还没有达到最大容量,放入元素到此结束。
  return nil
}

另一种情况,当放入新元素之后,缓存元素个数达到最大容量,需要把队列尾部的元素删掉。

把tail指针的元素剥离,tail指针前移即可。对应代码如下:

removedEntry := cache.tail
cache.tail = cache.tail.pre
removedEntry.pre = nil
cache.tail.next = nil
delete(cache.cache, removedEntry.Key)
return removedEntry.Value

4.2.2 从缓存中获取元素

获取元素比放入元素要简单,获取元素时,如果元素不存在,直接返回nil就可以。如果元素存在,把元素当做最近最常使用的元素,提到队列头部。

即,把当前元素剥离队列,再放入头部即可:

对应代码如下:

if existVal, exist := cache.cache[key]; exist {
  // 把该元素提到队列头部
  // 元素存在,下面把元素放到最前面去
  if existVal == cache.head {
    return
  }
  // 元素不是head,那么existVal.pre一定不为nil
  existVal.pre.next = existVal.next
  if existVal == cache.tail {
    cache.tail = existVal.pre
  } else {
    existVal.next.pre = existVal.pre
  }
  existVal.pre = nil
  existVal.next = cache.head
  cache.head.pre = existVal
  cache.head = existVal
  return existVal.Value
}
return nil

五 测试一下

测试代码如下:

func TestLRU(t *testing.T) {
  cache := NewCache(2)
  cache.Put("1", "one")  // 放入元素one,此时one在队列头部
  fmt.Println(cache.Get("1"))  // 此处输出“one”,此时one在队列头部
  cache.Put("2", "two")  // 放入元素two,此时two在队列头部
  fmt.Println(cache.Get("1"))  // 此处输出“one”,此时one在队列头部
  cache.Put("3", "three")  // 放入元素three,总元素个数为3,因此最近最少使用的元素“2”会被删除
  fmt.Println(cache.Get("2"))  // 此处输出nil
  fmt.Println(cache.Get("3"))  // 此处输出“three”
  fmt.Println(cache.Get("3"))  // 此处输出“three”
  fmt.Println(cache.Get("1"))  // 此处输出“one”,此时最近最少使用的元素为“3”
  cache.Put("2", "two")  // 放入元素three,总元素个数为3,因此最近最少使用的元素“3”会被删除
  fmt.Println(cache.Get("3"))  // 此处输出nil
  fmt.Println(cache.Get("1"))  // 此处输出one
}

运行一下看看输出:

=== RUN   TestLRU
one
one
<nil>
three
three
one
<nil>
one
--- PASS: TestLRU (0.00s)
PASS

跟我们预期的一致。

 

源码在Github:https://github.com/ychenracing/GoApps/blob/master/src/LRUCache/LRU.go

喜欢的点个Star。

 

喜欢的可以关注我的WeiXin订阅号,随时随地学习:

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值