LRU缓存替换策略

缓存是一种非常常见的设计,通过将数据缓存到访问速度更快的存储设备中,来提高数据的访问速度,如内存、CPU缓存、硬盘缓存等。

但与缓存的高速相对的是,缓存的成本较高,因此容量往往是有限的,当缓存满了之后,就需要一种策略来决定将哪些数据移除出缓存,以腾出空间来存储新的数据。

这样的策略被称为缓存替换策略(Cache Replacement Policy)。

常见的缓存替换策略有:FIFO(First In First Out)、LRU(Least Recently Used)、LFU(Least Frequently Used)等。

今天给大家介绍的是LRU算法。

核心思想

LRU算法基于这样一个假设:如果数据最近被访问过,那么将来被访问的几率也更高。

大部分情况下这个假设是成立的,因此LRU算法也是比较常用的缓存替换策略。

基于这个假设,我们在实现的时候,需要维护一个有序的数据结构,来记录数据的访问历史,当缓存满了之后,就可以根据这个数据结构来决定将哪些数据移除出缓存。

不适用场景

但如果数据的访问模式不符合LRU算法的假设,那么LRU算法就会失效。

例如:数据的访问模式是周期性的,那么LRU算法就会把周期性的数据淘汰掉,这样就会导致缓存命中率的下降。

换个说法比如,如果现在缓存的数据只在白天被访问,晚上访问的是另一批数据,那么在晚上,LRU算法就会把白天访问的数据淘汰掉,第二天白天又会把昨天晚上访问的数据淘汰掉,这样就会导致缓存命中率的下降。

后面有时间会给大家介绍LFU(Least Frequently Used)算法,以及LFU和LRU的结合LFRU(Least Frequently and Recently Used)算法,可以有效的解决这个问题。

算法基本实现

上文提到,LRU算法需要维护一个有序的数据结构,来记录数据的访问历史。通常我们会用双向链表来实现这个数据结构,因为双向链表可以在O(1)的时间复杂度内往链表的头部或者尾部插入数据,以及在O(1)的时间复杂度内删除数据。

我们将数据存储在双向链表中,每次访问数据的时候,就将数据移动到链表的尾部,这样就可以保证链表的尾部就是最近访问的数据,链表的头部就是最久没有被访问的数据。

当缓存满了之后,如果需要插入新的数据,因为链表的头部就是最久没有被访问的数据,所以我们就可以直接将链表的头部删除,然后将新的数据插入到链表的尾部。

如果我们要实现一个键值对的缓存,我们可以用一个哈希表来存储键值对,这样就可以在O(1)的时间复杂度内完成查找操作,.NET 中我们可以使用 Dictionary。

同时我们使用 LinkedList 来作为双向链表的实现,存储缓存的 key,以此记录数据的访问历史。

