LWN: 多代化之后的LRU!

这篇博客介绍了Linux内核中内存管理子系统的优化,特别是针对LRU(Least Recently Used)算法的改进。作者提出了一种名为multi-generational LRU的机制,通过增加更多的LRU列表来跟踪页面的年龄,以更精确地预测哪些页面可能不再被需要,从而提高内存利用率。这种方法减少了CPU时间的消耗,并在不同工作负载中表现得更为高效,尤其是在云环境和移动设备上。文章讨论了新机制如何处理匿名页和文件内容页的回收,以及如何通过优化页面扫描来减少开销。尽管这个改进是可选的,但如果证明其效果优于现有的LRU,未来可能会成为默认选项。

关注了就能看到更多这么棒的文章哦~

The multi-generational LRU

By Jonathan Corbet
April 2, 2021
DeepL assisted translation
https://lwn.net/Articles/851184/

memory-management 子系统的关键任务之一就是优化系统对可用内存的利用。这意味着需要把包含了不再需要的数据的 page 换出到别的地方去,从而能让这些 page 被更好的利用在其他地方。预测哪些 page 在不久之后会被访问到,这是一项棘手的任务。内核已经开发出一些机制希望提高猜对的几率。但是,内核不仅经常出错,而且很可能还耗费了大量的 CPU 时间来做出了错误的选择。Yu Zhao 发布的 multi-generational LRU patch set 就是希望改善这种情况。

一般来说,内核无法知道哪些 page 在不久的将来会被访问到,所以它必须依靠退而求其次的指标:最近被使用过的 page 的集合。最近被访问过的 page 有可能在未来会再次被使用,但也有例外。例如,假设有一个正在按顺序读取一个文件的应用程序。文件中的每一个 page 在被读取时都会被放入 page cache,其实应用程序已经不会再需要它了。在这种情况下,最近访问过,并不意味着该 page 很快会被再次使用到。

内核使用一对 least-recently-used(LRU,最少最近使用)的 list 来跟踪那些 page。最近被访问过的 page 都被保留在 "active" list 中,刚刚被访问过的 page 被放在 list 的头部。如果 page 最近没有被访问过,就从 list 的尾部移除出来,放在 "inactive" list 的头部。inactive list 中的 page 如果被某个进程访问到了,那么就会被再次加到 active list 中。有些 page,比如上面例子中的来自顺序读取文件所产生的 page,会一开始就在 inactive list 里面,这意味着如果没有后续使用的话,这些 page 会被较快地回收掉。

当然,还有更多的细节需要处理。尤其需要注意的是,这里实际上有两对 list,一对针对匿名页(anonymous page),另一对针对文件内容(file-backed)的 page。如果正在使用 cgroup 的话,那么每个 active cgroup 都有完整的这几个 LRU list。

Zhao 的 patch set 指出了当前方案的一些问题。分为 active/inactive,对于要想得到准确的决策来说,还是太粗糙了,而且 page 经常都被放到错误的 list 上了。尤其是在 cgroup 中使用了全套的独立 list,使得内核很难对各个 cgroup 中 page 的相对新旧程度进行对比。内核由于一些原因,长期以来偏向于优先把针对文件的(file-backed)page 给换出去(evict),这可能导致一些包含有用的文件的 page 被换出而空闲无用的匿名页仍然留在内存中。这个问题在云计算环境中变得更加严重。这种场景下,每个客户的本地存储(local storage)相对较少,因此导致,文件支持(file-backed)的 page 相对来说本来就很少。同时,匿名页的扫描会比较耗时,部分原因是因为它使用了复杂的反向映射(reverse-mapping)机制,当需要进行大量的扫描时,这个机制的表现并不理想。

Closing the generation gap

multi-generational LRU patch 希望试图通过两个底层改动来解决这些问题。

  • 增加更多的 LRU list,目的是不仅只分为 active 和 inactive list,而是将两者之间的 page age 范围也涵盖起来。这些 list 就被成为“generations(代)”

  • 改变 page scanning 的方式来减少其开销。

