Linux内存管理:从伙伴系统到malloc,揭秘内核内存分配机制

Linux内存管理:从伙伴系统到malloc,揭秘内核内存分配机制 1. 项目概述从开机到应用Linux内存管理的全景图刚接触Linux内核开发或者系统调优的朋友经常会听到“伙伴系统”、“Slab分配器”、“vmalloc”这些名词感觉它们既神秘又分散。今天我就结合自己这些年调试内核和优化服务的经历来聊聊Linux内存管理这个庞大体系中最核心的“初始化”和“分配”两件事。你可以把这篇文章看作一张地图它不会带你深入每个算法的数学证明那是教科书的事而是告诉你当按下电源键内存是如何一步步被系统认识和组织的当你的程序写下malloc(1)时背后究竟触发了怎样一条或简单或复杂的分配路径。理解这些无论是解决内存泄漏、优化程序性能还是单纯想搞懂/proc/meminfo里那些数字的含义都至关重要。这篇文章适合有一定C语言和操作系统基础希望窥探Linux内核核心机制的开发者、运维工程师以及性能调优爱好者。2. 内存初始化从物理探测到虚拟世界构建开机后CPU还处在实模式只能访问1MB内存。内核要管理动辄几十GB的物理内存第一步就是“摸清家底”——知道有多少可用的物理内存以及它们的布局。2.1 物理内存的探测与收集这个过程始于引导加载程序如GRUB它通过BIOS或UEFI获取内存布局信息称为E820图并将这些信息打包成一个“内存映射”数据结构传递给内核。内核的入口函数start_kernel()会调用setup_arch()其中最关键的一步就是解析这个内存映射。注意这里说的“可用”内存并不等同于你插在主板上的内存条容量。系统会保留一部分给内核代码、数据本身还有一部分可能被硬件如显卡预留为“保留内存”。你在dmesg里看到的reserved部分就来源于此。解析完成后内核得到了一个物理内存的“资源图”。接下来它通过memblock分配器一个在早期启动阶段使用的简单分配器来管理这些内存。memblock将内存划分为“内存”区域和“保留”区域内核早期需要的内存如页表、initrd都通过memblock_alloc()申请。这是内存管理的“临时政府”它的使命是创建更强大的“正式政府”——伙伴系统。2.2 伙伴系统的建立与页面初始化伙伴系统Buddy System是Linux管理物理页帧通常一页为4KB的核心算法。它的初始化在mm_init()中完成核心是free_area_init()系列函数。这个过程可以想象成初始化一个巨大的仓库划分仓库区域根据NUMA非统一内存访问架构信息将物理内存划分为多个节点pglist_data每个节点再根据内存类型可移动、不可移动、可回收等划分为多个区域zone如ZONE_DMA, ZONE_NORMAL, ZONE_HIGHMEM。建立货架free_list每个内存区域zone都维护一组空闲链表free_area[MAX_ORDER]。MAX_ORDER通常为11这意味着链表索引从0到10分别对应1页(4KB)、2页、4页……直到最大1024页(4MB)的连续空闲块。上架商品页面初始化时所有可用的物理页面被当作一个巨大的连续空闲块加入到对应区域的最大阶MAX_ORDER-1空闲链表中。随后伙伴系统会根据需要将这个大的空闲块分裂成更小的块或者将相邻的小空闲块合并成更大的块。此时物理页面管理框架就绪。但CPU不能直接使用物理地址必须通过页表转换成虚拟地址。这就是paging_init()的工作它建立内核空间的页表映射将物理内存映射到内核虚拟地址空间如0xC0000000开始的线性映射区。至此内核已经可以在虚拟内存的世界里通过伙伴系统这个“正式政府”来分配和回收物理页面了。2.3 slab分配器的诞生应对小对象分配之痛伙伴系统最小分配单位是一页4KB。如果内核需要频繁分配一个只有几十字节的结构体如task_struct,inode每次都分配一页会造成巨大的内部碎片效率极低。于是slab分配器应运而生。slab的初始化也在mm_init()阶段通过kmem_cache_init()完成。它的核心思想是“对象缓存”创建缓存为内核中频繁使用的数据结构如task_struct,mm_struct创建专用的缓存kmem_cache。从伙伴系统批发内存slab分配器从伙伴系统申请一整页或多页内存作为一个“slab”。在slab内零售将一个slab划分成一个个大小相等的“对象”并将其初始化。当内核需要分配该对象时直接从对应的slab缓存中获取一个空闲对象释放时也只需放回缓存而非立即归还给伙伴系统。这就像一家超市内核它不会每次顾客要买一瓶水小对象就去联系水厂伙伴系统进货一卡车一页。而是先建一个仓库slab缓存从水厂进一批货一个slab放在仓库里顾客需要时直接从仓库取。这样极大提升了小内存分配的效率和速度并减少了碎片。至此Linux内存管理的基础设施全部就位物理内存有伙伴系统管理小对象有slab分配器伺候虚拟映射也已建立。用户空间的程序可以通过系统调用踏入这个已经构建好的内存世界。3. 核心内存分配方式全解析理解了内存的“基建”过程我们再来看看在这个体系下有哪些主要的“申请通道”。不同的分配方式适用于不同的场景选错了可能带来性能问题甚至错误。3.1 物理页面分配器伙伴系统alloc_pages这是最底层、最直接的物理内存分配方式主要服务于内核自身或需要直接操作物理页面的驱动。接口与使用// 分配2的order次幂个连续的物理页返回指向第一页的page结构指针 struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); // 将alloc_pages得到的page指针转换为虚拟地址在低端内存区 void *page_address(struct page *page); // 更常用的封装直接分配并返回虚拟地址 unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);参数解析gfp_maskGet Free Page mask分配标志位这是内存分配中最重要也最容易出错的部分。它告诉分配器你的需求区域修饰符如__GFP_DMA从DMA区域分配、__GFP_HIGHMEM从高端内存分配。通常使用GFP_KERNEL即可它允许访问所有普通区域并在内存不足时触发直接回收。行为修饰符如__GFP_WAIT允许睡眠等待、__GFP_IO允许启动I/O操作进行页回收、__GFP_FS允许执行文件系统操作。GFP_KERNEL包含了等待、IO和FS权限因此只能在进程上下文中使用可以睡眠。紧急修饰符如__GFP_HIGH高优先级请求、__GFP_ATOMIC原子分配不允许睡眠用于中断上下文。order阶数0代表分配1页(2^0)1代表2页(2^1)以此类推。典型场景与避坑指南内核模块或驱动需要分配大块连续的DMA缓冲区时使用。例如网卡驱动接收数据包的环形缓冲区。内存紧张时的行为当使用GFP_KERNEL且内存不足时当前进程可能会睡眠并触发“直接内存回收”这会阻塞进程引起性能抖动。如果是在中断上下文或持有自旋锁时必须使用GFP_ATOMIC但它分配失败的概率更高。连续性问题伙伴系统保证分配的物理页面是连续的。但在系统长时间运行后物理内存碎片化严重分配高阶如order3的连续页面可能失败即使总的空闲内存还很多。这是很多驱动在加载时失败的原因。实操心得在编写内核代码时除非确有必要如DMA否则应避免直接使用alloc_pages。优先使用更上层的kmalloc。对于gfp_mask记住一个黄金法则能睡眠的上下文用GFP_KERNEL不能睡眠的中断、软中断、自旋锁内用GFP_ATOMIC。不确定时去看内核里类似功能的代码是怎么用的。3.2 通用对象缓存分配器slab与kmalloc这是内核中最常见、最通用的分配接口我们平时说的“在内核里动态分配内存”十有八九指的是它。原理与层级kmalloc并不是一个独立的分配器而是slab分配器的一个前端接口。它背后维护了一系列针对不同大小通常是2的幂次方如32, 64, 128, ... 8KB的通用slab缓存。当你调用kmalloc(size, flags)时内核根据size向上对齐到最近的标准大小如分配33字节会去64字节的缓存。根据flags与alloc_pages的gfp_mask类似找到对应的缓存。从该缓存中分配一个对象。接口与vmalloc对比// 分配大小不超过一个页面大小的内存物理和虚拟地址都是连续的 void *kmalloc(size_t size, gfp_t flags); // 释放kmalloc分配的内存 void kfree(const void *objp); // 分配虚拟地址连续但物理地址可能不连续的大内存 void *vmalloc(unsigned long size); // 释放vmalloc分配的内存 void vfree(const void *addr);kmallocvsvmalloc核心区别特性kmalloc/kzallocvmalloc物理连续性保证连续在低端内存范围内不保证连续分配大小通常较小 1页 ~ 几MB受限于最大order可以很大理论可达几GB分配速度快若缓存命中几乎无开销慢需要修改页表可能触发TLB刷新适用场景需要物理连续的小内存如DMA缓冲区、数据结构需要大块虚拟连续空间但不要求物理连续如模块加载、大型软件缓存睡眠可能取决于flagsGFP_KERNEL会睡眠可能睡眠因为需要GFP_KERNEL申请多个单页访问开销低线性映射虚拟地址物理地址固定偏移较高需要经过页表查询为什么kmalloc有大小限制因为它背后是伙伴系统分配大内存需要高阶的连续物理页面这在碎片化的系统中很难满足。而vmalloc通过逐页调用alloc_page然后将一个个离散的物理页映射到连续的虚拟地址空间巧妙地避开了物理连续性的要求但付出了修改页表和TLB刷新影响性能的代价。典型场景选择选择kmalloc分配内核数据结构、小型的驱动缓冲区、任何需要物理地址连续或对性能极其敏感的场合。99%的内核内存分配都用它。选择vmalloc加载内核模块模块的代码和数据通过vmalloc映射、为某个功能分配巨大的软件查找表或缓存例如一些文件系统或网络子系统的哈希表。踩坑记录我曾调试过一个驱动它用kmalloc分配一个大约1MB的DMA缓冲区。在测试机上一切正常但在某些客户的线上环境系统已运行数月总是加载失败。dmesg显示“kmalloc: allocation failed”。原因就是物理内存碎片化无法找到1MB的连续物理内存。解决方案是修改驱动在初始化早期内存还干净时就预留这块内存或者改用dma_alloc_coherent它可能从预留的DMA区域分配碎片较少。3.3 进程用户空间内存分配malloc与brk/mmap用户程序使用的malloc、calloc等库函数其底层最终通过系统调用向内核“要”内存。主要有两种机制1.brk系统调用原理通过移动进程的“program break”程序断点位置来扩展或收缩堆内存。堆是一段连续的虚拟地址空间。特点分配小内存时效率高因为只是修改一个边界指针。但容易产生内存碎片且不适合分配超大内存因为堆空间有限且与内存映射段相邻。2.mmap系统调用原理在进程的虚拟地址空间中创建一块新的匿名内存映射不与任何文件关联。特点适合分配大块内存如大于128KB这是glibcmalloc的默认阈值。分配的内存可以独立释放通过munmap更灵活且释放后立即将物理页归还给系统。但系统调用开销比修改brk指针大。glibcmalloc的实现策略 现代的malloc实现如glibc的ptmalloc是一个混合策略对于小内存请求 128KB它通过brk扩展堆并在堆上管理一个复杂的内存池以减少碎片和提高分配速度。对于大内存请求 128KB它直接使用mmap分配一块独立的内存区域。当堆顶的空闲内存超过一定阈值时malloc会调用brk收缩堆将内存归还给操作系统。你可以通过strace命令跟踪一个程序看到它调用brk和mmap的情况。内核视角 无论brk还是mmap当进程第一次访问新分配的虚拟内存时会触发“缺页异常”。内核的缺页异常处理程序会介入为其分配实际的物理页面通过alloc_page并建立页表映射。这就是所谓的“按需调页”它避免了为未使用的内存提前付出物理页的代价。性能调优提示理解这一点对优化程序很重要。malloc成功返回只代表虚拟地址分配成功不代表物理内存已就位。首次访问时的缺页异常会有一定开销。对于性能关键的实时路径有时会采用“内存预分配预热主动访问所有页面”的方式来消除运行时的不确定性。4. 高级话题与性能考量了解了基本分配方式我们再来探讨几个深入的问题它们在实际开发和运维中经常遇到。4.1 内存碎片化无声的性能杀手内存碎片化分为两种外部碎片空闲内存的总量足够但被分割成许多不连续的小块无法满足一个大块的连续分配请求。这是伙伴系统的主要敌人。内部碎片分配给请求的内存块比实际请求的大多余的部分被浪费。这是slab分配器和kmalloc需要权衡的问题为了效率而做的对齐牺牲。内核的抗碎片化技术页面迁移与内存规整内核线程kswapd或直接回收路径在内存紧张时会尝试迁移可移动的页面如用户空间页、页缓存将空闲页面“挤”到一起形成大的连续块。这依赖于将页面按可移动性分类MIGRATE_TYPES。CMA连续内存分配器为需要大块连续物理内存的设备如GPU、视频编解码器预留一块物理内存区域。平时这块区域可以被内核用于分配可移动的页面当设备驱动需要时再迁移走这些页面腾出连续空间。这是解决DMA缓冲区分配难题的现代方案。用户空间的碎片化 用户空间的堆碎片化由malloc实现来管理。使用mmap分配大块内存并独立释放是减少用户空间碎片的一种方式。对于长期运行的服务如果其内存分配释放模式固定可以考虑使用jemalloc或tcmalloc这类替代的内存分配库它们通常有更好的多线程性能和碎片控制能力。4.2 调试与监控你的内存去哪儿了当系统出现内存不足OOM或疑似内存泄漏时掌握排查工具至关重要。1./proc/meminfo解读 这是内存状态的概览。关键字段MemTotal/MemFree总物理内存和完全空闲的内存。MemFree少不代表内存紧张因为Linux会积极利用内存做缓存。Cached页缓存大小。这是内核缓存磁盘数据用的在应用程序需要时会自动释放。Buffers块设备缓存现在大多合并到Cached。Slab/SReclaimable/SUnreclaimSlab分配器占用的总内存、可回收部分如dentry, inode缓存、不可回收部分。Committed_AS已承诺的内存总量所有进程申请的总和这是判断是否会触发OOM的重要参考。2./proc/slabinfo与slabtop 查看slab缓存的使用详情。slabtop命令可以动态查看哪些内核对象缓存占用了最多的内存。如果你怀疑内核模块有泄漏这里会是第一现场。3.vmstat与sarvmstat 1可以动态查看内存、swap、系统活动。关注siswap in、soswap out是否持续大于0这表示内存已严重不足开始使用交换分区性能会急剧下降。sar -r可以查看历史内存使用趋势。4. 进程级内存分析/proc/[pid]/smaps与pmap 一个进程的虚拟内存空间布局详情。smaps文件详细展示了进程每一段内存映射的地址、大小、权限以及关键的RSS常驻内存集即实际占用的物理页大小和PSS按比例计算的常驻内存对于共享库计算更公平。pmap -x [pid]命令是查看smaps的友好形式。这是定位进程内存泄漏的终极武器。排查内存泄漏的经典思路观察系统级free命令看available是否持续下降slabtop看是否有某个slab缓存异常增长。定位嫌疑进程使用ps aux --sort-rss按内存使用排序找到RSS增长最快的进程。深入进程内部对嫌疑进程使用pmap或分析/proc/[pid]/smaps对比不同时间点的快照看是哪一段内存映射特别是堆[heap]或匿名映射[anon]在持续增长。代码级定位如果确定是用户进程泄漏可以使用valgrind --toolmemcheck如果是内核模块可能需要开启kmemleak内核调试功能。4.3 容器环境下的内存管理Cgroups的介入在现代容器化环境中如Docker Kubernetes内存管理多了一层抽象Cgroups控制组。Cgroups为容器设置了内存使用上限memory.limit_in_bytes。关键机制内存用量统计Cgroups统计容器内所有进程的RSS、页缓存、内核栈等内存使用。硬限制与软限制当容器内存使用接近上限时会触发回收。如果超过上限会触发容器内的OOM Killer选择容器内的某个进程杀死而不是宿主机的全局OOM Killer。这保证了单个容器的内存问题不会影响宿主机或其他容器。memory.swappiness控制容器使用交换分区的倾向性。在容器中有时会将其设置为0以尽量避免使用swap因为swap的延迟在容器共享I/O时可能带来不可预测的性能影响。对开发者的启示 在容器中运行应用不仅要关注进程自身的内存使用还要明确容器的内存限制。malloc可能成功但首次访问时触发缺页异常如果此时整个Cgroup内存已超限进程可能会被OOM Killer终止。因此对于Java等拥有大堆的应用程序需要合理设置-Xmx堆最大值使其显著低于容器内存限制为堆外内存如线程栈、Native库、元空间和系统运行留出空间。5. 总结与最佳实践建议Linux内存管理是一个层次化、高度优化的复杂系统。从开机时探测物理内存到建立伙伴系统和slab分配器再到提供kmalloc、vmalloc、brk/mmap等多层分配接口每一层都为了解决特定问题。给开发者的几条黄金建议选对分配接口内核小内存用kmalloc。内核大内存且不需物理连续用vmalloc。用户空间内存用malloc库函数但要知道它背后是brk和mmap。需要DMA且可能面临碎片考虑dma_alloc_coherent或内核的CMA机制。用对分配标志记住GFP_KERNEL和GFP_ATOMIC的适用场景。这是内核编程的基石用错会导致死锁或分配失败。在不确定的上下文中查看内核中类似功能的代码是如何选择标志的。理解分配的成本kmalloc一个几十字节的对象很快因为它来自预分配好的slab缓存。vmalloc一个大内存相对较慢因为它涉及多次单页分配和页表操作。用户空间malloc后首次访问内存会有缺页异常开销。关注内存碎片对于需要长期运行且分配大块连续物理内存的驱动或模块尽量在启动早期完成关键内存的分配。了解/proc/buddyinfo可以查看伙伴系统各阶空闲块的数量这是判断外部碎片程度的直观工具。善用监控工具出现内存问题按/proc/meminfo-slabtop//proc/slabinfo-ps/top-/proc/[pid]/smaps/pmap的顺序进行排查。对于容器使用docker stats或cadvisor监控容器级别的内存使用情况。内存管理就像一座冰山应用程序看到的水面之上只是malloc和free而水面之下是庞大而精密的内核机制在支撑。理解这些机制不仅能让你在程序出错时快速定位问题更能让你在设计和编码时做出更优的决策写出对系统更友好的高性能代码。