我们在每次操作 Dictionary 进行插入、删除、查找的时候,都需要将对应的 key 也插入、删除、移动到链表的尾部。

  1. // 实现 IEnumerable 接口,方便遍历
  2. public class LRUCache<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
  3. {
  4. private readonly LinkedList<TKey> _list;
  5. private readonly Dictionary<TKey, TValue> _dictionary;
  6. private readonly int _capacity;
  7. public LRUCache(int capacity)
  8. {
  9. _capacity = capacity;
  10. _list = new LinkedList<TKey>();
  11. _dictionary = new Dictionary<TKey, TValue>();
  12. }
  13. public TValue Get(TKey key)
  14. {
  15. if (_dictionary.TryGetValue(key, out var value))
  16. {
  17. // 在链表中删除 key,然后将 key 添加到链表的尾部
  18. // 这样就可以保证链表的尾部就是最近访问的数据,链表的头部就是最久没有被访问的数据
  19. // 但是在链表中删除 key 的时间复杂度是 O(n),所以这个算法的时间复杂度是 O(n)
  20. _list.Remove(key);
  21. _list.AddLast(key);
  22. return value;
  23. }
  24. return default;
  25. }
  26. public void Put(TKey key, TValue value)
  27. {
  28. if (_dictionary.TryGetValue(key, out _))
  29. {
  30. // 如果插入的 key 已经存在,将 key 对应的值更新,然后将 key 移动到链表的尾部
  31. _dictionary[key] = value;
  32. _list.Remove(key);
  33. _list.AddLast(key);
  34. }
  35. else
  36. {
  37. if (_list.Count == _capacity)
  38. {
  39. // 缓存满了,删除链表的头部,也就是最久没有被访问的数据
  40. _dictionary.Remove(_list.First.Value);
  41. _list.RemoveFirst();
  42. }
  43. _list.AddLast(key);
  44. _dictionary.Add(key, value);
  45. }
  46. }
  47. public void Remove(TKey key)
  48. {
  49. if (_dictionary.TryGetValue(key, out _))
  50. {
  51. _dictionary.Remove(key);
  52. _list.Remove(key);
  53. }
  54. }
  55. public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
  56. {
  57. foreach (var key in _list)
  58. {
  59. yield return new KeyValuePair<TKey, TValue>(key, _dictionary[key]);
  60. }
  61. }
  62. IEnumerator IEnumerable.GetEnumerator()
  63. {
  64. return GetEnumerator();
  65. }
  66. }
  1. var lruCache = new LRUCache<int, int>(4);
  2. lruCache.Put(1, 1);
  3. lruCache.Put(2, 2);
  4. lruCache.Put(3, 3);
  5. lruCache.Put(4, 4);
  6. Console.WriteLine(string.Join(" ", lruCache));
  7. Console.WriteLine(lruCache.Get(2));
  8. Console.WriteLine(string.Join(" ", lruCache));
  9. lruCache.Put(5, 5);
  10. Console.WriteLine(string.Join(" ", lruCache));
  11. lruCache.Remove(3);
  12. Console.WriteLine(string.Join(" ", lruCache));

输出:

  1. [1, 1] [2, 2] [3, 3] [4, 4] // 初始化
  2. 2 // 访问 2
  3. [1, 1] [3, 3] [4, 4] [2, 2] // 2 移动到链表尾部
  4. [3, 3] [4, 4] [2, 2] [5, 5] // 插入 5
  5. [4, 4] [2, 2] [5, 5] // 删除 3

算法优化

上面的实现中,对缓存的查询、插入、删除都会涉及到链表中数据的删除(移动也是删除再插入)。

因为我们在 LinkedList 中存储的是 key,所以我们需要先通过 key 在链表中找到对应的节点,然后再进行删除操作,这就导致了链表的删除操作的时间复杂度是 O(n)。

虽然 Dictionary 的查找、插入、删除操作的时间复杂度都是 O(1),但因为链表操作的时间复杂度是 O(n),整个算法的最差时间复杂度是 O(n)。

算法优化的关键在于如何降低链表的删除操作的时间复杂度。

优化思路:

  1. 在 Dictionary 中存储 key 和 LinkedList 中节点的映射关系
  2. 在 LinkedList 的节点中存储 key-value

也就是说,我们让两个本来不相关的数据结构之间产生联系。

不管是在插入、删除、查找缓存的时候,都可以通过这种联系来将时间复杂度降低到 O(1)。

  1. 通过 key 在 Dictionary 中找到对应的节点,然后再从 LinkedList 节点中取出 value,时间复杂度是 O(1)
  2. LinkedList 删除数据之前,先通过 key 在 Dictionary 中找到对应的节点,然后再删除,这样就可以将链表的删除操作的时间复杂度降低到 O(1)
  3. LinkedList 删除头部节点时,因为节点中存储了 key,所以我们可以通过 key 在 Dictionary 中删除对应的节点,时间复杂度是 O(1)
  1. public class LRUCache_V2<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
  2. {
  3. private readonly LinkedList<KeyValuePair<TKey, TValue>> _list;
  4. private readonly Dictionary<TKey, LinkedListNode<KeyValuePair<TKey, TValue>>> _dictionary;
  5. private readonly int _capacity;
  6. public LRUCache_V2(int capacity)
  7. {
  8. _capacity = capacity;
  9. _list = new LinkedList<KeyValuePair<TKey, TValue>>();
  10. _dictionary = new Dictionary<TKey, LinkedListNode<KeyValuePair<TKey, TValue>>>();
  11. }
  12. public TValue Get(TKey key)
  13. {
  14. if (_dictionary.TryGetValue(key, out var node))
  15. {
  16. _list.Remove(node);
  17. _list.AddLast(node);
  18. return node.Value.Value;
  19. }
  20. return default;
  21. }
  22. public void Put(TKey key, TValue value)
  23. {
  24. if (_dictionary.TryGetValue(key, out var node))
  25. {
  26. node.Value = new KeyValuePair<TKey, TValue>(key, value);
  27. _list.Remove(node);
  28. _list.AddLast(node);
  29. }
  30. else
  31. {
  32. if (_list.Count == _capacity)
  33. {
  34. _dictionary.Remove(_list.First.Value.Key);
  35. _list.RemoveFirst();
  36. }
  37. var newNode = new LinkedListNode<KeyValuePair<TKey, TValue>>(new KeyValuePair<TKey, TValue>(key, value));
  38. _list.AddLast(newNode);
  39. _dictionary.Add(key, newNode);
  40. }
  41. }
  42. public void Remove(TKey key)
  43. {
  44. if (_dictionary.TryGetValue(key, out var node))
  45. {
  46. _dictionary.Remove(key);
  47. _list.Remove(node);
  48. }
  49. }
  50. public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
  51. {
  52. return _list.GetEnumerator();
  53. }
  54. IEnumerator IEnumerable.GetEnumerator()
  55. {
  56. return GetEnumerator();
  57. }
  58. }

