在计算机的世界里,缓存就像一个“快速仓库”,它存储着我们频繁访问的数据,大大提升了数据的读取速度。但这个 “仓库” 空间有限,当它被装满时,就得决定舍弃一些数据,为新数据腾出位置,这个决策过程就由缓存置换算法来掌控。今天,就让我们探索常见的缓存置换算法中的最近最少使用算法(LRU),并使用c++实现它。
一、LRU 算法原理
LRU 算法的核心思想非常简单:当缓存已满,需要淘汰数据时,优先淘汰那些最近最少使用的数据。想象一下你在整理书架,那些很久都没被翻阅过的书籍(最近最少使用的数据),往往会被你放到更靠后的位置,甚至在书架空间不足时,你会优先选择将它们清理出去,以便为新的书籍腾出空间。LRU 算法就是基于这样的逻辑,确保缓存中始终保留最有可能被再次访问的数据。
为了实现这一目标,LRU 算法需要记录每个数据的访问时间或者访问顺序。每当访问一个数据时,它就会被标记为 “最近使用”,而那些长时间没有被访问的数据则会逐渐成为 “最近最少使用” 的数据。当缓存空间不足时,这些数据就会被替换掉。
二、实现 LRU 算法的数据结构选择
要实现 LRU 算法,选择合适的数据结构至关重要。通常,我们会结合双向链表和哈希表来实现 LRU 缓存。
(一)双向链表
双向链表用于维护数据的访问顺序。链表中的每个节点代表一个缓存数据,链表的尾部表示最近使用的数据,头部表示最近最少使用的数据。当访问一个数据时,对应的节点会被移动到链表尾部;当需要淘汰数据时,直接删除链表头部的节点即可。双向链表的优势在于,插入和删除操作的时间复杂度都是 O(1) ,这对于频繁的缓存操作来说非常高效。例如,在一个频繁读写的数据库缓存中,数据的插入和删除操作会经常发生,双向链表的这种特性可以保证缓存操作的快速执行。
(二)哈希表
哈希表则用于快速定位数据在双向链表中的位置。它以数据的键作为索引,存储对应数据在双向链表中的节点指针。这样,在查找数据时,通过哈希表可以在 O(1) 的时间复杂度内找到对应的数据节点,然后根据节点指针在双向链表中进行操作。例如,在一个基于键值对存储的内存缓存中,通过哈希表可以快速找到特定键对应的数据,提高缓存的查询效率。
不清楚哈希表的可以看我以前的文章c++ 手写STL篇(三)实现hashtable
结合双向链表和哈希表,我们可以在 O(1) 的时间复杂度内完成数据的查找、插入和删除操作,满足 LRU 算法高效性的要求。
三、LRU 算法的操作流程
下面详细介绍 LRU 算法在缓存操作中的具体流程。
(一)查询操作
当进行查询操作时,首先根据数据的键在哈希表中查找对应的节点。如果找到了,说明数据在缓存中,将该节点从双向链表中移除,并插入到链表尾部,表示该数据是最近使用的;如果在哈希表中未找到,则说明数据不在缓存中,需要从其他数据源获取数据(例如从磁盘读取数据),然后将数据插入到缓存链表尾部。
(二)插入操作
插入操作分为两种情况。如果缓存未满,直接将数据插入到双向链表头部,并在哈希表中记录数据的键和对应的节点指针;如果缓存已满,则先删除双向链表头部的节点(即最近最少使用的数据),同时在哈希表中删除对应的键值对,然后再将新数据插入到双向链表尾部,并更新哈希表。
四、LRU 算法的应用场景
LRU 算法在许多实际场景中都发挥着重要作用,以下是一些常见的应用场景。
-
操作系统内存管理:以 Ubuntu 系统为例,在其内存管理中,LRU 算法发挥着重要作用。Ubuntu 基于 Linux 内核,系统会将常用的数据和程序页面缓存到物理内存中,以提升访问速度。当物理内存不足时,就需要淘汰部分页面。此时,LRU 算法会优先淘汰那些最近最少使用的页面。假设用户在 Ubuntu 系统上同时运行多个程序,如浏览器、文本编辑器和音乐播放器。一段时间内,用户频繁操作浏览器,而文本编辑器和音乐播放器处于后台且较少被使用。那么,根据 LRU 算法,文本编辑器和音乐播放器相关的内存页面(如果长时间未被访问)就更有可能被淘汰,从而为其他更活跃的程序腾出内存空间,保证系统的稳定运行和高效性能。
-
浏览器缓存优化:主流浏览器如 Chrome、Firefox 等均采用 LRU 算法管理缓存。当用户浏览网页时,浏览器会将网页的 HTML、CSS、图片等资源缓存起来。以在 Ubuntu 系统上使用 Chrome 浏览器访问网页为例,当用户再次访问相同或相关页面时,若资源在缓存中,浏览器可直接从缓存读取,减少网络请求,加快页面加载速度(背过八股文的应该清楚这一部分)。随着用户浏览的网页增多,缓存空间会逐渐被占满。此时,LRU 算法会将最近最少访问的网页资源淘汰,确保缓存中始终保留最有可能再次被访问的内容。比如,用户之前浏览过多个新闻页面,一段时间后又频繁访问购物网站,那么那些较早浏览且长时间未再次访问的新闻页面缓存资源就可能被 LRU 算法淘汰,为购物网站的缓存资源腾出空间。
-
CPU 缓存协同工作:在 计算机硬件层面,CPU 缓存同样应用了 LRU 算法或类似策略。CPU 缓存用于存储 CPU 频繁访问的数据和指令,分为一级缓存(L1 Cache)、二级缓存(L2 Cache)和三级缓存(L3 Cache)(关于cpu的三级缓冲可以参考这篇文章:小林coding 图解系统)。当 CPU 需要读取数据或指令时,首先在缓存中查找。若缓存命中,则快速获取数据;若缓存未命中,则从主内存读取,并将数据存入缓存。由于缓存容量有限,当新数据需要存入缓存时,会根据类似 LRU 的算法淘汰最近最少使用的数据。比如在 Ubuntu 系统上运行复杂的图像渲染程序,CPU 需要频繁读取图像数据和相关指令。随着程序的运行,缓存中的数据不断更新,那些近期较少被使用的图像数据和指令就会被淘汰,为新的数据腾出空间,确保 CPU 能够快速访问到最需要的数据,提高图像渲染的效率。
五、LRU 算法的优缺点
(一)优点
- 简单高效:LRU 算法的原理和实现相对简单,并且在许多场景下都能表现出良好的性能。它能够快速地淘汰最近最少使用的数据,保证缓存中始终保留最有价值的数据,从而提高系统的整体性能。
- 符合局部性原理:在计算机系统中,数据访问往往具有局部性特征,即最近访问过的数据在未来一段时间内再次被访问的概率较高。LRU 算法正好符合这一原理,通过优先保留最近使用的数据,能够有效地提高缓存命中率,减少数据的重复读取。
(二)缺点
- 无法准确预测未来访问模式:LRU 算法仅仅根据过去的数据访问历史来决定淘汰哪些数据,而不能准确预测未来的数据访问模式。在某些情况下,一次性遍历大量不重复数据(或者突然访问大量冷数据),可能会将真正的热点数据清空(缓存污染),导致命中率低。
- 实现成本较高:虽然 LRU 算法的基本思想简单,但要实现一个高效的 LRU 缓存,需要结合双向链表和哈希表等数据结构,这增加了代码的复杂性和实现成本。此外,在高并发环境下,还需要考虑数据结构的线程安全性,进一步增加了实现的难度,同时锁的加入会在高并发下消耗大量资源用于线程的同步等待。
六、c++实现LRU
LRU 缓存置换算法是一种经典且实用的算法,在众多领域都有广泛的应用。通过深入理解其原理、数据结构选择、操作流程以及应用场景,我们可以更好地将其应用到实际项目中,提升系统的性能。接下来,我们将通过手撕代码的方式,详细展示如何实现一个 LRU 缓存。
接口类,对缓存置换算法设计统一接口实现隔离 :
#ifndef CACHEPOLICY_H
#define CACHEPOLICY_H
template<typename Key, typename Value>
class CachePolicy
{
public:
virtual ~CachePolicy() {};
virtual void put(Key key, Value value) = 0;
virtual bool get(Key key,Value& value) = 0;
virtual Value get(Key key) = 0;
};
#endif
节点类,LRU中的双向链表,每个节点保存缓存数据的键值对、访问次数和前后节点指针,同时封装类方法用于访问和修改私有成员变量
//前置声明LRU类,具体实现看下文
template<typename Key, typename Value> class LruCache;
template<typename Key, typename Value>
class LruNode
{
private:
Key key_;
Value value_;
size_t accessCount_;
std::shared_ptr<LruNode<Key,Value>> next_;
std::shared_ptr<LruNode<Key,Value>> prev_;
public:
//初始化列表方法对类成员变量赋初值
LruNode(Key key, Value value):
key_(key),
value_(value),
accessCount_(0),
next_(nullptr),
prev_(nullptr) {}
Key getKey() const {return key_;}//const声明方法不对成员变量修改
Value getValue() const {return value_;}
void setValue(const Value& value) {value_ = value;}
size_t getAccexxCount() const {return accessCount_;}
void incrementAccessCount() {++accessCount_;}
//LRU类作为节点类友元,用于在LRU类中访问节点类的私有成员
friend class LruCache<Key, Value>;
};
LRU类,其内部包含链表虚拟头节点(虚拟头节点的下一个节点就是最不经常使用节点)指针,虚拟尾节点(虚拟尾节点的上一个节点最近访问过的节点)指针。使用unordered_map作为哈希表(unordered_map底层是哈希表实现,不清楚哈希表的可以看我以前的文章c++ 手写STL篇(三)实现hashtable)。
template<typename Key, typename Value>
class LruCache : public CachePolicy<Key,Value>
{
public:
using LruNodeType = LruNode<Key,Value>;
using NodePtr = std::shared_ptr<LruNodeType>;
using NodeMap = std::unordered_map<Key,NodePtr>;
LruCache(int capacity)
: capacity_ (capacity)
{
initializeList();
}
~LruCache() override = default;
void put(Key key, Value value) override
{
if(capacity_ <= 0) return;
std::lock_guard<std::mutex> lock(mutex_);//互斥锁,避免临界区竞争
//先使用哈希表查找缓存中是否存在key,
//存在则将节点取出,并插入链表尾部,
//不存在则新建节点并插入链表尾部
auto it = nodeMap_.find(key);
if(it != nodeMap_.end())
{
updateExistingNode(it->second, value);
return ;
}
addNewNode(key,value);
}
bool get(Key key,Value& value) override
{
std::lock_guard<std::mutex> lock(mutex_);
//先查找是否存在,存在则把节点移到链表尾部,不存在直接返回false
auto it = nodeMap_.find(key);
if(it != nodeMap_.end())
{
moveToMostRecent(it->second);
value = it->second->getValue();
return true;
}
return false;
}
//get的多态
Value get(Key key) override
{
Value value{};
get(key,value);
return value;
}
void remove(Key key)
{
std::lock_guard<std::mutex> lock(mutex_);
auto it = nodeMap_.find(key);
if(it != nodeMap_.end())
{
removeNode(it->second);
nodeMap_.erase(it);
}
}
private:
//初始化就是构建虚拟头节点和虚拟尾节点,然后头尾相连
void initializeList()
{
dummyHead_ = std::make_shared<LruNodeType>(Key(),Value());
dummyTail_ = std::make_shared<LruNodeType>(Key(),Value());
dummyHead_->next_ = dummyTail_;
dummyTail_->prev_ = dummyHead_;
}
//修改节点值,然后移动到链表尾部
void updateExistingNode(NodePtr node, const Value& value)
{
node->setValue(value);
moveToMostRecent(node);
}
//新增节点,这里会判断缓存容量是否已满,满了删除最不经常使用的节点(头部节点)
void addNewNode(const Key& key, const Value& value)
{
if(nodeMap_.size() >= capacity_)
{
evictLeastNode();
}
NodePtr newNode = std::make_shared<LruNodeType>(key,value);
insertNode(newNode);
nodeMap_[key] = newNode;
}
//节点移动到链表尾部(删除-》插入)
void moveToMostRecent(NodePtr node)
{
removeNode(node);
insertNode(node);
}
void removeNode(NodePtr node)
{
node->prev_->next_ = node->next_;
node->next_->prev_ = node->prev_;
}
void insertNode(NodePtr node)
{
node->next_ = dummyTail_;
node->prev_ = dummyTail_->prev_;
dummyTail_->prev_->next_ = node;
dummyTail_->prev_ = node;
}
//删除最不经常使用节点(头部节点)
void evictLeastNode()
{
NodePtr leastRecent = dummyHead_->next_;
removeNode(leastRecent);
nodeMap_.erase(leastRecent->getKey());
}
private:
int capacity_;//容量
NodeMap nodeMap_;//哈希表
NodePtr dummyHead_;//虚拟头节点,它的next指向真正的头节点(最不经常使用节点)
NodePtr dummyTail_;//虚拟尾节点,它的prev指向真正的为尾节点(最近使用过的节点)
std::mutex mutex_;//互斥锁
};
通过以上代码不难看出,锁的粒度很大,这样的话高并发下会消耗大量资源用于线程同步等待,这个问题怎么解决呢?其实可以把一个大的缓存池分成几个小的缓存池,就好比多个人都要从一个仓库取货,但仓库每次只能进一个人,如果多个人要取货那就必须得排队等待,哪怕每个人要取的货物不同,既然这样那不如把一个大仓库分成若干个小仓库,每个人取得货物可能分布在不同仓库内,哪怕仍然有好几个人要的货物碰巧在同一个仓库,那队伍也不会排的像原先那么长。这样就实现了分流。
template<typename Key, typename Value>
class HashLruCaches
{
public:
//把lurcache进行分片,计算每一个小的cache容量大小
HashLruCaches(size_t capacity, int sliceNum)
: capacity_(capacity),
sliceNum_(sliceNum > 0 ? sliceNum : std::thread::hardware_concurrency())
{
size_t sliceSize = std::ceil(capacity / static_cast<double>(sliceNum_));
for(int i = 0; i < sliceNum_; i++)
{
lruSliceCaches_.emplace_back(new LruCache<Key,Value>(sliceSize));
}
}
//使用哈希函数计算要找的节点在哪一个cache中
void put(Key key,Value value)
{
size_t sliceIndex = Hash(key) % sliceNum_;
return lruSliceCaches_[sliceIndex]->put(key, value);
}
bool get(Key key, Value& value)
{
size_t sliceIndex = Hash(key) % sliceNum_;
return lruSliceCaches_[sliceIndex]->get(key, value);
}
Value get(Key key)
{
Value value{};
memset(&value, 0, sizeof(Value));
get(key, value);
return value;
}
private:
size_t Hah(Key key)
{
std::hash<Key> hashFunc;
return hashFunc(key);
}
private:
size_t capacity_;
int sliceNum_;
std::vector<std::unique_ptr<LruCache<Key,Value>>> lruSliceCaches_;
};