缓存是我们日常开发中来提高性能最直接的方式,经常会听到有人说:性能不行?是因为你没加缓存!常见的缓存有外部缓存服务以及程序内部缓存,外部缓存服务包括:Redis、Memcached等,内部缓存就是我们可以在程序内使用类似HashMap这种方式来建立缓存,另外比如Web中常见的cdn静态资源缓存等也属于缓存,以及我们计算机中的CPU缓存,文件系统缓存等都不约而同采用相同的思路来加速性能这个指标。缓存的目的很直接就是为了提升性能,是一种空间换时间的思想,既然是缓存,那么总不能把所有的数据全都缓存起来吧?通常根据存储器的层次结构,访问速度越快的硬件单位存储价格越贵,就像CPU L1缓存的访问延时在1ns,比内存快出100倍,大小也就是在32K~128K这个范围,价格也大约是内存的400倍,同样内存的速度比固态盘要快上1500倍,但是价格也贵将近40倍,所以我们看缓存的本质就是尽量用更高成本的存储来提升性能,但是受空间的限制,我们只能尽可能的将最常用的数据缓存下来,这些就是我们通常提的缓存命中率这个概念,比如CPU缓存的缓存命中率可以达到95%或者更高,这样可以极大的提升我们程序的性能。因为我们访问的数据不断变化的,因此缓存也必然处在一个动态变化的过程中,从而保证缓存命中率的相对稳定,所以缓存的数据必须要不断淘汰掉不用的数据同时将常用的添加进去,那么具体应该怎么淘汰呢?对于缓存淘汰的策略,通常有3种:

1.FIFO (First in first out):先进先出的策略,相当于队列,非常简单。

2.LFU (Least Frequently Used):最少使用策略,这个策略的思想是如果一个数据在最近一段时间内使用很少,那么在将来一段时间被使用的可能性也很少,这个策略相当于给每个数据添加使用次数这样的标记,当缓存占满的时候优先淘汰次数少的。

3.LRU (Least Recently Used):最近最少使用策略,按照访问的时间先后作为依据,如果说数据最近被访问过,那么将来被访问的几率也会更高,因此数据相当于按照时间排序,当缓存占满的时候优先淘汰上次使用时间最久远的数据。

我们日常开发最常用的其实就是LRU缓存淘汰策略,由于LRU是按照时间先后顺序,优先淘汰最近最少使用的,那么是否要为每个数据添加一个访问时间的选项?显然这样不太优雅同时每次根据时间排序的复杂度也不低,有没有更好的实现呢?答案是肯定的,我们现在使用的LRU缓存都是基于链表来实现的,最近访问的元素在链表头部,最早访问的元素在链表尾部,实现的逻辑大致如下:

1.插入元素

首先判断数据是否在链表内,如果在的话则需要删除该数据并插入到链表头部,插入元素也相当于一次访问。

如果不再链表内,需要判断缓存是不是满了,也就是链表数据个数是不是达到预定值,如果达到了,首先淘汰掉链表尾部元素,然后将新元素插入到链表头部,否则直接将新元素插入到链表的头部。

2.读取元素

判断数据是否在链表内,如果不再直接返回空也就是告诉调用者此元素不存在;如果在链表内则需要删除该数据节点,然后插入到链表头部,表示最近访问过。

上面是大致的逻辑,总体来说很简单,几句话就可以说清楚,那么接下来我们简单实现一下:

class KVNode:
def __init__(self, k, v):
self.key = k
self.value = v
self.next = None def __str__(self) -> str:
return "Node({},{})".format(self.key, self.value) class LruCache:
def __init__(self, capacity: int):
self.cap = capacity
self.size = 0
# header 哨兵
self.header = KVNode(0, 0) def __insert_to_tail(self, key, value):
"""在链表尾部插入元素
"""
if self.size >= self.cap:
return node = self.header
while node is not None and node.next is not None:
node = node.next node.next = KVNode(key, value)
self.size += 1 def __insert_to_head(self, key, value):
"""插入元素到链表头部
"""
if self.size >= self.cap:
return node = KVNode(key, value)
node.next = self.header.next
self.header.next = node
self.size += 1 def get(self, k):
"""按照key获取cache值 O(n)
"""
node = self.header.next
prev = self.header
while node is not None:
if node.key == k:
# 移动元素到头部
prev.next = node.next
node.next = self.header.next
self.header.next = node
# self.__insert_to_head(node)
return node.value prev = node
node = node.next def put(self, k, v):
"""向lru缓存中插入元素 O(n)
"""
node = self.header
prev = None
while node.next is not None:
prev = node
node = node.next
if node.key == k:
# 将节点移动至头部
node.value = v
prev.next = node.next
node.next = self.header.next
self.header.next = node
return # 未找到元素 插入到头部
if self.size == self.cap:
# 淘汰尾部元素
prev.next = None
self.size -= 1 node = KVNode(k, v)
node.next = self.header.next
self.header.next = node
self.size += 1 def __delete_tail(self):
"""删除尾节点
"""
if self.size == 0:
return node = self.header
prev = None
while node.next is not None:
prev = node
node = node.next # 此时prev为倒数第二个节点(有可能为header)
prev.next = None
self.size -= 1 def traverse(self):
"""遍历lru cache并输出
"""
if self.size == 0:
return node = self.header.next while node is not None and node.next is not None:
print(node, end="->")
node = node.next print(node) def clear(self):
"""清空缓存
"""
self.header.next = None
self.size = 0