进一步优化

因为我们对 双向链表 的存储需求是定制化的,要求节点中存储 key-value,直接使用 C# 的 LinkedList 我们就需要用 KeyValuePair 这样的结构来间接存储,会导致一些不必要的内存开销。

我们可以自己实现一个双向链表,这样就可以直接在节点中存储 key-value,从而减少内存开销。

  1. public class LRUCache_V3<TKey, TValue>
  2. {
  3. private readonly DoubleLinkedListNode<TKey, TValue> _head;
  4. private readonly DoubleLinkedListNode<TKey, TValue> _tail;
  5. private readonly Dictionary<TKey, DoubleLinkedListNode<TKey, TValue>> _dictionary;
  6. private readonly int _capacity;
  7. public LRUCache_V3(int capacity)
  8. {
  9. _capacity = capacity;
  10. _head = new DoubleLinkedListNode<TKey, TValue>();
  11. _tail = new DoubleLinkedListNode<TKey, TValue>();
  12. _head.Next = _tail;
  13. _tail.Previous = _head;
  14. _dictionary = new Dictionary<TKey, DoubleLinkedListNode<TKey, TValue>>();
  15. }
  16. public TValue Get(TKey key)
  17. {
  18. if (_dictionary.TryGetValue(key, out var node))
  19. {
  20. RemoveNode(node);
  21. AddLastNode(node);
  22. return node.Value;
  23. }
  24. return default;
  25. }
  26. public void Put(TKey key, TValue value)
  27. {
  28. if (_dictionary.TryGetValue(key, out var node))
  29. {
  30. RemoveNode(node);
  31. AddLastNode(node);
  32. node.Value = value;
  33. }
  34. else
  35. {
  36. if (_dictionary.Count == _capacity)
  37. {
  38. var firstNode = RemoveFirstNode();
  39. _dictionary.Remove(firstNode.Key);
  40. }
  41. var newNode = new DoubleLinkedListNode<TKey, TValue>(key, value);
  42. AddLastNode(newNode);
  43. _dictionary.Add(key, newNode);
  44. }
  45. }
  46. public void Remove(TKey key)
  47. {
  48. if (_dictionary.TryGetValue(key, out var node))
  49. {
  50. _dictionary.Remove(key);
  51. RemoveNode(node);
  52. }
  53. }
  54. private void AddLastNode(DoubleLinkedListNode<TKey, TValue> node)
  55. {
  56. node.Previous = _tail.Previous;
  57. node.Next = _tail;
  58. _tail.Previous.Next = node;
  59. _tail.Previous = node;
  60. }
  61. private DoubleLinkedListNode<TKey, TValue> RemoveFirstNode()
  62. {
  63. var firstNode = _head.Next;
  64. _head.Next = firstNode.Next;
  65. firstNode.Next.Previous = _head;
  66. firstNode.Next = null;
  67. firstNode.Previous = null;
  68. return firstNode;
  69. }
  70. private void RemoveNode(DoubleLinkedListNode<TKey, TValue> node)
  71. {
  72. node.Previous.Next = node.Next;
  73. node.Next.Previous = node.Previous;
  74. node.Next = null;
  75. node.Previous = null;
  76. }
  77. internal class DoubleLinkedListNode<TKey, TValue>
  78. {
  79. public DoubleLinkedListNode()
  80. {
  81. }
  82. public DoubleLinkedListNode(TKey key, TValue value)
  83. {
  84. Key = key;
  85. Value = value;
  86. }
  87. public TKey Key { get; set; }
  88. public TValue Value { get; set; }
  89. public DoubleLinkedListNode<TKey, TValue> Previous { get; set; }
  90. public DoubleLinkedListNode<TKey, TValue> Next { get; set; }
  91. }
  92. }

