新闻详情

新闻详情

首页 / 资讯中心 / 详情

LRU页面置换算法:从原理到工程实践的完整解析

发布时间:2026/9/29 10:21:34来源:尧图网络
LRU页面置换算法:从原理到工程实践的完整解析
长期不关机、大量应用常驻内存的电脑放着放着突然卡顿又或者服务器明明负载不高却频繁出现磁盘IO打满、响应时间飙升——这两类问题我都真实碰到过排查到最后根子往往不是内存条不够大而是操作系统在内存紧张时的页面置换策略出了问题。最近最久未使用置换算法LRU被写进几乎所有操作系统教材也是面试和期末考试的高频考点但很多资料只讲了它最近最久未使用这七个字却没说清楚它到底解决什么问题、怎么落地成代码、真实系统中为什么几乎没人直接用纯LRU。这篇文章就结合我自己的实践把LRU从原理、实现到工程化改造完整过一遍。正文部分原始信息不多我会以操作系统页面置换为主线把LRU相关的核心面一次讲透适合正在复习备考的学生也适合做缓存设计、性能优化时被LRU困扰的开发同学。1. 内存不够用的本质页面置换到底在解决什么问题1.1 请求分页带来的新麻烦现代操作系统普遍采用分页机制进程看到的是连续的虚拟地址空间而物理内存被切成固定大小的页框。为了不让每个进程都完整占有一份物理内存系统采用请求分页进程启动时只有当前真正需要用到的页面才被加载到物理内存其余页面留在磁盘或文件系统里用到了再去加载。这听起来很高效但矛盾也随之而来。当物理内存被占满而某个进程访问了一个不在内存中的页面内核必须腾出一个页框给新页面。腾框就意味着要选择一个旧页面“牺牲”掉把它写回磁盘或者直接丢弃。这个选择过程就是页面置换。很多人第一次学这块时觉得内存不够加内存不就行了但真实场景里你打开几十个标签页、几个开发工具、再挂着一堆后台服务物理内存再大也会被填满。页面置换算法的价值在于当内存紧张到必须换出页面的时候尽量换出那个未来最不可能被用到的页面从而降低缺页率。1.2 缺页代价远比你想象的大处理器访问内存是纳秒级的而磁盘读写是毫秒级甚至更慢两者相差不止5个数量级。一旦发生缺页中断必须发起磁盘IO把页面调入内存这段时间里CPU只能干等或切换去执行其他进程。如果置换策略选得不好很可能刚换出去的页面立马又要用结果再次缺页、再次换出系统就陷入“抖动”thrashing。我在实际调优时见过一个很典型的场景服务器内存占用率长期在90%以上业务偶尔慢得像卡死。用监控工具一查CPU不高但磁盘IO持续打满。这种情况下单看CPU、内存水位是发现不了问题的必须看缺页次数和换页速率。缺页率一旦飙升系统的大部分时间都在搬页面业务自然被拖垮。页面置换算法的目标因此非常朴素最大化后续访问的命中率最小化缺页次数同时兼顾置换算法本身的时间/空间开销。1.3 “随便换一个”为什么不行也许有人会提出既然预测不了未来干脆随机换一个不就好了随机置换Random实现成本最低但问题在于不可控。你很难保证随机选出的页面不是即将被访问的“热门页”一旦选错代价就是紧接着再来一次缺页。还有更直接的方案先进先出FIFO。它维护一个队列先进入内存的页面先被换出。FIFO实现简单但缺陷非常明显经常访问的页面可能因为“资历老”而被先换出导致频繁缺页。经典的Belady异常也发生在FIFO身上——增加页框数反而使缺页次数增多这对系统设计者来说是个警示不是内存多了就一定更快置换策略和内存大小必须匹配。LRU之所以能脱颖而出是因为它抓住了程序访问的局部性规律只依据历史信息做出判断既不需要预知未来又能比FIFO、随机置换要“贴近未来”。2. LRU的核心判定逻辑以“最久没被用过”为淘汰依据2.1 不要和“访问最少”混淆先花点力气把概念厘清。LRU的全称是Least Recently Used直译是“最近最久未使用”。它关注的是每个页面最后一次被访问距今有多久而不是这个页面在某个时间段内被访问了多少次。打个比方你办公室里有个公共微波炉有些人每天定时用一次频率高但规律有些人每隔很久才用一次但每次一用就用很长时间。如果微波炉只能同时保留一个热饭记录抱歉这个比喻不够准确LRU淘汰的是那个“最久没来用的人”而不是“来得最少的人”。因为“最久没来”很可能意味着接下来也不太会来而“来得少但最近刚用过”的人可能马上还会接着用。这两个指标在真实负载下有巨大差异。一个页面可能在某段时间内被访问了100次但最近一段时间没被碰过另一个页面只被访问过2次但就在刚刚。LRU会保留后者、淘汰前者。如果你用的是“访问频率最少”的逻辑类似LFU的简化版结论可能正好相反。2.2 手动推演一次完整的LRU置换为了直观理解我们用经典访问序列来推演。假设物理内存有4个页框初始为空页访问序列为7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1先看前几步访问7缺页调入7。当前内存状态[7]访问0缺页调入0。[7, 0]访问1缺页调入1。[7, 0, 1]访问2缺页调入2。[7, 0, 1, 2]访问0命中。0的最后访问时间被更新为最新。访问3此时内存满需要淘汰。当前每个页面最后一次访问的先后顺序是7最早、1、2、0最晚。按LRU规则淘汰7调入3。内存里剩下0、1、2、3。接下来的序列大家可以自己推观察点在于某次访问命中时被访问页的“资历”会被重置它从“最老”的位置挪到“最新”的位置。这种动态变化正是LRU区别于FIFO的地方——FIFO里进内存早的页面无论中间被访问多少次都还是排在队首随时可能被换出。2.3 栈算法性质与Belady异常在理论层面LRU属于栈算法stack algorithm。所谓栈算法指的是这样一种性质当物理内存页框数为n时内存中的页面集合一定包含在页框数为n1时内存中的页面集合内。也就是说增加内存只会让LRU的表现更好不会出现FIFO那样的Belady异常页框变多反而缺页率上升。这个性质让LRU在理论上非常优雅。面试或考试里经常让你比较FIFO、LRU、OPT最优置换算法在不同序列下的缺页次数背后就是在考察你是否理解“哪种算法更接近未来、哪种算法会出现反直觉的异常”。当然“理论上优雅”和“实践上可落地”之间还隔着巨大的鸿沟。LRU要求系统知道每个页面最后一次被访问的精确时间并且每次访问都要更新这个时间——这一点在硬件上很难做到完美所以才有了后面章节我们讲的近似实现。3. 如何实现LRU计数器法和栈法的代码级拆解3.1 计数器法最容易理解的实现最直观的LRU实现是给每个页面帧配一个计数器或时间戳逻辑如下每次页面被访问把系统的“当前时间”可以用一个递增计数器模拟赋给该页面的访问时间字段当需要淘汰页面时遍历所有页框找到访问时间最早的页面。这种实现思路很简单但代价不小每次淘汰都要遍历全部页框时间复杂度是O(n)。在页框数量少的时候无所谓但现代系统页框是几十万个起步每次全量扫描的CPU开销完全不能接受。而且每次访问都要更新时间戳本身也有写入开销。3.2 栈法哈希表双向链表工程上最经典的LRU实现是这样一套组合哈希表加双向链表。设计思路双向链表按访问时间从新到旧排列链表头部是最近刚访问的页面链表尾部是最久未被访问的页面哈希表的key是页面编号value是指向链表节点的指针/引用访问一个页面时先查哈希表如果命中就把对应节点从当前位置摘下来、移到链表头部如果未命中且缓存已满删除链表尾部节点再从哈希表里删掉对应key然后把新节点插到链表头部。用哈希表是为了O(1)地判断页面是否存在、并快速拿到链表节点用双向链表是为了在O(1)时间内完成“摘除”和“头部插入”。如果只用一个普通数组找一个节点需要在链表里从头遍历命中时的更新时间复杂度就变成O(n)根本扛不住真实场景。3.3 两种实现的复杂度对比实现方式查找页面更新访问时间淘汰页面空间开销适用场景计数器法O(1)哈希O(1)写时间戳O(n)扫全表找最小每个页框多一个计数器页框数量少、教学演示哈希表双向链表O(1)哈希O(1)移动到头部O(1)删尾部额外指针和节点结构缓存、业务系统、生产环境计数器法的“时间复杂度”看似有些是O(1)但淘汰时O(n)扫描在页框规模上来后就是灾难。栈法虽然在每个节点上要多维护两个指针但所有操作都是O(1)这是它在工程上胜出的根本原因。3.4 真实操作系统的限制需要特别说明的是上面的栈法在“缓存容量/页框数量固定”的场景下非常完美但操作系统的物理内存管理并不只有固定数量的槽位这么简单。OS里每个进程可以动态调整它占用的页框数还要考虑脏页要不要写回磁盘、不同进程的访问历史要不要区分优先级等等。更重要的是硬件在执行一条访存指令时CPU并不会告诉操作系统“我们刚才访问了哪个页”。操作系统能做的是利用页表项中的**访问位reference bit**由硬件在每次访问时自动置1再由内核周期性地去查看和复位这些位。这个机制只能告诉我们“这个页是否在最近一个周期内被访问过”无法给出精确到毫秒的最近访问时间所以纯LRU在内核里很难不做取舍地落地。4. 手写LRU从朴素实现到工程级实现4.1 先来一个单线程环境的完整实现我用Python写一个LRU缓存支持get和put复杂度O(1)。代码如下class DLinkedNode: def __init__(self, key0, value0): self.key key self.value value self.prev None self.next None class LRUCache: def __init__(self, capacity: int): self.capacity capacity self.hash {} self.head DLinkedNode() self.tail DLinkedNode() self.head.next self.tail self.tail.prev self.head def _remove_node(self, node): node.prev.next node.next node.next.prev node.prev def _add_to_head(self, node): node.prev self.head node.next self.head.next self.head.next.prev node self.head.next node def _move_to_head(self, node): self._remove_node(node) self._add_to_head(node) def _pop_tail(self): node self.tail.prev self._remove_node(node) return node def get(self, key: int) - int: if key not in self.hash: return -1 node self.hash[key] self._move_to_head(node) return node.value def put(self, key: int, value: int) - None: if key in self.hash: node self.hash[key] node.value value self._move_to_head(node) else: new_node DLinkedNode(key, value) self.hash[key] new_node self._add_to_head(new_node) if len(self.hash) self.capacity: tail_node self._pop_tail() del self.hash[tail_node.key]这个实现里最关键的是两个辅助节点head和tail。它们不存储真正的数据只作为哨兵让“头部插入”和“尾部删除”都不需要判断节点是否为空省掉大量边界条件。如果你用JavaJDK里LinkedHashMap的accessOrder模式就是干这个事的Python里也可以用collections.OrderedDict轻松实现但手动写一遍双向链表能加深理解。4.2 单测要覆盖哪些边界条件写这类代码最容易翻车的边界条件有四个capacity 1缓存只有一个槽位时put一个已存在的key要能覆盖put两个不同key时要能淘汰旧key。连续get同一个key但中间没有put不能改变缓存内容之外的任何状态当然LRU顺序会变但内容不能错。在缓存刚好填满时更新一个已有key的value此时缓存长度不能超过capacity。key不存在时get返回-1且不能触发任何淘汰动作。下面是一个最小测试用例cache LRUCache(2) cache.put(1, 1) cache.put(2, 2) assert cache.get(1) 1 cache.put(3, 3) assert cache.get(2) -1 cache.put(4, 4) assert cache.get(1) -1 assert cache.get(3) 3 assert cache.get(4) 44.3 测试后你会注意到的细节手写一遍之后你会发现LRU本质上是在维护一个“访问活跃度”的有序结构。它并不区分“刚访问过10次”和“刚访问过1次”——只要你刚访问过就放在头部区别只在于“距离上次访问的时间”。这带来一个问题如果某个页面碰巧在过去极短时间窗口内被高频访问然后突然很久不再被访问它依然可能占据头部位置很长时间才慢慢“沉”到底部。比如用户触发了一次批量扫描任务某个数据页被连续读取任务结束后再也没人碰它但它把一批真正高频的页面挤到了淘汰边缘。包括我实际遇到过业务里一个缓存热点命中率骤降原因就是上游有批处理任务偶尔扫了一轮全量数据把LRU头部污染了。5. 实际系统里的LRU近似实现与变体5.1 为什么操作系统内核不直接用“完美LRU”理论上每个页框配一个全局递增时间戳每次页面访问更新每次淘汰时扫一遍找最小——这套逻辑没有实现障碍但放到操作系统内核就不现实了。首先是硬件支持问题。普通x86/ARM架构只提供页表项中的访问位CPU访问页面时硬件会把访问位置1但它不会把“精确时间”记录到某个寄存器里。操作系统只能以一定周期比如时钟中断去扫描页表把访问位置1的页标记为“最近活跃”再清理这些位。这个周期扫描天然只能提供“最近一个时间段内是否被访问过”的粗粒度信息而不是“精确的最后访问时间”。其次是规模问题。操作系统管理的物理页面数量是海量的维护双向链表和哈希表需要大量额外内存还要考虑锁竞争。相比之下用近似方式只要在原来的数据结构上加一个位就行代价小很多。5.2 时钟算法最经典的LRU近似时钟算法Clock也叫二次机会算法是操作系统课本里的重要角色。它把所有页面组织成环形队列用一个指针在队列上循环移动每个页有一个使用位。流程如下页面被访问时使用位置1当需要淘汰页面时指针扫描环形队列如果当前页使用位为1就把它置0并继续扫描下一个页如果使用位为0说明这个页在最近一个周期内没被访问过淘汰它。这个算法本质上是在“四舍五入”地近似LRU使用位为1的页面相当于“最近被访问过”给它二次机会只有连续至少一个扫描周期都没被访问的页面才会被淘汰。指针和环形队列让实现代价变得很低不需要维护精确的访问时序因此被很多实际操作系统内核采用。5.3 增强型时钟再加一个脏位真实系统里脏页被修改过的页面换出时需要写回磁盘代价比干净页高得多。所以实际内核里的时钟算法往往再加一个“脏位”形成四个状态使用位脏位含义换出代价00未使用且未修改最低01未使用但被修改过中10使用过但未修改较高换出前至少给次机会11使用过且被修改最高内核扫描时优先选择状态为(0,0)的页面淘汰找不到再看(0,1)然后是(1,0)最后才看(1,1)。这个优先级设计让“干净且未使用”的页面最先被牺牲而不是机械地按使用位一刀切。5.4 从LRU派生出的其他工程变体除了时钟算法工业界还有几个常见的LRU改造方向LRU-K不只记“最后一次访问时间”而是记录最近K次访问的时间。K越大越能对抗突发访问污染LRU头部的问题。数据库缓冲池、存储引擎用得比较多。2QTwo Queue用两个队列一个放第一次访问的页面一个放第二次访问的页面。首次访问先进第一个队列被再次访问才升入第二个队列避免一次性扫描带来的“头部污染”。工作集模型不是单纯按访问时间而是估计进程在最近一个时间窗口内实际使用到的页面集合尽量让每个进程的驻留集匹配它的工作集从而减少抖动。Redis的近似LRURedis的maxmemory-policy里有个allkeys-lru它并不完全按每次访问更新而是采样部分key近似挑选最近最少使用的淘汰。Redis官方说这种方式在样本量合理时效果非常接近真实LRU但代价小得多。Linux内核的页面回收也不是纯LRU而是把页面分成active和inactive链表由内核在后台批量移动页面在inactive链表里优先回收更久未被访问的页面。这些设计背后的共同思路是用“粗略但廉价”的信息替代“精确但昂贵”的信息换取可接受的性能和可承受的实现成本。我在做数据库缓存设计时最常用的策略就是把LRU和频率统计结合起来——对流量突刺场景用基于“两次访问间隔”的变体比纯LRU稳得多。6. 避坑指南把LRU用在业务里的经验6.1 先分清“页面置换LRU”和“缓存淘汰LRU”第一个最容易被坑的地方是概念混用。操作系统页面置换里的LRU对象是物理内存页访问路径是硬件访存每次缺页的代价是磁盘IO而业务系统的LRU缓存比如Redis、本地缓存对象是KV数据访问路径是业务代码显式调用get命中只是省一次后端查询代价可大可小。这两类场景虽然LRU算法本身是一套逻辑但约束条件完全不同。操作系统里你必须考虑脏页写回、硬件访问位、多进程并发业务缓存里你更多要考虑TTL逻辑、缓存穿透、缓存一致性。如果只背一个双向链表实现就直接套到某个奇怪场景基本会踩到“语义不匹配”的坑。6.2 并发环境下别用单锁死磕我见过不少人在手写LRU之后直接加一个全局锁丢到多线程环境里结果高并发下性能惨不忍睹。双向链表的每次get都要移动节点这意味着即使是读操作也要修改链表结构锁粒度根本无法优化成单纯的读写锁。可行的出路有几个用分段锁把缓存按key哈希分布到多个小的LRU实例上用无锁结构但实现复杂度极高减少节点移动的频率比如只在计数达到某个阈值时才更新LRU状态或者直接依赖成熟组件比如CaffeineJava、Redis它们已经把效率优化到比较完善的程度。我从经验中得到的总结是如果你的业务没有到“千万级QPS”的量级用现成组件、考虑清楚淘汰策略和容量就足够不要一上来就自己搞一个定制LRU。缓存框架的坑远比LRU本身的坑要多。6.3 头部污染问题之前提到过LRU对突刺访问没有防御力。如果某个key因为一次批量任务被大量读取它就会占据链表头部把真正高价值的数据慢慢挤到底部导致缓存命中率下滑。应对方案有几类LFU最不经常使用按访问频率淘汰天然对突刺不敏感但实现和维护成本更高还可能有“历史频率压过新趋势”的问题。LRU-K / 2Q只有当页面在短时间窗口内被访问到第K次时才“升级”为热门页普通的一次性访问不会污染头部。自定义分层热数据层用LRU冷数据层用大容量但低优先级的存储通过分层把突刺流量隔离出去。另外真实项目里给LRU缓存加上过期时间往往比纠结用LRU还是LFU更有效。原因很简单很多“脏数据”其实不是因为活跃度不够而是因为时效性已过继续留在缓存里本身就是浪费。6.4 调整容量时要观察的指标把LRU系统接入生产环境前至少准备好这几个监控指标命中率hit ratio在业务高峰和低峰的变化曲线淘汰次数eviction count是否突然飙升平均访问延迟是否和命中率呈明显负相关缓存驱逐对象的存活时长也就是每个对象在淘汰前存活了多久。如果命中率很低不要急着改成LFU或者加内存。先看访问模式是不是存在大量一次性的key是不是有些key的TTL过短是不是容量设置得太小这些因素都会被误判成“LRU算法不行”。根据我自己的经历曾经一个部门间的公共缓存服务命中率只有60%出头加内存、换算法都试了一圈最后发现是调用方没设好key的粒度导致同一份数据被写入了几千个不同的key等于把LRU的容量全浪费在冗余数据上。修复后命中率直接跳到90%以上。这个教训值得记住面对性能问题先审视数据本身再动手优化算法。6.5 最后踩坑的一点不要迷信“完备实现”很多人学完LRU之后会认为只要按照教科书精确实现就一定比近似算法强。但真实系统常常对“精确”不买账。LRU依赖“过去”预测“未来”当访问模式发生漂移时比如业务从读多写少变成写多读少之前积累的LRU状态反而变成一种干扰不如定期重置、按新周期重新统计。我在实践中的习惯是对业务场景先做小范围A/B观测命中率、延迟分位数和资源消耗再决定是否在所有节点上推广。所谓“最优算法”一定是最匹配你业务数据特征的算法而不是理论上最漂亮的那个。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

