ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内存管理核心解析:虚拟内存、malloc与OOM Killer

Linux内存管理核心解析:虚拟内存、malloc与OOM Killer 1. 为什么每个Linux学习者都要啃内存管理这块硬骨头我在接触Linux的前两年一直觉得内存管理是内核开发者才会关心的事情自己写点C代码、跑跑服务内存这东西系统自己管不就行了直到有一次我自己维护的一个后台服务莫名其妙被OOM Killer干掉排查了一天也没找到是谁吃掉了内存才老老实实回头补课。这件事让我明白不懂内存管理你在Linux上排查问题时就是半个瞎子。这篇文章是Linux学习笔记系列里关于内存管理与访问的部分我会用自己的学习路径来组织内容从虚拟内存和物理内存的映射关系讲起再到进程地址空间的布局、内存访问权限的控制、malloc背后的分配机制最后落到实际排查内存问题的方法论。内容偏实践但该讲的原理也会讲透彻适合正在学习Linux的开发者、运维工程师以及准备Linux面试的求职者。内存管理这个主题的覆盖面非常大一篇文章不可能面面俱到。我尽量把最核心、最常见、面试和实战中最高频的部分讲深讲透每个主题都配了可以直接敲的命令和思路。建议你打开一台Linux机器跟着操作一遍只看不敲效果会差很多。2. 虚拟内存与物理内存先搞懂这层映射关系2.1 为什么需要虚拟内存先思考一个问题假如一个程序可以直接读写物理内存会出现什么情况程序A可以随意读到程序B的内存数据程序C不小心改写了内核的数据区整个系统直接崩溃。更麻烦的是多个程序同时运行时物理内存的分配会变得像抢座位一样混乱谁先申请谁占位程序一多内存碎片化会严重到连连续的大块内存都申请不到。虚拟内存就是为了解决这些问题而存在的。每个进程都拥有自己独立的虚拟地址空间在32位系统上是4GB64位系统上则是极其巨大的地址范围。进程以为自己独占了一大片内存实际上这些地址并不直接对应物理内存而是要经过一层映射由内核和硬件配合完成。这层映射机制的核心就是内存管理单元也就是常说的MMU。当你访问一个虚拟地址时MMU负责查出这个虚拟地址对应的物理地址在哪然后真正去读写物理内存。如果虚拟地址没有对应的物理页就会触发缺页异常内核来处理分配物理内存、建立映射或者直接报段错误。2.2 分页机制和页面大小虚拟内存和物理内存的映射不是按字节进行的而是按页为单位。页的大小在x86架构上通常是4KB也可以通过内核配置开启更大的页面比如2MB或1GB的HugePages。为什么要按页映射想象一下管理一座城市的快递配送如果每次配送都按单件包裹分别规划路线效率会很差。更合理的方式是先把同一片区域的包裹打包成一个配送批次。分页就是这个思路以固定大小的页为单位管理映射关系每条映射记录既简洁又高效。Linux内核通过struct page结构来管理物理内存中每一页的状态:/proc/meminfo里展示的总内存、空闲内存、缓存占用等指标本质上都是对页的状态和数量做统计。所以你在看内存指标时心里要有一根弦这些数字背后都是一个个物理页的状态统计。2.3 页表和TLB的工作原理虚拟地址到物理地址的映射关系存储在页表中。每个进程都有自己的页表页表的每一项称为PTEPage Table Entry里面记录了物理页帧号以及该页的权限属性比如可读、可写、可执行。但页表本身也存在内存里每次访问内存都去查一次页表性能会非常差。CPU里有TLBTranslation Lookaside Buffer这个专用缓存专门缓存最近使用的虚拟地址到物理地址的映射关系。命中TLB时CPU不需要真正遍历页表直接就能完成地址转换。这就是为什么像Redis这种高性能服务或者某些数据库特别注重局部性。局部性好意味着TLB命中率高。有些服务为了降低TLB压力还会刻意开启HugePages用更大的页面来覆盖同样的内存区域这样需要的页表项数量大幅减少TLB的覆盖范围就变大了。我在实际调优中遇到过这样一个场景一个搜索服务的内存占用约30GB默认4KB页面时仅页表开销就有几十MBTLB命中率不理想。开启HugePages2MB页后页表数量从数百万条降到一万多条整体性能提升了几个百分点。对于大内存的在线服务来说这个优化思路非常值得尝试。3. 进程地址空间到底长什么样3.1 从0到高地址的完整布局每个进程的虚拟地址空间不是一整块混沌的区域而是被内核清晰地划分为多个段。我最初学习时觉得这些段的名字很难记后来用写程序的类比把它们串起来了。一个C程序编译后最早被加载进内存的是代码段这里面放的是机器指令。紧接着是数据段放的是已经初始化的全局变量和静态变量。再往后是BSS段放的是未初始化或初始化为0的全局变量BSS段本身不占可执行文件的空间只在内存中清零。从BSS段往上就是堆区域堆是动态分配的领地向上增长。然后是内存映射区域动态库、mmap映射的文件、线程栈都在这个区域。栈区域从高地址向下增长每一次函数调用都会往栈上压入返回地址、局部变量等信息。最高处的地址属于内核空间用户态代码无法直接访问。这里就是系统调用要切换上下文才能操作的地方。理解了这些布局再去看pmap的输出就会觉得非常清楚。3.2 用pmap和/proc/pid/maps实测理论说再多不如实测。随便找一个Linux进程用pmap查看它的地址空间$ pmap 12345 12345: ./test_program 0000556a1d6c9000 8K r---- test_program 0000556a1d6cb000 4K r-x-- test_program 0000556a1d6cc000 4K r---- test_program 0000556a1d6cd000 4K r---- test_program 0000556a1d6ce000 4K rw--- test_program 0000556a1d6cf000 132K rw--- [ anon ] ...每一行都对应一个虚拟内存区域权限位的含义是rread、wwrite、xexecute、pprivate、sshared。你还会注意到同一个可执行文件被映射成多个段分别赋予不同的权限代码段是可读可执行但不可写数据段是可读可写但不可执行。这正是地址空间布局设计的精髓也是内存访问控制的基础。想观察得更精细时可以直接查看/proc/PID/maps文件它和pmap展示的内容类似但每一行还会附加上与文件的具体偏移点556a1d6cb000-556a1d6cc000 r-xp 00001000 08:01 262147 /home/user/test_program这一行说明该区域映射的是可执行文件从偏移0x1000开始的内容权限为只读执行属于代码段。配合这些信息排查程序崩溃时就能精确定位到访问了哪个文件、哪个偏移处的代码。3.3 栈为什么向下增长不少初学者会问为什么堆向上长、栈向下长一个比较直观的解释是早期设计者希望它们相向而行尽量共用一段地址空间区域避免预先划分固定大小导致一方用尽另一方却浪费。虽然现代Linux下栈的大小通常有明确限制ulimit -s查看堆也有独立的映射区域但从历史沿革看这种相对布局确实能更灵活地利用空间。更重要的是栈向下增长可以配合栈溢出检测机制。当栈溢出时数据会往低地址方向覆盖如果编译器插入了栈保护值stack canary就能在返回前检测出缓冲区溢出。这个保护值放在栈帧的高位与返回地址之间任何越界写入都会先破坏它导致程序在函数返回前主动崩溃而不是被攻击者劫持控制流。我见过很多刚学Linux的同学用gdb调试段错误时看到bt显示访问了某个奇怪的地址就无从下手。如果你能先想清楚这个地址落在进程地址空间的哪一段问题往往就清晰多了。4. 内存访问权限不是每块内存都能随便动4.1 页表项里的权限位内存访问权限控制的核心并不神秘它就是页表项里几个标志位在起作用。每个PTE都记录着这一页是否可读、可写、可执行以及当前是否在物理内存中。当CPU访问某个虚拟地址时MMU在转换地址的同时会检查访问类型是否和页表项里的权限一致。比如你试图对一个只读页执行写操作MMU会直接触发保护异常内核随即向进程发送SIGSEGV信号。这就是段错误的底层来源。在x86架构上NXNo-eXecute位还可以标识某页是否允许执行代码。经典的缓冲区溢出攻击之所以会被现代系统防住很大程度上就是因为栈和堆默认不可执行攻击者把shellcode注入数据区域也跑不起来。你可execstack命令实验一下把栈改为可执行后老式攻击方式又能跑通了也就能理解系统默认配置的良苦用心。4.2 缺页异常与按需分页内存访问的另一个关键点是虚拟内存中的页面并不一定真的在物理内存中。内核大量使用按需分页的机制当你申请了大量内存但尚未使用任何一部分时内核只为这些区域建立虚拟地址映射不真正分配物理页。直到进程第一次读写某个虚拟地址时CPU发生缺页异常page fault内核才会在异常处理中分配物理页、填充页表项然后让进程继续执行。这样做的好处非常明显许多程序启动后会申请很多内存但实际使用的往往只是一小部分。按需分页保证了物理内存不被浪费。这个机制也解释了为什么有时候你看到进程的VIRT虚拟内存占用高达几十GBRES常驻内存却很小。比如用Java启动一个分配了8GB堆的JVM系统并不会立刻实际占用8GB物理内存而是等代码真正访问到对应堆区域时才逐步分配物理页。缺页异常还可以进一步分为轻微缺页和严重缺页。轻微缺页指目标页面已经在物理内存中只是页表项还没有建立严重缺页指页面需要从磁盘换入比如程序刚启动时访问代码段触发磁盘I/O读取可执行文件内容。4.3 写时复制技术大量节省了内存写时复制Copy-on-WriteCOW是内存管理中一个非常实用且巧妙的机制。最典型的应用场景就是fork()系统调用。传统的fork()如果不加优化需要把父进程地址空间里的所有数据完全复制一份传给子进程。几十GB的进程做一次fork光是复制内存就要卡顿很久。Linux采用COWfork时子进程和父进程共享同一批物理页页表项统一标记为只读。只要双方都只读就不需要任何复制。一旦父进程或子进程尝试写入某个共享页MMU检测到只读页被写入触发缺页异常。内核在异常处理中判断这个页面是COW页于是分配一个新的物理页复制原来的内容然后把双方各自的页表项指向各自的物理页权限恢复为可写。这样一来fork()几乎成了常数级操作只有在实际发生写入时才复制对应页面内存使用效率大幅提升。这个机制对理解共享内存、容器技术也有帮助。子进程启动时占用的虚拟内存看着很大实际物理内存占用很少原因就在COW。我在参考容器内存占用时也会刻意关注触发COW之后页面的逐页复制避免低估高频fork写入场景下的内存增长。5. 堆内存申请malloc、brk和mmap的幕后分工5.1 brk与mmap两条分配路径用户态程序调用malloc()时libc通常是glibc并不是每一次都直接调用系统调用申请内存而是先从自己维护的内存池里找空闲块。内存池空了才会向内核申请更多内存。glibc有两条向内核申请内存的路径。对于较小的分配主要使用brk系统调用它调整程序数据段的末尾位置称为program break把堆向上扩展。路径简单、开销小而且地址连续。但对于大块分配brk方式有缺陷堆顶的内存释放后收缩也不容易容易造成内存无法归还给系统。因此当请求的大小超过某个阈值通常是MMAP_THRESHOLD默认128KB时glibc直接使用mmap系统调用独立映射一段匿名内存。mmap分配的块在munmap后能立即释放回内核。简单记就是小块靠堆扩展brk大块靠文件映射式分配mmap。阈值通过glibc的M_MMAP_THRESHOLD可以调整。5.2 用strace看malloc的真实行为想验证这套机制可以写一个简单C程序配合strace查看系统调用序列#include stdio.h #include stdlib.h #include unistd.h int main() { char *small malloc(1024); char *big malloc(1024 * 1024); printf(small%p big%p\n, small, big); pause(); return 0; }编译后运行在另一个终端用strace跟踪$ strace -f -e tracebrk,mmap,munmap ./heap_test brk(NULL) 0x5639a3ac4000 brk(0x5639a3ae5000) 0x5639a3ae5000 ... mmap(NULL, 1105920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 0x7f8c82e8b000你会看到小内存分配先触发brk扩展堆大内存分配则走mmap。实际运行中进程启动时的动态链接器还会做很多其他mmap。通过这个实验就能直观感受到分配器的内部路径。5.3 malloc分配器的几大坑既然理解了malloc背后的机制一些著名的坑也就能说清了。第一个坑是内存碎片。即使是mmap处理大块小块分配基本都走堆扩展。堆内频繁地申请和释放小块内存可能造成外部碎片。glibc的ptmalloc分配器会把释放的空闲块按大小分级放到bins里但碎片严重时heap会被切得支离破碎无法合并成更大的连续空闲区。长生命周期服务最好减少频繁申请并释放大量不同大小的小对象的行为。第二个坑是内存的延迟归还。malloc申请了内存free掉之后这些内存并不一定立刻还给操作系统。尤其是小块内存glibc为了效率会把它们留在进程的内存池中。你以为内存占用下降了可能在系统层面看RSS并没有怎么降。不要用free后立即观察RSS的方式判断是否存在内存泄漏需要持续观察趋势。第三个坑是malloc(0)。这个在很多面试题里被反复问过。不同libc版本的实现有差异返回NULL也不是标准允许的所以不要依赖malloc(0)的行为做逻辑判断。我见过有同事写的代码里malloc(0)返回了非NULL指针后续写字节越界最终导致神秘的堆破坏。这种边界行为必须做防御性处理。5.4 和jemalloc等分配器的对比glibc的ptmalloc在并发场景下的表现并非最优。多线程频繁申请内存时主分配区main arena会成为瓶颈虽然ptmalloc引入了per-thread arena来优化但arena数量达到一定倍数后不再增加线程间仍可能竞争。像jemalloc、tcmalloc这类分配器则针对多线程做了更多设计。jemalloc按CPU核数划分arena每个线程绑定局部缓存无锁路径占比更高碎片的控制也更激进。如果你的服务有多线程高并发、大量小对象分配的特点切换到jemalloc往往能带来明显的吞吐提升。做法很简单安装jemalloc后通过LD_PRELOAD加载$ LD_PRELOAD/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./your_server很多高性能中间件默认就使用jemalloc比如Redis的某些构建方式。遇到性能问题时把分配器换掉是排障清单里很靠前的一步代价小、效果好。6. 系统级内存观测free、top、vmstat这些命令到底在看什么6.1 free命令各列的含义在你还没有掌握内存观测命令之前盲目去看top和free的输出很容易被带偏。free命令的输出虽然简洁每一列背后都有讲究$ free -h total used free shared buff/cache available Mem: 31Gi 12Gi 2.5Gi 174Mi 16Gi 17Gi Swap: 8Gi 0B 8Gi很多人看used很高就以为内存不够用这其实是对buff/cache的误解。buff/cache是内核把空闲内存用来做页缓存和缓冲区的那部分进程需要时可随时被回收再分配给程序。判断系统内存是否紧张应该看available这个值它估算的是在不主动交换的情况下还能满足多少新内存申请。如果available长期很小free也趋近于零才说明系统真的在内存压力下。不要一看到cache占用几十GB就慌那是Linux在物尽其用。6.2 VIRT、RES、SHR分别是什么top或htop里进程的内存列有很多关键的是VIRT、RES、SHR这三列。VIRT是进程的虚拟内存总大小包含尚未真正分配的页面、映射的共享库、文件映射等。RES是常驻物理内存大小即这个进程当前实际占用的物理页数量。SHR是共享内存大小包括动态库中与其他进程共享的页面。判断某个进程真正的内存开销得看RES而不是VIRT。VIRT动辄几十GB可能是预留了很大的虚拟空间但没有实际使用。我遇到过有人拿VIRT去投诉某个进程内存泄漏拓了一整圈发现只是线程栈空间和mmap预留区域大完全不是问题。6.3 procfs里有哪些宝藏除了free和top/proc/meminfo也是排查内存问题时的宝库。其中比较常用的字段MemTotal MemFree MemAvailable Buffers Cached SwapCached Active(anon) / Inactive(anon) Active(file) / Inactive(file) Dirty Writebackanon表示匿名页比如进程堆和栈file表示文件页比如页缓存。如果你的服务内存持续增长但文件页占比不大问题通常出在匿名内存的分配和释放上。对应进程级别cat /proc/PID/status里的VmRSS、VmSwap、RssAnon、RssFile等项目能快速定位进程的物理内存构成。排查一个进程是否因为swap导致性能下降时重点看VmSwap不为0的大户。6.4 强制回收缓冲区的注意事项当你的Linux机器内存几乎全被buff/cache占满而available又很低时可以用drop_caches尝试让内核回收页面。这个操作在生产环境要非常谨慎# 清页缓存 echo 1 /proc/sys/vm/drop_caches # 清dentries和inodes echo 2 /proc/sys/vm/drop_caches # 清上述所有 echo 3 /proc/sys/vm/drop_caches在业务高峰期执行drop_caches会导致大量已缓存的数据重新访问磁盘性能瞬间变差。更稳妥的做法是观察并定位是哪个进程在持续申请内存或者调整内核参数提升内存回收的积极性而不是一味靠手动清缓存。6.5 vmstat中的swap读写信号vmstat输出里的siswap in和soswap out是衡量系统是否在频繁换页的关键指标$ vmstat 1 procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 0 0 260000 512000 1200000 200 150 500 300 1 2 15 8 77 0 0si、so长期不为0说明物理内存已经不足系统在把进程页面换到磁盘。这时候即便CPU使用率不高应用响应也会变慢。因为磁盘I/O的速度和内存不在一个数量级。遇到这种情况先升级内存或降低服务的内存占用同时检查是否有大内存分配后频繁访问的代码路径。我自己的建议是处理线上内存不足但CPU低的问题时第一件事先看si/so和vmstat的wa。如果swap读写高优先思考如何让数据尽量留在物理内存里。为了减少swap带来的性能抖动有些服务会直接用mlock锁定关键内存页但使用时要区分锁定后是否会导致物理内存碎片化。7. 内存压力下的系统行为回收、交换与OOM Killer7.1 内核怎么决定先回收哪些页面物理内存紧张时内核需要回收一部分页面供新请求使用。它首先选择回收的是那些可以随时重建的文件页比如页缓存。如果文件页还带着脏数据就要先写回磁盘。匿名页没有对应的磁盘文件回收时只能换到swap分区或swap文件这个过程叫交换。交换比回收文件页代价高得多所以内核通常把匿名页和文件页分开管理优先回收文件页。内核会维护一组LRU链表来跟踪页面的活跃度大体可以分为活跃链表和不活跃链表。页面常被访问就进入活跃链表长时间没被访问就逐步降级到不活跃链表成为回收的候选项。手动调整swappiness参数可以改变内核把匿名页交换出去的倾向# 查看 cat /proc/sys/vm/swappiness # 临时改为10 sysctl vm.swappiness10swappiness范围是0到100值越大越倾向于交换匿名页。这个参数不是越大越好也不是越小越好。推荐在正常业务负载下默认值60通常不用改如果大量使用内存做缓存而希望保留更多匿名内存可以适当调低。7.2 OOM Killer的工作原理当回收机制也撑不住内存需求时内核会启动最后手段OOM Killer。它会选择杀死一个进程来释放内存。OOM Killer并不是随机杀而是根据每个进程的oom_score打分来衡量被杀优先级。分数越高越容易被选中。打分逻辑综合考虑因素包括进程占用的内存量、运行时间、进程类型等。每个进程的/proc/PID/oom_score就是当前分数oom_score_adj可以对这个分数进行加减调整。比如mysqld这类数据库我们希望它别被杀可以调整echo -1000 /proc/PID/oom_score_adj极端情况下可以设为-1000表示OOM Killer完全跳过该进程。反过来如果某个进程本来就是用来处理突发任务、可以被随时重建的可以把它调成高分值优先牺牲。不过在我的观点里把oom_score_adj设为-1000更像是止血而非治病。真正该做的是控制进程自身的内存使用或者在容器层面用cgroup做内存限额让内存用量永远不会逼到系统级OOM。7.3 cgroup如何限制进程组内存容器化之后内存管理的最小单位往往不再是单个进程而是一个cgroup。cgroup v2中memory.max控制整个控制组的内存上限# 创建一个控制组 mkdir /sys/fs/cgroup/mygroup echo 1073741824 /sys/fs/cgroup/mygroup/memory.max # 把当前shell加入 echo $$ /sys/fs/cgroup/mygroup/cgroup.procs当控制组里的内存使用超过memory.max时内核会回收该组内的页面如果回收不了则组内进程可能被OOM Killer处决。memory.oom.group可以用来控制是杀掉所有组内进程还是仅杀触发者。这套机制在生产环境中非常重要。有了cgroup限额单个进程失控时不会拖垮整台机器。排查系统OOM时当时的警戒级指标主要是内存增长是否触顶以及对应cgroup的memory.events里oom_count是否增长。8. 排查内存问题的完整链路从泄漏定位到系统级优化8.1 进程RES持续上涨从怀疑到实锤以最常见的服务内存持续上涨为例一个相对完整的排查链路是先记录基线用ps或top记录进程PID、RSS、%MEM和启动时间。周期性采样判断上涨趋势是持续的还是有波动的。波动可能是正常缓存持续单边上涨则更可疑。查看/proc/PID/status确认VmRSS、VmSwap、RssAnon、RssFile的分布。用pmap或smem统计进程内部哪些地址段增长明显。越是不知名的匿名映射区越需要关注。如果证据指向堆内存持续增长对代码内存分配逻辑做静态审查或接入内存profiling工具。观察系统整体free -h是否越来越低available是否告急swap是否开始使用。第4步很容易被忽略。pmap输出的很多匿名块其实无法直接看到业务含义但从增长速度快慢可以推断出大概是哪个模块。我排查过一个服务内存每小时涨几百MB用pmap看到有一个持续增长的匿名段最终定位到代码里一个并发安全的map结构没有做清理key越积越多。至此问题才算实锤。8.2 用valgrind和AddressSanitizer找泄漏点如果是C/C代码valgrind的memcheck是经典工具但性能很慢适合测试环境和复现问题$ valgrind --leak-checkfull --show-leak-kindsall ./your_service输出的Leak summary会清楚告诉你definitely lost、indirectly lost、possibly lost各有多少字节再配合栈回溯能直接看到泄漏的分配点。如果追求更低的性能开销可以用AddressSanitizerASan配合编译器插桩$ gcc -fsanitizeaddress -g -o test main.c $ ./testASan在出现危险内存操作时直接终止程序并打印出错位置对检测堆越界、释放后使用等问题帮助很大。valgrind和ASan配合使用的原则简单说就是复现复杂问题用ASan跑真实负载深挖所有细节时用valgrind慢慢磨。我自己的经验是valgrind的slow是出了名的大型程序跑一遍要好几个小时。ASan只侵入关键模块编译往往在几分钟内就能暴露问题。但ASan没法完全代替valgrind因为两者检测的未定义行为覆盖范围不完全重合。8.3 堆块损坏和use-after-free这类棘手问题有时候内存崩溃不是泄漏而是use-after-free或者堆块损坏。这种问题表现很随意可能在release版本里几天崩一次也可能在压力测试时随机段错误。棘手的地方在于崩溃的位置往往和根因的位置不在同一个地方。比如你在函数A里释放了对象在函数B里又用了它可能要到函数C里才触发段错误。对付这类问题除了ASan还可以借助gdb的watchpoint来监视某个地址的写操作或者使用thread sanitizer检查数据竞争。需要注意的一点是不要只崩溃一次就去改代码先把能把内存状态完整dump出来或打印有效堆栈的手段准备好尤其是生产环境尽量用得起core dump就开起来。8.4 内核参数调整实战排除掉代码问题后系统层面的内存参数也值得梳理。比较常用的几个# 倾向于保留更多匿名内存减少swap vm.swappiness10 # 设置dirty page在后台开始写回之前可以占用的系统内存比例 vm.dirty_background_ratio5 vm.dirty_ratio10 # 避免过度使用overcommit防止申请大量虚拟内存然后不用的进程拖垮机器 vm.overcommit_memory0 # 在内存不足时, 内核尝试回收page cache的积极程度 vm.vfs_cache_pressure200overcommit_memory这个参数值得多说一句。0表示启发式允许适度overcommit1表示总是overcommit2表示禁止超过CommitLimit的过度分配。很多大内存应用比如某些数据库会追求overcommit_memory1这样malloc申请大块虚拟空间时不会被拒绝但代价是真实内存不足时更容易触发OOM。没有充分把握时不要在生产随意开着1跑。vfs_cache_pressure的调优同样需要小心。调高了内核回收dentries和inodes的倾向更强能更好释放内存给应用但如果文件操作非常频繁过高会导致元数据缓存命中率下降反而增加磁盘I/O。8.5 内存指标采样与趋势分析排查完可以用现成工具连续采样帮助判断修改是否生效。比如简单记录每隔5分钟采样一次RSS$ while true; do date mem.log; cat /proc/PID/status | grep VmRSS mem.log; sleep 300; done这种做法适用于没有监控平台的临时环境。更高的操作方式是在生产环境接入Prometheus的node_exporter和process exporter指标做出来后按小时查看趋势代码改动前后直接对比。我个人的习惯是线上服务至少保留30天内存指标不然你很难在复盘时确认某次版本发版是否引发了内存衰减。9. 补一点实操笔记内存池、锁页与shm9.1 自己写内存池需要注意什么如果业务中存在大量大小接近的小对象频繁分配自己写内存池可以避开ptmalloc的锁竞争。最简单的做法是预先申请一大块内存自己按固定大小切成多个slot用空闲链表管理释放的slot。不过内存池也会引入新问题内存池扩容策略不当会造成预留内存浪费某些slot长期空闲但无法还给系统让RSS虚高内存池自己的元数据也需要占用内存。因此写内存池前先做profiling确认瓶颈确实在malloc上而不是盲目优化。对大多数业务来说先换jemalloc往往比自研内存池更划算。真到了需要自研的规模再考虑根据对象大小和生命周期做专门设计。9.2 mlock锁页的适用场景mlock可以把指定内存锁定在物理内存中禁止被换出。适合用在密码学密钥、实时任务工作缓冲区上或者那些一旦swap就会产生安全风险的核心数据结构。#include sys/mman.h #include unistd.h int ret mlock(ptr, size);不过mlock有数量限制在Linux上可以用ulimit -l查看可锁内存上限。锁页过多会减少内核可回收的内存反而影响系统其他部分。9.3 共享内存(system-V shm和mmap)多进程通信时共享内存是最高效的方式因为不需要拷贝。System V风格的shmget/shmat适合分散的大块共享缓存mmap配合匿名映射也常用于父子进程通信。使用shm时要注意权限管理与生命周期。shmget创建的共享内存不会因进程退出自动释放需要shmctl显式清理。有些老服务常年积累出了一堆不用的共享内存段手机查看时用ipcs命令能揪出来再根据业务判断是否清理。mmap加MAP_SHARED的共享内存随最后一个进程映射结束而释放但多进程并发访问时需要额外同步手段。我自己做多进程缓存共享时优先考虑mmap文件映射数据落盘还能顺便恢复。但如果你不需要持久化只想快速共享system-V shm也够用。两者在高并发访问下都绕不开锁方案选型时需要把无锁设计seqlock、RCU思想一并想清楚。9.4 NUMA架构下的内存策略现代多路服务器基本都是NUMA架构CPU和内存的访问速度依赖物理距离。如果你跑在NUMA机器上内存管理就越发复杂。查看NUMA拓扑$ numactl --hardware进程在内存分配时如果想紧贴当前CPU所在的内存可以用numactl的--membind或--interleave策略。数据库、搜索服务这类高吞吐应用非常建议做一次NUMA感知调优把线程尽量绑定到和内存同一侧的CPU上。不过NUMA调优是一把双刃剑。绑错了节点反而可能导致某一侧内存和CPU都过载另一侧空闲。先观察业务线程分布和内存访问热点再动手绑核绑内存是最稳妥的路径。这里再补一个小技巧通过watch -n1 numa_stat了解进程的局部命中率观察numa_miss是否在持续增长。10. 最后再分享一些我踩过的坑和日常习惯第一次学内存管理的人很容易陷入对内核源码的深挖。但我的经验是先从系统和应用的视角建立起完整的地图再按照需求点去了解实现效率会高很多。日常习惯上我现在看任何一台Linux机器都会在脑子快速过一遍这些信息可用内存还剩多少swap是否被使用buff/cache占比如何CPU是否存在大量上下文切换。这不是照本宣科而是因为内存、CPU、I/O三大子系统之间的耦合极强只看单独指标往往会误判。另外想特别提一下调内存参数最怕的事就是一次改三个以上变量。有一次我为了一台瓶颈在磁盘I/O的机器同时改了swappiness、dirty_ratio和vfs_cache_pressure结果完全分不清是哪个改动起了作用。正确的做法是逐一调整、对比收益每次改动都要有充分的观察窗口。最后遇到内存问题不要急着上工具链。先问自己这个问题是稳定复现还是随机的影响的进程有没有规律机器的整体内存状况如何把这三个问题问答清楚了再用工具定位时已经走出了最关键的几步。内存管理的内容非常多这篇笔记只是搭了一个框架后续我打算分别深入写虚拟内存全景、glibc malloc源码分析、以及高性能中间件的内存优化实践。如果你是跟着这个系列在学Linux的也建议在读完这篇文章后自己开个虚拟机跑一跑free、top、mmap相关的实验踩过的坑越多对内存的直觉就越准。
返回列表