最近使用过的 page 会被分配到最年轻的一代(尽管有一些例外情况,后面有描述)。随着时间的推移,内存管理子系统会扫描各个进程的 page,以确定每一个 page 自从上次扫描之后是否被使用过。那些一直闲置的 page 就会被移到下一代的 list 去,表示有一段时间没有用过了。任何一代中的 page 如果有发生过访问,就会被移回最年轻的一代的 list 中。

这项工作最后产出的是一个根据 page age 年龄来得到的多组分布,既有那些最近被访问过的 page,又有那些在一段时间内没有被使用的 page。支持多少代,可以通过内核配置来设置。对于手机场景来说,这个数字似乎可以小到 4 个,而对于云服务器来说,数字则应该是这个数字的几倍。

当需要回收 page 时,只需要考虑最老的这一代。对于匿名页和文件内容(file-backed)page 来说,"最老的一代" 可能是不同的。匿名页一般来说更难回收(通常它们总是需要被写入 swap 中),于是这个新功能的代码中会更加偏向于更积极地回收 file-backed page。因此,file-backed page 可能不会像匿名页面那样能经过那么多代的 list 而未被回收掉。不过,目前的 patch 中对 file-backed page 的回收只允许比匿名页回收提前一代。

据称,multi-generational 机制比目前的双链表方法更加准备。当一个页面进入最老的一代时,比起 inactive list 中的页面来说,它更加可能不再被需要了。这反过来意味着这些 page 可以被更积极地回收掉,能拿到更多的内存来用于真正使用它的任务。这种机制允许随时比较匿名页和 file-backed page 的年龄,通过跟踪每一代的创建时间,来比较不同 cgroup 中 page 的年龄。当前的内核中这个信息并没有持续记录下来。反过来,这也使得识别和回收闲置的匿名页变得更加容易。

另一个号称具有的优势是改变了 page 的扫描方式。page 是通过每个进程中的 page-table entries(PTE,页表项)来访问的,这些 PTE 中就含有这个 "最近访问过" 的标志位。不过,目前内核是通过 page 本身进行扫描的,必须使用反向映射来寻找并检查相关的 PTEs,这个操作很耗时。而 multi-generational LRU 代码则直接扫描 PTE,这种方法具有更好的本地性(locality,意味着很少有全局影响)。scheduler 中的一个 hook 则可以帮助跟踪自从上次扫描以来实际运行过的进程,因此可以跳过那些没有执行过的进程。

multi-generational LRU 还受益于跳过了当前内核中用于决定哪些页面应该被回收的许多启发式方法的做法。不过,还是有一些保留了。比如,当一个 page 被首次建立时,就会根据这些规则来决定它应该被安排在哪一个 generation。

  • 当前由于发生 fault 而引入的 page,会被分配到最年轻的一代,这是大家公认的合适做法。

  • 未被映射的 page(也就是驻留在内存中但没有 PTE 指向它,这些 page 包括被选定要回收、但被再次引用之前实际上尚未被回收的 page)如果被再次引用,就会加到第二年轻的这一个 generation。这样做似乎是为了避免使最年轻的一个 generation 包含太多 page 从而延迟后续的 page scan 到创建下一代的时候。

  • 正在被回收(reclaim)的 page,但在其内容完全被写入后备存储之前必须要一直保持可访问,这些就会被添加到第二代。这可以防止在回写(writeback)过程中再次尝试回收它们。

  • 正在被停用(deactivated)的 page 会进入最老的一代。那些由 readahead 机制带来的 page 也会被放到这里,毕竟读取这些 page 其实是内核的一种投机预测行为,并不能保证它们会有用武之地。

有一些提供给 user space 的开关可以控制这个机制,包括可以完全关闭 multi-generational 代码。更多信息请看这个documentation patch。

Generational change

据说,在完成所有这些工作之后,page reclaim 比以前更有效、更有针对性。像安卓这样的系统,在使用这种方案时,可以看到有更少的 low-memory kill(指应用程序进程由于内存不够而被杀死),Chrome OS 也测到更少的 out-of-memory kill,服务器系统也能更好地利用可用内存。这看起来是一个全面的改进。