从零构建大语言模型到推理模型:完整技术栈与实操路线 2026/9/29 11:22:53

从零构建大语言模型到推理模型:完整技术栈与实操路线

最近“ai-engineering-from-scratch”这个字头又频繁出现在技术社区的收藏夹里,连带《Build a Large Language Model (From Scratch)》也成了常被问到的内容,甚至有人专门来问我有没有电子书资源。每次遇到这种问题,我都会先反问一句&#xf…

阅读更多 →
【ARM 裸机开发 (IMX6ULL-mini)】SPI 协议与ADXL345 三轴加速度传感器 2026/9/29 11:22:52

【ARM 裸机开发 (IMX6ULL-mini)】SPI 协议与ADXL345 三轴加速度传感器

文章目录前言一、SPI基础概念二、SPI的时序三、IMX6ULL上的SPI3.1 概念及原理框图3.2 相关寄存器RXDATATXDATACONREGCONFIGREGSTATREG四、ADXL345加速度传感器五、SPI初始化六、SPI读写函数七、ADXL345与IMX6ULL通信八、UART、I2C与SPI对比前言 在【ARM 裸机开发 (IMX6ULL-min…

阅读更多 →
Commitizen交互式提交流程:告别混乱Git提交信息,让代码历史清晰可溯 2026/9/29 11:22:31

Commitizen交互式提交流程:告别混乱Git提交信息,让代码历史清晰可溯

见过太多这样的场景:代码写得漂漂亮亮,到了git commit这一步,随手甩一句fix bug或update就交差了。等三个月后真要回查某次改动,git log里全是fix xxx、update、temp这种信息,想定位一个具体功能变更,简直像…

阅读更多 →
Windows包管理器winget实战:安装、配置与进阶玩法 2026/9/29 11:22:25

Windows包管理器winget实战:安装、配置与进阶玩法

1. 开篇:为什么Windows用户都需要winget如果你还在用浏览器搜索软件官网、下载安装包、一路点击“下一步”的安装向导,那你真的该认识一下winget了。winget是Windows官方的包管理器,全称Windows Package Manager,简单理解就是Wind…

阅读更多 →
N.E.K.O.角色定制实战:从角色卡编辑到Live2D换装,打造独一无二的AI伴侣 2026/9/29 11:22:18

N.E.K.O.角色定制实战:从角色卡编辑到Live2D换装,打造独一无二的AI伴侣

N.E.K.O.角色定制实战:从角色卡编辑到Live2D换装,打造独一无二的AI伴侣 【免费下载链接】N.E.K.O A catgirl who lives with you in real time — reaching out first, sharing your media, and actually getting things done, powered by an embodied e…

阅读更多 →
从JDK到Maven:环境变量配置与镜像仓库避坑指南 2026/9/29 11:22:18

从JDK到Maven:环境变量配置与镜像仓库避坑指南

上周新同事入职,领了台新电脑,第一天就卡在装环境上。按网上一堆教程装完JDK,java -version能出结果,可一敲mvn -version就报"不是内部或外部命令"。这种问题我见得太多了,十有八九是环境变量PATH里少配了Ma…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