
内存回收这个话题说实话是Linux内核内存管理里最容易让人头皮发麻的部分。日常你写业务代码malloc/free 一切正常压根不会去想背后发生了什么。但一旦碰到线上机器MemAvailable一路往下掉、kswapd 跑满一个核、某天凌晨 OOM killer 直接把你的服务干掉你就得回过头来啃这套 Linux 内核的内存回收机制了。这篇东西,是我把内存回收从源码到线上观察这条链路重新梳理一遍的结果面向的是那些已经会用 Linux、能看懂一点 C但还没把内核内存管理子系统彻底吃透的人。看完它你至少能明白为什么空闲内存还很多系统就开始回收、匿名页和文件页回收起来差在哪、swappiness到底调了个什么、以及线上内存告急时该怎么一步步定位。1. 内存回收机制的整体设计动机在动手翻任何一行源码之前先得把为什么要回收这件事想清楚。Linux 内核的内存管理子系统有一个很反直觉的设计目标它不希望系统里存在大量真正的空闲内存。闲置的内存就是浪费与其让它空着不如拿来做 page cache、buffer、slab 缓存加速磁盘 IO 和内核对象分配。这就直接引出了回收机制存在的根本理由——内存不是不够用而是看起来不够用内核需要在需要的时候把那些可以先借出去、以后再拿回来的内存挤出来。1.1 从 page cache 借内存说起理解这套机制最好的切入点就是 page cache。你cat一个大文件读到的数据并不会在 read 返回后立刻从内存里消失而是躺在 page cache 里。下次再读同一段直接命中内存速度差了几个数量级。问题来了内存总量是有限的一个整天读写大文件的机器page cache 会迅速把空闲内存吃光。这时候如果有个进程要malloc一大块内核拿什么给它答案是从 page cache 里抢。文件页有个天然优势——它是磁盘数据的副本脏了就先写回写回完成或者本来就干净直接丢掉即可下次要用再从磁盘读回来。这属于可回收内存。而匿名页进程堆、栈、malloc出来的没有磁盘上的备份要腾地方就得先写到 swap 或者压缩内存代价明显更高。内核把这两类页分得很清楚回收策略也完全不同这一点后面会反复提到。所以可以这么理解内存回收机制本质是一套内存再分配的调度器它决定了当有人急需内存时先去牺牲谁、后牺牲谁、什么时候该动用代价更高的手段。内核里真正用来衡量内存够不够的不是MemFree这个数字而是能用相对低代价回收出多少内存也就是/proc/meminfo里那个MemAvailable。1.2 两条回收路线直接回收与后台回收内核把回收分成了两条路线这是理解整套机制的关键分野。第一条是后台回收由每个 NUMA node 上一个叫kswapd的内核线程负责。它平时睡觉等到某个 zone 的空闲内存跌破一个叫low的水位线就被唤醒开始后台扫描 LRU 链表、回收页。它的好处是未雨绸缪在内存真正告急之前就把页准备好属于温和派。第二条是直接回收direct reclaim发生在分配路径上。当一个进程调用alloc_pages之类的接口申请内存结果发现连min水位线都守不住了而且 kswapd 也没能及时把内存腾出来那么这个申请者自己就得停下来亲自去扫描回收。注意这个自己是申请内存的进程它会被阻塞在回收逻辑里直到回收出足够的页或者彻底放弃——放弃的结果通常就是 OOM。这两条路线的代码入口不同但最终会走同一套核心回收函数shrink_node只是参数和可回收的目标量不一样。区分它们非常重要因为线上问题定位时是 kswapd 在忙还是业务进程被卡在直接回收里对应的优化方向完全不同。前者更多是整体内存水位问题后者往往是分配速度瞬间超过了回收速度属于突发压力。1.3 水位线min/low/high 三条线的设计哲学水位线这个概念光看名字容易迷糊其实设计得相当克制。每个 zone 都会根据内存大小算出三条线WMARK_MIN、WMARK_LOW、WMARK_HIGH。它们的职责划分是这样的水位触发动作典型行为跌破 high无kswapd 停止回收回到睡眠跌破 low唤醒 kswapd后台开始异步回收跌破 min直接回收分配者阻塞同步回收可能触发 OOMmin这条线是硬底线它下面还留了一小部分内存专门给某些不可阻塞的高优先级分配用正常分配不能随便动。low和min之间是留给 kswapd 的缓冲带让它有时间在内存耗尽之前把活干完。high则是 kswapd 的收工线回收超过 high 就停下避免过度回收把有用的缓存也清掉了。min的大小由min_free_kbytes决定而这个值在启动时会根据物理内存自动折算大致是内存开方再放大若干倍并设了上下限。low和high默认是相对min拉开一定间距间距大小受watermark_scale_factor影响默认值 10代表间距是内存大小的千分之一。这里有个很多人忽略的点watermark_scale_factor调大会让缓冲带变宽kswapd 更早介入直接回收概率下降但对小内存机器来说可能造成内存利用率降低。注意水位线是按 zone 计算的不是整机。一台有 Normal 和 DMA32 两个 zone 的 64 位机器上完全可能出现 Normal 区还很富余、但 DMA32 已经跌破 min 被频繁直接回收的情况。排查时一定要用/proc/zoneinfo逐 zone 看。2. 内存回收涉及的核心数据结构想真正读懂回收流程绕不开几个核心数据结构。它们构成了一张从全局内存到单个页逐层收敛的网回收逻辑就是在这张网上不断爬。我这里挑最关键的四个讲pglist_data、zone、lruvec和struct page里的几个标志位顺带把struct shrinker也捎上因为 slab 回收是另一条独立的线。2.1 pglist_data 与 zone内存的物理分层内核眼里内存不是一整块而是按 NUMA node 组织的。每个 node 对应一个struct pglist_data简称pgdat里面最核心的成员是一个struct zone数组即node_zones。常见的 zone 类型有ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_HIGHMEM32 位时代的东西、ZONE_MOVABLE。为什么非要分 zone因为硬件设备的 DMA 寻址能力有限比如某些老设备只能访问物理地址低 16MB 的内存那部分内存就必须单独圈出来不能被普通分配随便占满否则设备驱动申请 DMA 缓冲时就会失败。每个 zone 里维护着自己的空闲链表buddy system 的free_area、水位线_watermark、以及最重要的——LRU 链表和 lruvec。回收的基本单位就是 zoneshrink_node遍历 node 下的所有 zone逐个尝试回收。这也解释了一个常见疑问为什么内存回收不是全局统一的因为 zone 之间不能随便借用一个 zone 濒临耗尽即使另一个 zone 富余也救不了它。2.2 LRU 链表族四条链表的精妙平衡这是回收机制里最有意思的设计。每个 zone更准确说是每个 lruvec维护多条 LRU 链表核心是四条LRU_INACTIVE_ANON不活跃的匿名页LRU_ACTIVE_ANON活跃的匿名页LRU_INACTIVE_FILE不活跃的文件页LRU_ACTIVE_FILE活跃的文件页外加一条LRU_UNEVICTABLE专门放那些锁定页、mlock 的页回收永远不碰它。那为什么要分 active 和 inactive 两级这借鉴了二次机会second chance算法的思想。如果只有一条链表从头开始扫最近刚被访问过的热页和早就没人用的冷页混在一起命中率高的热页可能被误伤丢掉这就亏大了。于是内核把链表分成两段新页或者被访问过的页放 active回收时优先从 inactive 尾部最冷的一端下手。只有当 inactive 不够用才把 active 尾部的页降级deactivate到 inactive 里。页在链表之间怎么移动靠struct page上的PG_referenced和PG_active两个标志。页被访问时如果 PT 标志里发现它被引用过page_check_references会给它一个再活一次的机会把它从 inactive 提到 active一个 active 页如果在一个扫描周期里没被引用就被降到 inactive。这套机制让真正热的页能稳稳待在 active 里冷页则沉到 inactive 尾部等着被回收。2.3 lruvec 与 mem_cgroup按 cgroup 分开算账lruvec这个结构把 LRU 链表和内存 cgroup 绑在了一起。它里面还有一组 per-LRU 的计数器lruvec-lru_lock新内核改成了 per-memcg 的锁甚至 folio batched 操作。为什么要引入 mem_cgroup因为现代服务器上容器和 cgroup 是标配。如果回收时不区分哪个 cgroup 用了多少内存那某个失控的容器就可能把整台机器的内存挤爆然后连累所有邻居。所以内核支持按 memcg 定向回收shrink_node_memcgs会遍历 node 上的所有 memcg对每个 memcg 单独调用shrink_lruvec这样哪个 cgroup 超了自己扛不会无差别伤害。这带来一个非常实用的排查视角容器里出现 OOM但宿主机MemAvailable看着还行很多时候就是 memcg 级别的回收没能及时触发或者某个 cgroup 的 page cache 占用过高却没被算进压力里。这种问题在宿主机层面用free是看不出来的得直接看 cgroup 的memory.stat和memory.current。2.4 struct shrinker另一条回收战线LRU 回收管的是页slab 回收管的是内核对象。这两套是并行的。struct shrinker是内核组件注册进来的回调结构dentry、inode 缓存dcache/icache也就是SReclaimable那部分都通过它来响应内存压力。shrink_slab在回收时会按seeks权重去调用各个 shrinker 的count和scan回调能收多少收多少。这里的坑在于slab 回收的优先级和 LRU 回收不一样注册时给的seeks值越大代表这个缓存越不值钱越优先被回收。vfs_cache_pressure这个参数影响的就是 inode/dentry 缓存相对页缓存的回收倾向默认值 100 意味着按平衡比例来。这个参数调大内核会更积极回收内核对象缓存适合那种元数据操作超多、SReclaimable居高不下的场景调小了则相反。3. 可回收页与不可回收页的判定逻辑不是所有页都能被回收判定哪些能动、哪些不能动是整个机制里最需要小心的地方判错了就是数据丢失或者系统卡死。这一块的核心是三件事区分匿名页和文件页、处理反向映射、以及给脏页和锁定页留后路。3.1 匿名页与文件页代价天差地别的两类目标文件页file-backed是磁盘文件的缓存回收起来最便宜。它分两种状态干净的就直接丢脏的得先写回磁盘pageout调writepage或者走 writeback 队列写回完成后才能释放。文件页的回收几乎总是划算的因为大不了下次再读一遍磁盘而且磁盘上的数据是权威副本丢内存里的不会丢数据。匿名页anonymous就没有这个退路。它可能是进程的堆、栈也可能是共享内存。要回收它唯一可行的办法是把它写到 swap 分区/文件或者用一个压缩内存设备zram/zswap把它压起来。写入 swap 涉及磁盘 IO压缩涉及 CPU 开销都比丢文件页贵得多。所以内核的回收策略天然偏向先收文件页文件页收不够了再动匿名页。这个偏好的强弱就是swappiness在控制的东西。它不是一个开关而是一个比例参数——值越高内核越倾向于回收匿名页值越低越舍不得动匿名页。提示很多数据库和中间件场景建议把swappiness调低比如 1 或 10原因就是这些服务的匿名页往往比文件页更值钱一旦被换出去再读回来延迟抖动非常明显。但这不等于越低越好设成 0 只是尽可能不主动换出内存真到极限时还是可能换出别指望它能完全禁用 swap。3.2 反向映射回收一个页前必须问清谁在用回收一个物理页之前内核必须弄清楚有哪些进程的页表映射到了这个页然后把对应的页表项改掉、解除映射unmap否则你把页释放了进程地址空间里还指着一块被回收的物理内存那就是彻底的内存损坏。这套从物理页反查谁在引用我的机制就叫反向映射简称 rmap。实现上匿名页和文件页用了两种不同的组织方式。匿名页通过anon_vma挂在anon_vma_chain上一个 page 能通过它的mapping字段找到对应的一串 vma文件页则通过address_space里的i_mmap树区间树来反查所有映射了该文件偏移的 vma。回收时try_to_unmap就是沿着这些结构去遍历把 PT 项逐一提删。这个过程的代价不小一个被很多进程共享的页比如共享库的代码页、或者 fork 出来的大量进程共享的匿名页unmap 时要挨个改所有映射者的页表。所以共享度越高的页回收越慢越贵。这也是为什么 fork 出几百个子进程的进程内存回收压力会格外大——大量匿名页被几十上百个进程映射回收成本成倍增加。3.3 脏页回写、锁定页与不可回收页的处理判定完能被回收的页还有几类特殊情况要处理。脏页必须先写回内核不会直接丢弃脏文件页因为那等于丢数据。所以回收时会调用pageout把脏页扔给回写子系统处理自己不阻塞等待同步回收里会稍微参与一下。写回完成的页下一轮回收才能被真正释放。这就造成一个现象磁盘慢的时候脏页堆积回收扫了一遍又一遍可回收的量还是上不去。锁定页PG_locked、mlock过的、正在回写或正在被 IO 使用的页会被直接跳过归到LRU_UNEVICTABLE回收永远不碰。被引用次数异常高的页、正在被硬件使用的页同理。所以内核里那句活跃页的比例不能太高是有道理的——如果链表里绝大多数页都是 active 且都被引用着那回收能拿到的内存就很少get_scan_count会据此调整每个链表的扫描量尽量避免做无用功。注意如果发现/proc/vmstat里pgsteal_kswapd很低但pgscan_kswapd很高说明 kswapd 扫了很多却收获很少典型原因是可回收页比例太低——要么全是匿名页且 swap 不足要么大量页被锁定。这时候再加大回收力度也没用得从内存构成上找问题。4. 内存回收的完整调用链与实操理论讲完了接下来上干活的。这一章我把从分配内存失败到页被释放的完整调用链拉出来逐段解释每一步在干什么配合参数计算和实际观察手段让你能对着自己的机器跑一遍。4.1 分配路径触发回收slowpath 的入口分配页的正常快速路径是get_page_from_freelist它会检查zone_watermark_ok一旦发现水位不够就直接返回失败进入慢速路径__alloc_pages_slowpath。慢速路径里动作很多和回收相关的关键几步是先尝试用较高水位重试一次比如允许动用保留区能拿到就拿避免过早回收。调用wake_all_kswapds把各 node 的 kswapd 唤醒让后台先干起来。再次尝试分配如果还是不行就调用__alloc_pages_direct_reclaim申请者自己下场回收。回收后如果还失败且用尽了重试次数MAX_RECLAIM_RETRIES就考虑 OOM。这里能看出一个设计取舍内核总是先礼后兵先唤醒后台、再自己动手、实在不行才 OOM。所以__GFP_NOFAIL这类标志很危险它会让分配者无限重试回收稍不注意就把一个进程永久卡死。写驱动时如果你非用不可务必想清楚。直接回收的入口是__alloc_pages_direct_reclaim它内部会调__perform_reclaim把工作甩给do_try_to_free_pages。这个函数里有个scan_control结构控制了本次回收的目标——它要回收多少、优先收哪类页、能不能收匿名页、能不能回写脏页等等全在sc里。4.2 shrink_node 到 shrink_inactive_list 的完整走查do_try_to_free_pages的核心循环是遍历每个 node 调shrink_node。shrink_node先调shrink_node_memcgs按 memcg 粒度逐个回收对每个 memcg进入shrink_lruvec。这里是重头戏拆开看第一步get_scan_count决定本轮每个 LRU 链表各扫多少个页。它综合考虑了swappiness、匿名页和文件页的可回收比例、以及 priority 等级。priority 从 12 起DEF_PRIORITY每轮不够就减半减到 0 还没回收够说明压力极大。扫描量随 priority 降低而放大——优先小步试探不行就加大力度。第二步对每个非空链表调shrink_list。它先处理 active 链表如果是 active 页调shrink_active_list把尾部一些 active 页通过page_check_references判定后降级到 inactive。然后再处理 inactiveshrink_inactive_list是真正干回收的地方。第三步shrink_inactive_list里先把页从链表隔离isolate到本地临时链表然后调shrink_folio_list做实际的 unmap、写回、释放。隔离这一步很重要它短暂拿链表锁把要处理的页摘出来避免边扫边有人插页导致死循环。处理完再归还锁。第四步对每个页shrink_folio_list会判断类型文件页调pageout写回或丢弃匿名页如果没有 swap 直接跳回 active能回收的调try_to_unmap解除映射然后free_unref_page释放。整个过程中need_resched会被反复检查避免一次回收霸占 CPU 太久影响其它进程调度。这点在实时系统里尤其重要。4.3 swap 与回写代价估算和取舍匿名页回收能不能成很大程度看 swap 空间够不够以及值不值得换。内核不会盲换get_scan_count里有一段逻辑如果 swap 快满了就会主动降低匿名页的扫描权重因为换出去也收不回多少不如把力气花在文件页上。这就能解释一个现象——有时候系统 swap 用满了但匿名页依然回收不动其实是内核主动放弃了这块。回写这边脏文件页的回收和 writeback 子系统是联动的。如果回写队列堵了磁盘慢、队列深回收拿不到干净页就会一直空转。观察nr_dirty、nr_writeback和pgpgout增速能帮你判断是不是回写拖了后腿。现象可能原因处理方向回收扫得多收得少可回收页比例低调整内存构成、加 swap脏页迟迟不释放回写慢或队列堵排查磁盘、调 dirty 阈值匿名页换不出去swap 满或无 swap增加 swap 或 zram直接回收频繁分配速度超回收限制并发、扩容内存提示给回收相关参数调优前先跑一轮vmstat 1和/proc/vmstat采样把pgscan_*、pgsteal_*、pgmajfault的基线记下来。没有基线就没有优化的依据闭着眼睛调sysctl大概率是负优化。5. 观察手段与关键参数调优光看源码不够线上问题得靠观测数据说话。这一章给你一套可直接上手的工具组合以及几个最值得调的参数附上我实际使用中的经验值。5.1 从 meminfo 和 vmstat 读出回收状态/proc/meminfo里和回收最相关的是这几项MemAvailable真正能用多少、Active(anon/file)和Inactive(anon/file)各链表规模、Cached、Dirty、Writeback、SReclaimable可回收 slab。判断回收压力时我一般先看Inactive(file)是不是还有余量——它富余说明还有便宜内存可收压力可控。/proc/vmstat是真正的细粒度数据源。关键的几组pgscan_kswapd/pgsteal_kswapd后台扫描/回收量pgscan_direct/pgsteal_direct直接回收量这个数字持续增长要警惕pgactivate/pgdeactivate页在 active/inactive 之间搬运的次数pgmajfault主缺页次数涨得快说明页被换出后又频繁读回pswpin/pswpout换入换出页数我常做的一个判断如果pgscan_direct增速明显pgsteal_direct却很低说明业务进程被频繁拖进直接回收而且收效甚微这是性能杀手级别的信号必须处理。5.2 三个最值得动的水位与交换参数参数不少但真正值得调的并不多。下面这几个是我实践中最常用的参数默认作用调节建议vm.swappiness60匿名页回收倾向数据库/低延迟服务调到 1-10vm.min_free_kbytes自动决定 min 水位大内存机器适当调大别无限加vm.watermark_scale_factor10拉大 low/high 间距想减少直接回收可适度调大vm.vfs_cache_pressure100slab 回收倾向元数据密集场景调大关于min_free_kbytes新手有个坑以为调大就安全。它太大反而会让内核长期守着大量不可动用内存可用内存变少还容易触发回收。我的经验是除非有明确的分配延迟要求否则让系统自动算就好真需要改一般也不要超过物理内存的 5%。swappiness更是个玄学参数。我见过有人直接设成 0 以为能禁用 swap结果内存压力一来匿名页还是被换出去了——因为 0 只是尽量不换不是禁止换。想真正避免换出得配合 zram 让换出代价可控或者直接上大内存。5.3 用 tracepoint 抓回收现场想知道到底哪个进程触发的回收、回收了多少页ftrace是最直接的。内核里vmscan相关的 tracepoint 很全常用的有mm_vmscan_direct_reclaim_begin/end、mm_vmscan_kswapd_wake、mm_vmscan_lru_isolate、mm_vmscan_lru_shrink_inactive。开一段抓下来是这样的# 打开回收相关的几个 tracepoint echo 1 /sys/kernel/debug/tracing/events/vmscan/enable # 抓 10 秒 timeout 10 cat /sys/kernel/debug/tracing/trace_pipe /tmp/vmscan.logdirect_reclaim_begin事件里带着触发进程的 pid 和order能一眼看出是哪个进程在猛申请内存。lru_shrink_inactive里带着nr_reclaimed能看出每次回收实际拿到了多少。把这两者结合基本就能还原出一场内存压力事件的完整过程谁申请、回收了多少、有没有成功。注意trace 缓冲有大小限制压力大的机器上很容易被冲掉。建议先调大buffer_size_kb或者就针对性地只开一两个关键 tracepoint别一股脑全开。6. 常见问题与排查技巧实录前面都是道理这一章讲我踩过的坑和实际遇到过的几类典型问题。内存回收这块的疑难杂症很有规律摸清了基本能对症下药。6.1 常见问题速查表先把最常被问到的几个问题集中列一下方便你快速对照问题现象常见根因排查切入点空闲内存很多却开始回收page cache 占满MemFree 不准看 MemAvailable 而非 MemFreekswapd 长期占满一核内存水位长期偏低/proc/zoneinfo看水位业务进程随机卡顿被拖入直接回收/proc/vmstat看 pgscan_direct换出后性能骤降匿名页热数据被换出调低 swappiness、查 pswpin容器 OOM 但宿主机正常memcg 级回收没跟上看 cgroup memory.stat脏页迟迟不释放回写慢/队列堵查 nr_dirty、磁盘 IO这里头空闲内存很多却开始回收是最常见的困惑。根因就是前面说的MemFree只反映完全空闲的页而 page cache 被算在Cached里不属于MemFree。系统做了一段时间 IO 后MemFree很低很正常只要MemAvailable健康就没问题。别一看free输出里 free 那一列很小就慌。另一个常被忽略的是slab 泄漏伪装成内存不足。如果SReclaimable很小但SUnreclaim不可回收 slab持续增长那多半是某个内核模块申请的对象没释放这不是回收机制能解决的得揪出那个模块。slabtop是找这类问题的好帮手。6.2 内存泄漏与回收不动的排查思路线上报内存不足先别急着下单买内存按这个顺序走一遍第一确认是真不足还是假不足。MemAvailable到没过危险线如果MemAvailable一直健康那内存不足可能是某个进程的错觉比如它自己申请被限了。第二确认是哪类内存吃满。Active/Inactive里匿名页多还是文件页多如果是匿名页占大头且 swap 小那就是典型的匿名页回收不动加 swap 或者上 zram 是最直接的缓解。第三确认回收是不是在做无用功。看pgscan和pgsteal的比值比值太大说明扫得多收得少回归到内存构成问题。第四排查用户态泄漏。/proc/pid/status的VmRSS、/proc/pid/smaps能看清一个进程的真实占用。很多人把 page cache 增长误判成进程泄漏其实那部分随时可回收。第五如果确认是内核对象SUnreclaim在涨那就要查具体模块这一步难度陡增可能需要kmemleak之类的工具。我踩过的最大的一个坑是把一次 page cache 的正常增长当成了泄漏折腾半天才发现是某个日志服务在疯狂写文件、page cache 涨得飞快但系统压力其实没问题。所以先分清内存类型再谈泄漏能省下大量时间。6.3 几个反直觉的实操心得最后分享几个我实际调优中总结的、和直觉相悖的点。第一加内存不一定能解决直接回收问题。如果瓶颈在分配速度瞬时超过回收速度加内存只是把 OOM 时间往后推真正该做的是限流或者改用更省内存的数据结构。第二盲目调大min_free_kbytes经常是负优化。它守住的内存不计入可回收等于人为制造紧张尤其在容器环境里可能适得其反。第三swappiness调到极低反而可能引发更严重的卡顿。因为内核被逼着死磕文件页一旦文件页不够匿名页还是得换而且是在更被动的时候换延迟更不可控。留一点余地比如 10 而不是 0往往更稳。第四关注pgmajfault比关注 free 内存更有意义。它是用户能直接感受到的卡顿的量化指标涨得快说明页在被频繁换出换回这时候的优化收益立竿见影。调内存回收这块我的体会是别迷信单一参数也别指望一个 sysctl 解决所有问题。它是一个系统工程从内存构成、swap 配置、回写能力到应用的内存行为环环相扣。平时多采集基线、多观察vmstat和meminfo的变化趋势真出问题时你才有对比的锚点。我自己的习惯是在每台关键机器上加一个轻量监控每 10 秒记一次MemAvailable、pgscan_direct、pgmajfault这几个值真到告警时翻出历史曲线根因往往一眼就能锁定比事后手忙脚乱地去复现要高效得多。