鉴于此,人们可能会感到疑惑,为什么 multi-generational 算法要与内存管理代码的其他部分分开,并且还是 optional(可选的)。本质上,它是一种完全独立的 page aging and reclaim(内存页老化和回收)方法,跟当前的 LRU list 是并列存在的关系。据推测,分离开来的原因是有很多 workload 可能不会从这种方案中得到好处。我们需要做更多的测试,从而以了解 multi-generational LRU 在哪些方面会出现问题,以及需要做什么来防止这种情况发生。

multi-generational LRU 最终可能会赢得内存管理开发者的支持,他们中的大多数人还没有对这个 patch set 发表意见。不过,它还是需要在大部分 workload 中能表现出更好的性能(或者至少是没有性能降低),才能让大家赞同它可以作为当前 LRU 的替代品,而不是仅仅是 LRU list 的补充。维护两个独立的 LRU 方案,会给内核社区带来不少困难。如果 multi-generational LRU 真的更好,那么完全切换到过去会是更好的选择。

要想回答清楚这个问题,肯定会有一个漫长的过程。memory-management 这边哪怕较小的改动也会需要不少时间才能合入。因为这里的改动非常可能给某些用户带来性能损失。这个变化不是 "relatively small" (改动相对不大)的,所以要想得到合入,标准会更高。但是,如果 multi-generational LRU 真能达到它所说的水平的话,它最终还是应该能达到这个标准的。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~