我们这里简单实现1个支持key,value数据节点的单向链表,然后添加了get/put这两个操作方法,来实现了lru的功能,不过很容易可以发现这样无论是get还是put时间复杂度都是O(n),如果缓存数量很大的话,获取缓存的性能就会比较慢,我们仔细看无论是get还是put都需要遍历链表来判断元素是否存在于链表内,判断元素是否存在这种场景属于典型的等值查找,我们很容易想到可以用hash表或者二叉树来实现,这样复杂度就是O(1)或者O(logn),这里我们选用hash表速度上可以达到最优,这里hash表的key就是缓存的key,hash表的value就是这个节点。不过现在又有个新的问题就是如果元素满了我们要淘汰尾部元素怎么办或者移动元素该怎么办?刚才我们在遍历时可以记录下节点的prev,而现在用hash表就只能拿到next了,这个时候我们会想到使用双向链表,同时保存前驱指针和后继指针,这样就可以方便的实现节点的删除以及尾节点的淘汰了,那么这样查找和删除的问题都解决了,但是空间占用更大了,除了hash表还有双向链表的前驱指针,这样整体的空间复杂度相较于上面来说是O(n),这也是空间换时间的实现方式,好了,那么接下来上代码:

class TwoKVNode:
"""双向链表节点
"""
def __init__(self, k, v) -> None:
self.key = k
self.value = v
self.next = None
self.prev = None def __str__(self) -> str:
return "Node({}, {})".format(self.key, self.value) class LruCachePro:
def __init__(self, capacity: int):
if capacity < 0:
capacity = 0
self.cap = capacity
self.size = 0
# header和tail 哨兵
self.header = TwoKVNode(0, 0)
self.tail = TwoKVNode(0, 0)
self.header.next = self.tail
self.tail.prev = self.header
# key -> Node
self.cache = {} def get(self, k):
"""按照key获取cache值 O(1)
"""
if k not in self.cache:
return node = self.cache[k]
# 移动node到头部
# 删除
node.prev.next = node.next
node.next.prev = node.prev
# 添加
node.prev = self.header
node.next = self.header.next
self.header.next.prev = node
self.header.next = node
return node.value def put(self, k, v):
"""向lru缓存中插入元素 O(n)
"""
if self.cap == 0:
return if k in self.cache:
node = self.cache[k]
# 更新值并移动至头部
node.value = v
# 删除
node.prev.next = node.next
node.next.prev = node.prev
# 添加
node.prev = self.header
node.next = self.header.next
self.header.next.prev = node
self.header.next = node
return # 未找到元素 直接插入到头部
if self.size == self.cap:
# 淘汰尾部元素
del self.cache[self.tail.prev.key]
self.tail.prev.prev.next = self.tail
self.tail.prev = self.tail.prev.prev
self.size -= 1 node = TwoKVNode(k, v)
node.prev = self.header
node.next = self.header.next
self.header.next.prev = node
self.header.next = node
self.cache[k] = node
self.size += 1 def traverse(self):
"""遍历lru cache并输出
"""
if self.size == 0:
return node = self.header.next
while node.next is not None:
print(node, end="->")
node = node.next print("NULL")

重点是双向链表操作这里有点绕,不过没关系细心去写都非常简单,这里类叫做LruCachePro,然后我们可以测试一下两者的性能差距:

#!/usr/bin/env python3
# coding=utf-8
import time from lru_cache import LruCache
from lru_cache import LruCachePro if __name__ == '__main__':
t1 = time.time()
lru = LruCache(10000)
for i in range(1000):
lru.put(str(i), i) t2 = time.time()
print("time1: {:.3f}s".format(t2 - t1)) t1 = time.time()
lru = LruCachePro(10000)
for i in range(10000):
lru.put(str(i), i) t2 = time.time()
print("time1: {:.3f}s".format(t2 - t1))

执行结果如下:

time1: 4.324s
time1: 0.013s

可以看到第二种hash表结合双向链表的实现带来的性能提升非常大。

关于LRU缓存淘汰算法的内容就是上面这些,感谢您的阅读,有问题希望能多多交流

