LRU キャッシュ置換戦略
キャッシュは、メモリ、CPU キャッシュ、ハードディスク キャッシュなど、アクセス速度の速いストレージ デバイスにデータをキャッシュすることで、データ アクセス速度を向上させる非常に一般的な設計です。
ただし、キャッシュの高速性と比較して、キャッシュのコストが高いため、容量が制限されることが多く、キャッシュがいっぱいになった場合、新しいデータのためのスペースを確保するためにキャッシュからどのデータを削除するかを決定する戦略が必要です。ストレージ、データ。
このようなポリシーをキャッシュ置換ポリシー(Cache Replacement Policy)と呼びます。
一般的なキャッシュ置換戦略には、FIFO (先入れ先出し)、LRU (最も最近使用されていない)、LFU (最も頻繁に使用されていない) などが含まれます。
今日紹介するのは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 を使用し、キャッシュのキーを保存し、データのアクセス履歴を記録します。
辞書を操作して挿入、削除、検索を行うたびに、対応するキーを挿入、削除し、リンク リストの末尾に移動する必要があります。
// 实现 IEnumerable 接口,方便遍历
public class LRUCache<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
{
private readonly LinkedList<TKey> _list;
private readonly Dictionary<TKey, TValue> _dictionary;
private readonly int _capacity;
public LRUCache(int capacity)
{
_capacity = capacity;
_list = new LinkedList<TKey>();
_dictionary = new Dictionary<TKey, TValue>();
}
public TValue Get(TKey key)
{
if (_dictionary.TryGetValue(key, out var value))
{
// 在链表中删除 key,然后将 key 添加到链表的尾部
// 这样就可以保证链表的尾部就是最近访问的数据,链表的头部就是最久没有被访问的数据
// 但是在链表中删除 key 的时间复杂度是 O(n),所以这个算法的时间复杂度是 O(n)
_list.Remove(key);
_list.AddLast(key);
return value;
}
return default;
}
public void Put(TKey key, TValue value)
{
if (_dictionary.TryGetValue(key, out _))
{
// 如果插入的 key 已经存在,将 key 对应的值更新,然后将 key 移动到链表的尾部
_dictionary[key] = value;
_list.Remove(key);
_list.AddLast(key);
}
else
{
if (_list.Count == _capacity)
{
// 缓存满了,删除链表的头部,也就是最久没有被访问的数据
_dictionary.Remove(_list.First.Value);
_list.RemoveFirst();
}
_list.AddLast(key);
_dictionary.Add(key, value);
}
}
public void Remove(TKey key)
{
if (_dictionary.TryGetValue(key, out _))
{
_dictionary.Remove(key);
_list.Remove(key);
}
}
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
{
foreach (var key in _list)
{
yield return new KeyValuePair<TKey, TValue>(key, _dictionary[key]);
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
var lruCache = new LRUCache<int, int>(4);
lruCache.Put(1, 1);
lruCache.Put(2, 2);
lruCache.Put(3, 3);
lruCache.Put(4, 4);
Console.WriteLine(string.Join(" ", lruCache));
Console.WriteLine(lruCache.Get(2));
Console.WriteLine(string.Join(" ", lruCache));
lruCache.Put(5, 5);
Console.WriteLine(string.Join(" ", lruCache));
lruCache.Remove(3);
Console.WriteLine(string.Join(" ", lruCache));
出力:
[1, 1] [2, 2] [3, 3] [4, 4] // 初始化
2 // 访问 2
[1, 1] [3, 3] [4, 4] [2, 2] // 2 移动到链表尾部
[3, 3] [4, 4] [2, 2] [5, 5] // 插入 5
[4, 4] [2, 2] [5, 5] // 删除 3
アルゴリズムの最適化
上記の実装では、キャッシュのクエリ、挿入、および削除にはすべて、リンクされたリスト内のデータの削除が含まれます (移動には削除と挿入も含まれます)。
LinkedList に格納するのはキーであるため、最初にキーを通じてリンク リスト内の対応するノードを見つけてから削除操作を実行する必要があります。これにより、リンク リストの削除操作の時間計算量は O になります。 (n)。
辞書の検索、挿入、および削除操作の時間計算量は O(1) ですが、リンク リスト操作の時間計算量は O(n) であるため、アルゴリズム全体の時間計算量が最悪の場合は O(n) になります。
アルゴリズム最適化の鍵は、リンク リストの削除操作の時間を短縮する方法です。
最適化のアイデア:
- キーとノード間のマッピング関係を LinkedList に辞書に保存します
- Key-ValueをLinkedListのノードに格納する
つまり、無関係な 2 つのデータ構造間を接続します。
キャッシュの挿入、削除、検索の際に関係なく、この接続を使用すると、時間の複雑さを O(1) に減らすことができます。
- キーを使用して辞書内の対応するノードを検索し、LinkedList ノードから値を取得します。時間計算量は O(1) です。
- LinkedList はデータを削除する前に、まずキーを通じて辞書内の対応するノードを見つけてから削除するため、リンク リストの削除操作の時間計算量は O(1) に削減されます。
- LinkedList がヘッド ノードを削除するとき、キーはノードに格納されているため、キーを通じて辞書内の対応するノードを削除でき、時間計算量は O(1) です。
public class LRUCache_V2<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
{
private readonly LinkedList<KeyValuePair<TKey, TValue>> _list;
private readonly Dictionary<TKey, LinkedListNode<KeyValuePair<TKey, TValue>>> _dictionary;
private readonly int _capacity;
public LRUCache_V2(int capacity)
{
_capacity = capacity;
_list = new LinkedList<KeyValuePair<TKey, TValue>>();
_dictionary = new Dictionary<TKey, LinkedListNode<KeyValuePair<TKey, TValue>>>();
}
public TValue Get(TKey key)
{
if (_dictionary.TryGetValue(key, out var node))
{
_list.Remove(node);
_list.AddLast(node);
return node.Value.Value;
}
return default;
}
public void Put(TKey key, TValue value)
{
if (_dictionary.TryGetValue(key, out var node))
{
node.Value = new KeyValuePair<TKey, TValue>(key, value);
_list.Remove(node);
_list.AddLast(node);
}
else
{
if (_list.Count == _capacity)
{
_dictionary.Remove(_list.First.Value.Key);
_list.RemoveFirst();
}
var newNode = new LinkedListNode<KeyValuePair<TKey, TValue>>(new KeyValuePair<TKey, TValue>(key, value));
_list.AddLast(newNode);
_dictionary.Add(key, newNode);
}
}
public void Remove(TKey key)
{
if (_dictionary.TryGetValue(key, out var node))
{
_dictionary.Remove(key);
_list.Remove(node);
}
}
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
{
return _list.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
高度な最適化
二重リンク リストのストレージ要件がカスタマイズされており、キーと値をノードに格納する必要があるため、C# の LinkedList を直接使用する場合は、間接ストレージとして KeyValuePair などの構造を使用する必要があります。不必要なメモリのオーバーヘッドが発生します。
二重リンクリストを自分で実装できるため、キーと値をノードに直接格納できるため、メモリのオーバーヘッドが削減されます。
public class LRUCache_V3<TKey, TValue>
{
private readonly DoubleLinkedListNode<TKey, TValue> _head;
private readonly DoubleLinkedListNode<TKey, TValue> _tail;
private readonly Dictionary<TKey, DoubleLinkedListNode<TKey, TValue>> _dictionary;
private readonly int _capacity;
public LRUCache_V3(int capacity)
{
_capacity = capacity;
_head = new DoubleLinkedListNode<TKey, TValue>();
_tail = new DoubleLinkedListNode<TKey, TValue>();
_head.Next = _tail;
_tail.Previous = _head;
_dictionary = new Dictionary<TKey, DoubleLinkedListNode<TKey, TValue>>();
}
public TValue Get(TKey key)
{
if (_dictionary.TryGetValue(key, out var node))
{
RemoveNode(node);
AddLastNode(node);
return node.Value;
}
return default;
}
public void Put(TKey key, TValue value)
{
if (_dictionary.TryGetValue(key, out var node))
{
RemoveNode(node);
AddLastNode(node);
node.Value = value;
}
else
{
if (_dictionary.Count == _capacity)
{
var firstNode = RemoveFirstNode();
_dictionary.Remove(firstNode.Key);
}
var newNode = new DoubleLinkedListNode<TKey, TValue>(key, value);
AddLastNode(newNode);
_dictionary.Add(key, newNode);
}
}
public void Remove(TKey key)
{
if (_dictionary.TryGetValue(key, out var node))
{
_dictionary.Remove(key);
RemoveNode(node);
}
}
private void AddLastNode(DoubleLinkedListNode<TKey, TValue> node)
{
node.Previous = _tail.Previous;
node.Next = _tail;
_tail.Previous.Next = node;
_tail.Previous = node;
}
private DoubleLinkedListNode<TKey, TValue> RemoveFirstNode()
{
var firstNode = _head.Next;
_head.Next = firstNode.Next;
firstNode.Next.Previous = _head;
firstNode.Next = null;
firstNode.Previous = null;
return firstNode;
}
private void RemoveNode(DoubleLinkedListNode<TKey, TValue> node)
{
node.Previous.Next = node.Next;
node.Next.Previous = node.Previous;
node.Next = null;
node.Previous = null;
}
internal class DoubleLinkedListNode<TKey, TValue>
{
public DoubleLinkedListNode()
{
}
public DoubleLinkedListNode(TKey key, TValue value)
{
Key = key;
Value = value;
}
public TKey Key { get; set; }
public TValue Value { get; set; }
public DoubleLinkedListNode<TKey, TValue> Previous { get; set; }
public DoubleLinkedListNode<TKey, TValue> Next { get; set; }
}
}
基準
BenchmarkDotNet を使用して、3 つのバージョンのパフォーマンスを比較します。
[MemoryDiagnoser]
public class WriteBenchmarks
{
// 保证写入的数据有一定的重复性,借此来测试LRU的最差时间复杂度
private const int Capacity = 1000;
private const int DataSize = 10_0000;
private List<int> _data;
[GlobalSetup]
public void Setup()
{
_data = new List<int>();
var shared = Random.Shared;
for (int i = 0; i < DataSize; i++)
{
_data.Add(shared.Next(0, DataSize / 10));
}
}
[Benchmark]
public void LRUCache_V1()
{
var cache = new LRUCache<int, int>(Capacity);
foreach (var item in _data)
{
cache.Put(item, item);
}
}
[Benchmark]
public void LRUCache_V2()
{
var cache = new LRUCache_V2<int, int>(Capacity);
foreach (var item in _data)
{
cache.Put(item, item);
}
}
[Benchmark]
public void LRUCache_V3()
{
var cache = new LRUCache_V3<int, int>(Capacity);
foreach (var item in _data)
{
cache.Put(item, item);
}
}
}
public class ReadBenchmarks
{
// 保证写入的数据有一定的重复性,借此来测试LRU的最差时间复杂度
private const int Capacity = 1000;
private const int DataSize = 10_0000;
private List<int> _data;
private LRUCache<int, int> _cacheV1;
private LRUCache_V2<int, int> _cacheV2;
private LRUCache_V3<int, int> _cacheV3;
[GlobalSetup]
public void Setup()
{
_cacheV1 = new LRUCache<int, int>(Capacity);
_cacheV2 = new LRUCache_V2<int, int>(Capacity);
_cacheV3 = new LRUCache_V3<int, int>(Capacity);
_data = new List<int>();
var shared = Random.Shared;
for (int i = 0; i < DataSize; i++)
{
int dataToPut = shared.Next(0, DataSize / 10);
int dataToGet = shared.Next(0, DataSize / 10);
_data.Add(dataToGet);
_cacheV1.Put(dataToPut, dataToPut);
_cacheV2.Put(dataToPut, dataToPut);
_cacheV3.Put(dataToPut, dataToPut);
}
}
[Benchmark]
public void LRUCache_V1()
{
foreach (var item in _data)
{
_cacheV1.Get(item);
}
}
[Benchmark]
public void LRUCache_V2()
{
foreach (var item in _data)
{
_cacheV2.Get(item);
}
}
[Benchmark]
public void LRUCache_V3()
{
foreach (var item in _data)
{
_cacheV3.Get(item);
}
}
}
パフォーマンス テスト結果を書き込みます。
| Method | Mean | Error | StdDev | Median | Gen0 | Gen1 | Allocated |
|------------ |----------:|----------:|----------:|----------:|---------:|---------:|----------:|
| LRUCache_V1 | 16.890 ms | 0.3344 ms | 0.8012 ms | 16.751 ms | 750.0000 | 218.7500 | 4.65 MB |
| LRUCache_V2 | 7.193 ms | 0.1395 ms | 0.3958 ms | 7.063 ms | 703.1250 | 226.5625 | 4.22 MB |
| LRUCache_V3 | 5.761 ms | 0.1102 ms | 0.1132 ms | 5.742 ms | 585.9375 | 187.5000 | 3.53 MB |
パフォーマンス テスト結果のクエリ:
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|------------ |----------:|----------:|----------:|--------:|----------:|
| LRUCache_V1 | 19.475 ms | 0.3824 ms | 0.3390 ms | 62.5000 | 474462 B |
| LRUCache_V2 | 1.994 ms | 0.0273 ms | 0.0242 ms | - | 4 B |
| LRUCache_V3 | 1.595 ms | 0.0187 ms | 0.0175 ms | - | 3 B |
ブロガーの技術公開アカウント EventHorizonCLI にご注目ください。
控えめで強力な開発ツール、JNPF 高速開発プラットフォームを紹介します。最新の主流である前後分離フレームワーク (SpringBoot+Mybatis-plus+Ant-Design+Vue3) を採用しています。コードジェネレータは依存性が低く、柔軟な拡張性を備えており、二次開発も柔軟に実現できます。
データベースモデリングからWeb API構築、ページデザインまで、より技術要件の高いアプリケーション開発をサポートするため、従来のソフトウェア開発とほとんど変わりませんが、ローコード可視化モードのみで、「足し算」「追加」を構築する繰り返し作業が不要になります。 「削除、変更、クエリ」機能が縮小されます。