<think>好的,用户让我介绍一下页面替换算法,特别是LRU,要求用通俗易懂的方式,可能还要举例子或者类比。用户是Java高级开发,所以可能需要结合一些实际应用或者代码层面的东西。首先,我得回忆一下页面替换算法的基本概念,确保自己理解正确。 页面替换算法通常用于操作系统的内存管理,当物理内存不足时,需要将部分页面换出到磁盘,这时候就需要算法来决定换出哪个页面。LRU是其中一种常用的算法,全称是最近最少使用,也就是选择最近最久未被访问的页面进行替换。 接下来要考虑如何用通俗的方式解释。可能需要用类比的方法,比如图书馆的书架,空间有限,当新书进来时,需要决定把哪本旧书放回仓库。LRU的话,就是查看哪本书最近被借阅的时间最久远,就替换那本。这样的例子应该比较容易理解。 用户是Java开发者,可能需要一些代码层面的例子。比如在Java中,LinkedHashMap可以实现LRU缓存。可以简单提一下实现原理,比如通过双向链表维护访问顺序,最近访问的放在头部,最久未访问的在尾部,需要替换时删除尾部的条目。 还要注意用户之前的问题是关于红黑树的,可能对数据结构有一定了解,所以在解释LRU实现的时候,可以提到数据结构的选择,比如哈希表加双向链表,这样查找和插入都是O(1)的时间复杂度。 另外,用户提到“尽可能详细”,所以可能需要分步骤讲解LRU的工作原理,或者不同类型的页面替换算法,比如FIFO、OPT、LFU等,但用户主要关注LRU,所以重点放在LRU上,其他算法可以稍微提一下作为对比。 还要考虑LRU的优缺点,比如实现相对复杂,需要维护访问顺序,但比FIFO更高效,能够减少缺页中断。可能还要提到在实际系统中的使用场景,比如数据库缓存、Web服务器缓存等。 最后,是否要给出一个Java实现的例子?比如用LinkedHashMap实现一个简单的LRU缓存,并说明如何覆写removeEldestEntry方法。这样可以让用户更直观地理解,同时符合其Java开发者的身份。 需要检查是否有错误,比如LRU的实现是否真的用双向链表和哈希表,LinkedHashMap的内部结构是否如此,确保技术细节准确。同时,例子是否恰当,是否容易理解,是否需要更多比喻或实际应用场景的描述。 总结下来,回答的结构应该是先介绍页面替换算法的背景和用途,然后解释LRU的原理,用图书馆的例子类比,再讲解实现方式,数据结构的选择,Java中的LinkedHashMap应用,最后给出优缺点和应用场景。这样既全面又符合用户的需求。</think>作为Java高级开发工程师,我来用开发视角为你拆解LRU页面替换算法的精髓(含Java实现原理)。我们从三个维度展开: --- ### **一、生活比喻理解(快递驿站场景)** 假设你是驿站管理员: 1. 驿站货架最多存放5个包裹(内存容量固定) 2. 新包裹到来时: - 若货架有空位 → 直接存放 - 若货架已满 → 必须淘汰最久未被查询的包裹 3. 每次查询包裹时: - 被查询的包裹要标记为"最新使用" - 包裹顺序动态更新(类似链表节点移动) **示例流程**: ``` 货架容量=3 | 包裹序列:A → B → C → A → D 操作记录: 1. 存入A → [A] 2. 存入B → [B, A](新包裹放头部) 3. 存入C → [C, B, A] 4. 查询A → [A, C, B](被访问的移到头部) 5. 存入D → 淘汰B → [D, A, C] ``` --- ### **二、技术实现原理(数据结构选择)** 在Java中实现高效LRU需要两个核心组件: #### **1. 哈希表(快速查找)** - 使用`HashMap`实现O(1)时间复杂度的数据定位 - 存储键值对:Key → 链表节点指针 #### **2. 双向链表(维护访问顺序)** ```java class Node { int key; int value; Node prev; Node next; public Node(int key, int value) { this.key = key; this.value = value; } } ``` - **头部**:最近被访问的节点 - **尾部**:最久未被访问的节点 - **操作API**: - `addToHead(Node node)` // 新增节点时使用 - `removeNode(Node node)` // 删除指定节点 - `moveToHead(Node node)` // 访问已有节点时触发 - `popTail()` // 淘汰最久未使用节点 --- ### **三、Java标准库实现(LinkedHashMap源码解析)** Java内置的`LinkedHashMap`可直接实现LRU缓存,关键在构造函数: ```java public class LRUCache<K,V> extends LinkedHashMap<K,V> { private final int capacity; public LRUCache(int capacity) { // accessOrder=true表示按访问顺序排序 super(capacity, 0.75f, true); this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return size() > capacity; // 触发淘汰的条件 } } ``` **源码关键设计**: 1. **访问顺序维护**: ```java void afterNodeAccess(Node<K,V> e) { // 访问后移动节点到头部 LinkedHashMap.Entry<K,V> last; if (accessOrder && (last = tail) != e) { // 调整链表指针... } } ``` 2. **插入策略**: ```java void afterNodeInsertion(boolean evict) { // 插入新节点后检查淘汰 LinkedHashMap.Entry<K,V> first; if (evict && (first = head) != null && removeEldestEntry(first)) { // 移除最旧节点... } } ``` --- ### **四、开发实践要点** #### **场景选择** - 适合:热点数据集中(如电商首页商品信息) - 不适合:数据均匀访问(此时LFU更优) #### **性能优技巧** ```java // 使用WeakHashMap避免内存泄漏(配合ReferenceQueue) Map<Key, SoftReference<Value>> cache = new LinkedHashMap<>(MAX_ENTRIES, 0.75f, true) { // ... 实现淘汰策略 }; // 并发场景使用ConcurrentLinkedHashMap(第三方库) Cache<Key, Value> cache = CacheBuilder.newBuilder() .maximumSize(1000) .concurrencyLevel(4) .build(); ``` #### **常见面试问题** 1. **为什么不用单向链表?** 答:删除中间节点时需要前驱指针,双向链表操作更高效 2. **时间复杂度的保证?** 答:哈希表O(1)查找 + 链表O(1)节点调整 → 整体O(1) 3. **如何应对突发流量?** 答:可结合TTL过期策略,例如: ```java LoadingCache<Key, Value> cache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<Key, Value>() { public Value load(Key key) { return generateValue(key); } }); ``` --- ### **五、算法对比表** | 算法 | 实现复杂度 | 命中率 | 典型应用场景 | |------|-----------|--------|-------------| | LRU | ★★★☆ | ★★★★☆ | 通用缓存系统 | | FIFO | ★★☆☆ | ★★☆☆ | 简单队列管理 | | LFU | ★★★★ | ★★★★☆ | 长期热点数据 | | Clock| ★★★☆ | ★★★☆☆ | 操作系统内存 | 通过这样的多维度解析,相信你已经对LRU算法有了立体的认知。在实际开发中,建议根据具体业务特点选择合适的淘汰策略,必要时可组合多种算法(如LRU+TTL)。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值