Benchmark

使用 BenchmarkDotNet 对3个版本进行性能测试对比。

  1. [MemoryDiagnoser]
  2. public class WriteBenchmarks
  3. {
  4. // 保证写入的数据有一定的重复性,借此来测试LRU的最差时间复杂度
  5. private const int Capacity = 1000;
  6. private const int DataSize = 10_0000;
  7. private List<int> _data;
  8. [GlobalSetup]
  9. public void Setup()
  10. {
  11. _data = new List<int>();
  12. var shared = Random.Shared;
  13. for (int i = 0; i < DataSize; i++)
  14. {
  15. _data.Add(shared.Next(0, DataSize / 10));
  16. }
  17. }
  18. [Benchmark]
  19. public void LRUCache_V1()
  20. {
  21. var cache = new LRUCache<int, int>(Capacity);
  22. foreach (var item in _data)
  23. {
  24. cache.Put(item, item);
  25. }
  26. }
  27. [Benchmark]
  28. public void LRUCache_V2()
  29. {
  30. var cache = new LRUCache_V2<int, int>(Capacity);
  31. foreach (var item in _data)
  32. {
  33. cache.Put(item, item);
  34. }
  35. }
  36. [Benchmark]
  37. public void LRUCache_V3()
  38. {
  39. var cache = new LRUCache_V3<int, int>(Capacity);
  40. foreach (var item in _data)
  41. {
  42. cache.Put(item, item);
  43. }
  44. }
  45. }
  46. public class ReadBenchmarks
  47. {
  48. // 保证写入的数据有一定的重复性,借此来测试LRU的最差时间复杂度
  49. private const int Capacity = 1000;
  50. private const int DataSize = 10_0000;
  51. private List<int> _data;
  52. private LRUCache<int, int> _cacheV1;
  53. private LRUCache_V2<int, int> _cacheV2;
  54. private LRUCache_V3<int, int> _cacheV3;
  55. [GlobalSetup]
  56. public void Setup()
  57. {
  58. _cacheV1 = new LRUCache<int, int>(Capacity);
  59. _cacheV2 = new LRUCache_V2<int, int>(Capacity);
  60. _cacheV3 = new LRUCache_V3<int, int>(Capacity);
  61. _data = new List<int>();
  62. var shared = Random.Shared;
  63. for (int i = 0; i < DataSize; i++)
  64. {
  65. int dataToPut = shared.Next(0, DataSize / 10);
  66. int dataToGet = shared.Next(0, DataSize / 10);
  67. _data.Add(dataToGet);
  68. _cacheV1.Put(dataToPut, dataToPut);
  69. _cacheV2.Put(dataToPut, dataToPut);
  70. _cacheV3.Put(dataToPut, dataToPut);
  71. }
  72. }
  73. [Benchmark]
  74. public void LRUCache_V1()
  75. {
  76. foreach (var item in _data)
  77. {
  78. _cacheV1.Get(item);
  79. }
  80. }
  81. [Benchmark]
  82. public void LRUCache_V2()
  83. {
  84. foreach (var item in _data)
  85. {
  86. _cacheV2.Get(item);
  87. }
  88. }
  89. [Benchmark]
  90. public void LRUCache_V3()
  91. {
  92. foreach (var item in _data)
  93. {
  94. _cacheV3.Get(item);
  95. }
  96. }
  97. }