LRU缓存及其实现的更多相关文章

  1. LRU缓存实现(Java)

    LRU Cache的LinkedHashMap实现 LRU Cache的链表+HashMap实现 LinkedHashMap的FIFO实现 调用示例 LRU是Least Recently Used 的 ...

  2. 转: LRU缓存介绍与实现 (Java)

    引子: 我们平时总会有一个电话本记录所有朋友的电话,但是,如果有朋友经常联系,那些朋友的电话号码不用翻电话本我们也能记住,但是,如果长时间没有联系了,要再次联系那位朋友的时候,我们又不得不求助电话本, ...

  3. volley三种基本请求图片的方式与Lru的基本使用:正常的加载+含有Lru缓存的加载+Volley控件networkImageview的使用

    首先做出全局的请求队列 package com.qg.lizhanqi.myvolleydemo; import android.app.Application; import com.android ...

  4. 如何用LinkedHashMap实现LRU缓存算法

    阿里巴巴笔试考到了LRU,一激动忘了怎么回事了..准备不充分啊.. 缓存这个东西就是为了提高运行速度的,由于缓存是在寸土寸金的内存里面,不是在硬盘里面,所以容量是很有限的.LRU这个算法就是把最近一次 ...

  5. 面试挂在了 LRU 缓存算法设计上

    好吧,有人可能觉得我标题党了,但我想告诉你们的是,前阵子面试确实挂在了 RLU 缓存算法的设计上了.当时做题的时候,自己想的太多了,感觉设计一个 LRU(Least recently used) 缓存 ...

  6. Java集合详解5:深入理解LinkedHashMap和LRU缓存

    今天我们来深入探索一下LinkedHashMap的底层原理,并且使用linkedhashmap来实现LRU缓存. 摘要: HashMap和双向链表合二为一即是LinkedHashMap.所谓Linke ...

  7. 04 | 链表(上):如何实现LRU缓存淘汰算法?

    今天我们来聊聊“链表(Linked list)”这个数据结构.学习链表有什么用呢?为了回答这个问题,我们先来讨论一个经典的链表应用场景,那就是+LRU+缓存淘汰算法. 缓存是一种提高数据读取性能的技术 ...

  8. LRU缓存原理

    LRU(Least Recently Used)  LRU是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象. 采用LRU算法的缓存有两种:LrhCache和DisL ...

  9. 链表(上):如何实现LRU缓存淘汰算法?

    一.什么是链表 和数组一样,链表也是一种线性表. 从内存结构来看,链表的内存结构是不连续的内存空间,是将一组零散的内存块串联起来,从而进行数据存储的数据结构. 链表中的每一个内存块被称为节点Node. ...

  10. [Leetcode]146.LRU缓存机制

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

随机推荐

  1. Openssl命令详解 - 密钥篇

    非对称算法密钥生成和公钥提取 # ecc算法 openssl ecparam -out pri.key -name prime256v1 -genkey #生成ec密钥,国密使用-name SM2 o ...

  2. mongo Payload document size is larger than maximum of 16777216. 如何处理

    MongoDB中的文档大小限制为16MB(即16777216字节).如果你遇到Payload document size is larger than maximum of 16777216的错误,意 ...

  3. 适用mybatis和jpa的全数据库类型主键生成插件,分布式高并发有序id生成器

    适用mybatis和jpa的全数据库类型主键生成插件,分布式高并发有序id生成器

  4. RGB、HSV、HSL和CMYK颜色空间

    目录 简介 RGB(红绿蓝)颜色空间 HSV(色调.饱和度.亮度)颜色空间 HSL(色调.饱和度.亮度)颜色空间 CMYK(青.品红.黄.黑)颜色空间 简介 这四种颜色空间在不同的应用领域有不同的用途 ...

  5. vue入门教程之-插槽

    vue入门教程之-插槽 欢迎关注博主公众号「java大师」, 专注于分享Java领域干货文章, 关注回复「资源」, 免费领取全网最热的Java架构师学习PDF, 转载请注明出处 https://www ...

  6. Java加密技术(四)——非对称加密算法RSA

    Java非对称加密算法rsa     接下来我们介绍典型的非对称加密算法--RSA RSA     这种算法1978年就出现了,它是第一个既能用于数据加密也能用于数字签名的算法.它易于理解和操作,也很 ...

  7. 3、Azure Devops之Azure Repos篇

    1.什么是Azure Repos Azure Repos,就是我们常说的代码仓库,相当于gitee,github,git,svn工具.主要是提供给开发人员使用的,管理.查看代码的部件.通过Files. ...

  8. PAT 甲级【1009 Product of Polynomials】

    /* 系数为0不输出 貌似runtime异常也显示答案不正确*/ import java.io.BufferedReader; import java.io.IOException; import j ...

  9. 记录--5个知识点,让 Vue3 开发更加丝滑

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 前言 最近鼓捣了一下 Vue3 + Vite2,遇到了不少问题,整理了5个可以提高开发效率的小知识,让你在 Vue3 的项目开发中更加丝滑 ...

  10. 一文搞懂JavaEE的接口

    在Java EE(Java Enterprise Edition)开发环境中,编程意义上的"接口"(interface)和API接口虽然都涉及接口的概念,但它们属于不同层面的术语. ...