写入性能测试结果:

  1. | Method | Mean | Error | StdDev | Median | Gen0 | Gen1 | Allocated |
  2. |------------ |----------:|----------:|----------:|----------:|---------:|---------:|----------:|
  3. | LRUCache_V1 | 16.890 ms | 0.3344 ms | 0.8012 ms | 16.751 ms | 750.0000 | 218.7500 | 4.65 MB |
  4. | LRUCache_V2 | 7.193 ms | 0.1395 ms | 0.3958 ms | 7.063 ms | 703.1250 | 226.5625 | 4.22 MB |
  5. | LRUCache_V3 | 5.761 ms | 0.1102 ms | 0.1132 ms | 5.742 ms | 585.9375 | 187.5000 | 3.53 MB |

查询性能测试结果:

  1. | Method | Mean | Error | StdDev | Gen0 | Allocated |
  2. |------------ |----------:|----------:|----------:|--------:|----------:|
  3. | LRUCache_V1 | 19.475 ms | 0.3824 ms | 0.3390 ms | 62.5000 | 474462 B |
  4. | LRUCache_V2 | 1.994 ms | 0.0273 ms | 0.0242 ms | - | 4 B |
  5. | LRUCache_V3 | 1.595 ms | 0.0187 ms | 0.0175 ms | - | 3 B |

LRU缓存替换策略及C#实现的更多相关文章

  1. LeetCode:146_LRU cache | LRU缓存设计 | Hard

    题目:LRU cache Design and implement a data structure for Least Recently Used (LRU) cache. It should su ...

  2. 如何使用 LinkedHashMap 实现 LRU 缓存?

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 在上一篇文章里,我们聊到了 HashMap 的实现原理和源码分析,在源码分析的过程中,我 ...

  3. LRU缓存实现(Java)

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

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

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

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

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

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

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

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

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

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

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

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

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

  10. LRU缓存原理

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

随机推荐

  1. qt中的一些对话框(个人备忘录)

    一.标准对话框 1.对于颜色对话框 void MyWidget::on_pushButton_clicked() { QColorDialog dialog(Qt::red,this); dialog ...

  2. 修改tomcat启动时,修改默认访问的页面

  3. 数组扩展(Java)

    Arrays类 基本介绍 数组的工具类java.util.Arrays 由于数组本身中没有什么方法可供我们调用,但API中提供了一个工具类Arrays供我们使用,从而可以对数据对象进行一些基本操作 查 ...

  4. 更多Linux实用命令

    更多实用命令 进程相关 当程序运行在系统上时,我们称之为进程(process).想监测这些进程,需要熟悉 ps/top 等命令的用法.ps 命令好比工具中的瑞士军刀,它能输出运行在系统上的所有程序的许 ...

  5. CSS 常用样式-盒模型属性

    盒模型又叫框模型,包含了五个用来描述盒子位置.尺寸的属性,分别是宽度 width.高度 height.内边距 padding. 边框 border.外边距 margin. 常见盒模型区域: • 盒模型 ...

  6. 洛谷 P1832 A+B Problem(再升级)题解

    START: 2021-08-09 15:28:07 题目链接: https://www.luogu.com.cn/problem/P1832 给定一个正整数n,求将其分解成若干个素数之和的方案总数. ...

  7. Jenkins集成appium自动化测试

    一,引入问题 自动化测试脚本绝大部分用于回归测试,这就需要制定执行策略,如每天.代码更新后.项目上线前定时执行,才能达到最好的效果,这时就需要进行Jenkins集成. 不像web UI自动化测试可以使 ...

  8. 大数据组件对应Ranger插件的选择

    在都是开源组件的前提下,一般需要我们多关注到组件和插件的版本和类型选择. 参考 https://zhuanlan.zhihu.com/p/370263573 https://www.bookstack ...

  9. bash中的basename与dirname以及${}

    var=/dir1/dir2/file.tar.gz basename $var        #获取文件名 file.tar.gz dirname $var            #获取目录名称 / ...

  10. flask - fastapi (python 异步API 框架 可以自动生成swagger 文档) 常用示例 以及整合euraka nacos

    flask - fastapi    (python 异步API 框架  可以自动生成swagger 文档)  常用示例: 之前使用 flask 需要手动写文档, 这个可以自动生成, fastapi ...