
内存分配是操作系统中最基础也最容易被误解的话题之一。很多开发者熟悉 malloc也知道 kmalloc但对两者在调用链上的本质差异、设计取舍和边界行为并不清晰。本文从函数调用级别展开逐层剥开两套分配体系的实现细节帮助建立清晰的思维映像。由于版本和架构的差异使用文中涉及对象数值时需查询源码或手册核实一、核心差异一句话用户空间分配依赖 C 库封装malloc → brk/mmap存在用户态管理层做缓冲syscall 次数被大幅摊薄内核空间直接调用内核内部接口kmalloc/vmalloc无中间缓冲层速度更快但失败不可依赖 page fault 自动恢复错误处理责任完全落在调用者身上。把这句话展开一步用户空间是三层结构应用 → libc 分配器 → 内核内核空间是两层结构调用者 → 分配器/伙伴系统。少掉的这一层不是微小的实现细节而是两套体系在行为上产生一切差异的根源。libc 向内核批量拿内存自己切割管理内核对应用的大量 malloc/free 动作一无所知。内核空间没有这个缓冲层SLUB 的 per-CPU slab cache 在热路径上有类似缓冲的效果但它只缓存固定尺寸的 slot不做通用的 chunk 切割与合并语义完全不同。二、用户空间分配调用链2.1 完整调用链应用代码 └─ malloc() / free() ← glibc 公开接口 └─ ptmalloc2glibc 默认分配器 ├─ tcache (Per-Thread Cache) ← 【无锁极速径】默认容量 64个bin × 7个chunk接管 ≤ 1040B 的分配 │ ├─ arena / bin / chunk 管理 ← 【用户态主内存池】多线程存在锁/CAS竞争 │ ├─ fastbin (≤ 128B) ← 单链表LIFOCAS原子操作。默认上限128B最大可配160B │ ├─ unsorted bin (任意大小) ← 回收中转站释放的非 fastbin 块优先进入提供极致的数据局部性 │ ├─ smallbin ( 1024B) ← 64位标准共62个尺寸精确匹配的桶双链表FIFO │ └─ largebin (≥ 1024B) ← 64位标准按尺寸范围划分内部按大小排序双链表 │ ├─ brk() syscall ← 【连续堆区】主 Arena 默认扩展方式处理上述 bin 的底层内存供给 └─ mmap() syscall ← 【离散映射】匿名映射。默认阈值 ≥ 128KB动态最高可推至 32MB └─ 内核 VMA 管理 └─ 缺页中断 (do_page_fault) ← 发生读写时真正分配物理内存 └─ 伙伴系统 (Buddy) → 物理页2.2 ptmalloc2 的核心设计ptmalloc2 最关键的设计是延迟归还free()在绝大多数情况下不会触发 syscall而是把 chunk 放回对应的 bin等下次malloc直接复用。这使得真实的 syscall 次数远少于malloc/free调用次数。void*p1malloc(64);// 第一次brk() 扩展堆获取物理页void*p2malloc(64);// 直接从 bin 取无 syscallfree(p1);// chunk 放回 fastbin无 syscallvoid*p3malloc(64);// 从 fastbin 取 p1 的内存无 syscall理解 bin 的分层逻辑有助于建立清晰的分配路径映像。free()后chunk 首先进入unsorted bin这是一个中转站不做大小区分。下次malloc时分配器会扫描 unsorted bin将合适大小的 chunk 直接返回不合适的则按大小归入 smallbin 或 largebin。fastbin是对小对象的特殊优化使用单链表、LIFO 顺序不合并相邻空闲 chunk为了速度牺牲碎片率。smallbin使用双链表、FIFO 顺序会合并相邻空闲 chunk减少碎片。largebin对大 chunk 按大小排序支持 best-fit 查找确保大对象的碎片率最低。多线程场景下ptmalloc2 通过arena机制减少锁竞争。每个线程尽量使用独立的 arena每个 arena 是一套完整的 bin 集合arena 数量上限为8 × CPU 核数。当线程数超过 arena 上限时多个线程会共享同一个 arena 并竞争其锁这是 ptmalloc2 在高并发下性能退化的根本原因也是 jemalloc 和 tcmalloc 的优化切入点。分配策略的分叉点ptmalloc2 在以下情况才会走mmap()而不是brk()请求大小 ≥MMAP_THRESHOLD默认 128KB可通过mallopt(M_MMAP_THRESHOLD, n)动态调整多线程场景下新建 arena 时brk()无法满足对齐要求时用mmap()分配的内存在free()时会立即通过munmap()归还内核不会留在 bin 里——这是与小块分配行为最显著的区别也是大对象频繁分配/释放性能差的根本原因。用brk()管理的堆内存的归还逻辑则不同只有当堆顶连续空闲区域超过M_TRIM_THRESHOLD默认 128KB时ptmalloc2 才会调用brk()缩减堆顶主动还页给内核。因此日常观察到的现象是程序释放了大量小对象后RSS物理内存占用并不立即下降因为那些内存还留在 bin 里等待复用对内核不可见。2.3 懒分配Lazy Allocation的真相brk()和mmap()返回的是虚拟地址此时物理页尚未分配。只有当进程真正访问该地址时CPU 触发缺页异常内核的do_page_fault()才会调用伙伴系统分配物理页并建立页表映射。void*pmalloc(1024*1024*100);// 100MB// 此时虚拟地址已分配物理内存几乎未消耗// malloc 可能成功返回即使系统物理内存只有 50MBmemset(p,0,1024*1024*100);// 此时真正触发物理页分配// 如果内存不足OOM Killer 可能在这里介入而不是在 malloc 处这是 Linux 默认开启 overcommit 策略的直接后果行为由/proc/sys/vm/overcommit_memory控制值为 0默认启发式判断允许合理的过量提交但拒绝明显荒谬的请求如申请超过物理内存 swap 总量的单次大分配。值为 1始终允许malloc 在物理内存实际耗尽前永远不返回 NULL但访问时可能触发 OOM。值为 2禁止过量提交系统会拒绝超出CommitLimit物理内存 × overcommit_ratio% swap的分配请求malloc 可能返回 NULL但不会有意外的 OOM kill。从这里可以得出一个反直觉但重要的结论malloc 返回非 NULL 不代表内存真的可用。OOM Killer 介入时它按oom_score综合考虑内存占用、运行时间、是否是系统进程等选择性价比最高的受害者不一定是当前进程。这意味着内存不足的后果是全局性的、不可预测的而不只是当前 malloc 调用的本地问题。2.4 替代分配器分配器核心优化方向典型使用场景jemallocper-CPU arena slab碎片率低Firefox、Redis、FreeBSDtcmallocThread Cache 无锁小对象分配Google 内部系统、Chromemimalloc细粒度 page 管理局部性优化.NET runtime、各种服务器ptmalloc2 并非唯一选择实际生产环境中常见的替代方案jemalloc的核心思路是 per-CPU arena 加上 slab 风格的 size class 管理碎片率显著低于 ptmalloc2适合长期运行的服务Firefox、Redis、FreeBSD 默认使用。tcmalloc的核心是 Thread Cache——每个线程持有一个本地的小对象缓存分配时完全无锁只有 cache 满溢或不足时才需要与全局 heap 交互适合高并发、小对象频繁分配的场景Google 内部系统、Chrome。mimalloc则在局部性上做了更细致的优化每个线程的页分配单元更小减少内存占用适合现代服务器场景.NET runtime 默认使用。替换方式无需修改代码LD_PRELOAD即可LD_PRELOAD/usr/lib/libmimalloc.so ./your_program三、内核空间分配调用链3.1 小对象kmalloc 路径kmalloc(size, gfp_flags) └─ SLUB 分配器现代内核默认 ├─ per-CPU slab cache 命中 ← 无锁快路径最快 ├─ 从 partial slab 分配 ← 需要短暂加锁 └─ 新建 slab向伙伴系统申请整页 └─ __alloc_pages(gfp_flags, order) └─ 内存区域选择ZONE_DMA / ZONE_NORMAL / ZONE_HIGHMEM └─ 伙伴系统返回 2^order 个连续物理页SLUB 内部有三条路径性能依次递减理解这个层次是理解kmalloc开销的关键。最快的路径是 per-CPU slab cache 命中每个 CPU 持有一个本地的空闲对象指针列表freelist命中时完全无锁直接指针操作返回纳秒级延迟。这是 SLUB 相比老 SLAB 分配器的核心改进点消除了大量的 per-object 锁。当 per-CPU freelist 耗尽时走 partial slab 路径从 per-node 的 partial slab 列表中取一个半满的 slab 补充到 per-CPU freelist需要短暂持有 per-node 的自旋锁。当 partial slab 也不足时向伙伴系统申请整页调用__alloc_pages分配一个或多个物理页格式化为新的 slab 加入 cache然后从中分配对象。这一步的代价最高且行为受 GFP 标志控制见 3.3 节。kmalloc返回的地址位于内核直接映射区PAGE_OFFSET以上虚拟地址和物理地址之间存在固定偏移可以通过virt_to_phys()直接转换因此天然适合 DMA 操作。这与用户空间的内存有本质不同——用户空间的虚拟地址和物理地址之间没有固定偏移关系需要页表才能查到物理地址。关于kmalloc的尺寸限制需要建立准确的认知SLUB 预定义了一系列固定大小的 cache8、16、32、64……4096、8192 字节请求会向上取整到最近的 cache 尺寸。当请求大小超过最大 slab cache 尺寸通常 8KB部分架构和配置可达 32KB时kmalloc并不会直接失败而是 fallback 到直接调用伙伴系统按页分配——这意味着它仍然能工作但返回的内存粒度变为页的整数倍内碎片可能极大。实践中kmalloc的合理上限约 4MB超过这个量应该重新评估设计。3.2 大对象vmalloc 路径vmalloc(size) └─ 在 vmalloc 地址空间寻找连续虚拟区域vmap_area └─ 循环调用 alloc_page() 分配单个物理页物理上不连续 └─ 建立页表映射将离散物理页映射到连续虚拟空间 └─ 刷新 TLB所有 CPU 核心vmalloc的代价远高于kmalloc而且这种差距在单纯的内存分配动作上看不出来需要展开每一步理解修改内核页表的代价首先体现在加锁上内核的 vmalloc 地址空间是全局共享的查找空闲虚拟区间需要持有vmap_area_lock。分配完成后还需要将新的页表项写入所有 CPU 的内核页表而不像用户进程页表那样只属于一个进程。TLB shootdown是vmalloc代价最容易被忽视的部分。修改内核页表后必须通知所有 CPU 刷新 TLB 中对应地址的缓存项否则其他 CPU 仍然使用旧的无效的映射。这个通知通过 IPI处理器间中断实现每个 CPU 收到 IPI 后停下当前工作执行 TLB 刷新然后返回。在 NUMA 多核系统上这个操作的代价随 CPU 核数线性增长几十上百核的机器上 TLB shootdown 的耗时不可忽视。此外vmalloc分配的内存在函数返回时物理页其实已经全部分配就位这与用户空间的懒分配有本质区别。但在首次访问时它仍会触发缺页异常。这并非为了申请物理内存而是为了进行内核页表同步vmalloc修改的是主内核页表Master Kernel Page Table而当前进程的内核页表副本可能尚未更新映射关系。当内核首次访问该区域时缺页处理程序会从主页表中将对应的页表项PTE拷贝到当前进程的页表中。因此vmalloc在访问每一页时仍存在首次同步的性能开销且这种同步型缺页是内核在特权级下隐式完成的。vmalloc的合理使用场景加载内核模块.ko 文件映射到 vmalloc 区、需要大块但不要求物理连续的缓冲区、ioremap映射设备寄存器区域。核心判断标准是不需要 DMA不需要virt_to_phys()换算物理地址只需要虚拟连续的大块内存。3.3 GFP 标志分配行为的控制开关GFPGet Free Pages标志决定了内核分配器在资源紧张时的行为是内核内存分配中最容易出错的地方// 进程上下文允许睡眠等待内存回收pkmalloc(size,GFP_KERNEL);// 中断上下文 / 持有自旋锁时禁止睡眠pkmalloc(size,GFP_ATOMIC);// DMA 专用分配 ZONE_DMA 区域的内存物理地址 16MBpkmalloc(size,GFP_DMA);// 内核内部用不触发文件系统操作避免内存回收死锁pkmalloc(size,GFP_NOFS);// 不触发任何 IO 操作pkmalloc(size,GFP_NOIO);理解 GFP 标志的关键是理解它们控制的是分配失败时的行为边界而不只是允不允许睡眠这一点。GFP_KERNEL是最宽松的标志。内存不足时分配器会唤醒 kswapd 触发后台页面回收如果 kswapd 回收速度不够还会同步执行 direct reclaim调用者自己扫描并回收页面必要时还会等待 writeback 完成以腾出脏页。这些步骤都可能导致调用者睡眠几毫秒到几十毫秒因此绝对不能在持有 spinlock 或处于中断上下文时使用。持有 spinlock 时睡眠会导致其他等待该锁的 CPU 无限自旋进而引发系统死锁或 watchdog 触发 panic。lockdep 工具在调试内核上可以提前检测此类问题。GFP_ATOMIC的底层机制与GFP_KERNEL有本质区别它使用的是高于普通水位线WMARK_MIN的内存储备当系统内存低于这条水位线时ATOMIC 分配也会失败。ATOMIC 分配失败不可重试、不可等待驱动中必须为此设计降级逻辑——通常是丢弃当前操作并返回错误码。失败率在内存压力下远高于GFP_KERNEL。GFP_NOFS 和 GFP_NOIO是用于避免递归死锁的标志。文件系统代码如 ext4 的写路径申请内存时如果触发内存回收回收器可能又需要调用文件系统的 writeback——这就形成了循环等待。GFP_NOFS告诉分配器不要触发文件系统操作来回收内存GFP_NOIO更严格连 IO 操作都不允许触发。存储驱动的 IO 路径通常使用GFP_NOIO防止内存回收递归进入 IO 层。3.4 其他内核分配接口分配接口核心机制最佳使用场景kzallockmalloc 零初始化绝大多数通用的驱动小对象分配kcalloc数组分配 溢出检查处理来自用户态或硬件的动态数组__get_free_pages绕过 Slab直接找伙伴系统需要页对齐、大块且物理连续的内存dma_alloc_coherent物理连续 Cache 一致性处理硬件 DMA 缓冲区最稳妥的方式mempool_alloc预留池机制分配保底关键 IO 路径防止内存回收死锁alloc_percpu每 CPU 独立副本完全无锁高频更新的计数器、热点统计数据kmem_cache_alloc专用 Slab 桶固定尺寸高频创建/销毁的特定结构体kzalloc(size, flags)是kmalloc加memset(0)的等价封装用于需要零初始化的小对象是驱动代码中最常用的分配接口之一。kcalloc(n, size, flags)用于数组分配与kzalloc(n * size, flags)的区别在于内置了整数溢出检查——如果 n × size 溢出size_t它会安全返回 NULL 而不是分配一个错误大小的内存块。在处理来自硬件或用户空间的不可信大小参数时这一点非常重要。__get_free_pages(flags, order)绕过 slab 分配器直接从伙伴系统申请2^order个物理连续页返回物理连续的内核虚拟地址。适合需要整页对齐、物理连续但大小已知是页倍数的场景。dma_alloc_coherent(dev, size, dma_handle, flags)是 DMA 缓冲区的正确分配方式而不是简单地kmalloc加标GFP_DMA。它不仅保证物理连续还处理了跨架构的 cache 一致性问题在某些架构上需要将缓冲区映射为 uncached或手动刷新 cache并直接返回设备可用的dma_handle物理地址或总线地址。驱动开发中凡是涉及 DMA 的缓冲区都应优先考虑这个接口而非手动操作物理地址。mempool_alloc(pool, flags)是针对分配必须成功场景的解决方案。在系统初始化时通过mempool_create()预分配一批对象当普通分配路径失败时从预留池取用。块设备驱动的 bio 结构是典型用例——内存回收本身依赖 IOwriteback而 IO 依赖能分配 bio 结构如果 bio 分配因内存不足而失败系统会陷入死锁。mempool 打破了这个循环。alloc_percpu(type)为每个 CPU 分配一份独立的对象副本通过get_cpu_ptr()/put_cpu_ptr()访问完全无锁且每个 CPU 的副本在内存中彼此间隔天然消除 cache 伪共享false sharing。适合高频更新的统计计数器、per-CPU 缓存等是内核性能优化中的重要工具。kmem_cache_create()加kmem_cache_alloc()是专用 slab cache 的接口。当某类对象需要大量频繁分配/释放时建一个专用 cache 比直接kmalloc效率更高对象大小固定无内碎片可以加 constructor 在分配时自动初始化且 slabinfo 中会有独立的统计条目便于调试。四、关键行为差异深度解析4.1 物理连续性最常被混淆的一点用户空间的内存虚拟地址连续物理地址几乎必然不连续由页表将散布在 DRAM 各处的物理页拼接成连续的虚拟空间。应用开发者在绝大多数场景下不需要关心这一点。内核空间的情况则必须明确区分。kmalloc返回的内存虚拟连续且物理连续背后是伙伴系统分配的一块连续物理页地址可以用virt_to_phys()直接换算天然适合 DMA。vmalloc返回的内存虚拟连续但物理不连续和用户空间一样靠页表拼接不能用于 DMA也不能用virt_to_phys()直接换算需要vmalloc_to_pfn()逐页查找。把vmalloc的地址传给 DMA 引擎不是慢一点而是静默的数据损坏。硬件 DMA 使用物理地址工作它看到的是连续的物理地址区间但vmalloc的物理页是分散的DMA 会按照连续物理地址写入实际上写到了不相关的内存区域损坏其他数据。这类 bug 极难复现和排查因为崩溃现场离真正的写入操作可能有很长的时间和调用栈距离。4.2 分配时机懒与即时的根本区别用户空间的懒分配已在 2.3 节展开。这里对比内核空间。kmalloc成功返回即意味着物理页就位、可以立即使用没有缺页异常这道工序。这不是偶然而是设计约束内核的很多代码路径中断处理程序、softirq、持有 spinlock 的代码根本无法处理缺页异常如果采用懒分配一旦触发缺页就会直接 BUG。内核在自己的栈上运行没有像进程那样完善的缺页处理基础设施。vmalloc是内核空间中少有的例外——它的区域在首次访问时确实也有缺页处理称为vmalloc 缺页。但这个缺页处理是在内核态完成的没有 signal、没有 OOM 保护和用户空间的缺页语义完全不同。如果 vmalloc 缺页时内存不足直接 panic。4.3 分配失败进程与系统的代价不对称用户空间malloc失败返回 NULL进程可以检查、降级、重试最坏是进程崩溃不影响其他进程和内核。这层隔离是由虚拟内存和进程隔离机制保证的。内核空间kmalloc失败如果不检查就直接解引用会立即触发内核 oops 甚至 panic整个系统宕机。内核驱动中对每一个kmalloc/vmalloc的返回值做 NULL 检查不是好习惯而是正确性的必要条件。很多新手写驱动时习惯像写应用程序一样先用再说在开发机的充裕内存下永远跑得通一到内存压力场景就立即崩溃。4.4 内存泄漏进程生命周期 vs 系统生命周期这是两套体系代价最不对称的地方之一。用户空间的内存泄漏在进程退出时由内核自动收拾残局——内核会回收进程的所有虚拟地址空间和物理页无论代码有没有调用free。长期运行的进程如果持续泄漏RSS 会缓慢增长最终触发 OOM但这是进程级别的事不影响系统整体。内核空间没有进程退出这个概念来兜底。kmalloc/vmalloc分配的内存是内核全局资源没有任何自动回收机制。泄漏的内存永久占用直到重启。驱动开发中最常见的泄漏场景是模块卸载路径module_exit遗漏kfree。反复insmod/rmmod会逐渐耗尽内核内存症状是/proc/meminfo中Slab字段尤其是SUnreclaim部分持续缓慢增长slabtop中某个 cache 的active_objs持续上升但module_exit从未真正清理。4.5 栈内存的对比用户进程的栈由内核初始分配一个较小的区域通过缺页异常动态增长guard page 检测溢出。超出默认 8MB 上限ulimit -s触发 SIGSEGV进程可以捕获并处理。内核线程的栈则是固定大小的在 x86_64 上默认 16KB部分旧配置或嵌入式场景是 8KB不能动态增长没有类似用户栈的缺页扩展机制。内核栈溢出的结果不是一个可以被捕获的信号而是静默地覆盖相邻的内存数据直到某个关键数据被破坏触发 panic或者更糟糕地以无法复现的方式产生错误行为。在内核函数中声明大数组是经典的危险行为例如// 危险在内核栈上分配 4KB极易溢出charbuf[4096];应改为动态分配kmalloc(4096, GFP_KERNEL)用完后kfree。CONFIG_VMAP_STACKLinux 4.9 之后默认启用为内核栈使用 vmalloc 区分配并在栈底放置 guard page使得内核栈溢出能被及时检测而不是静默损坏数据。这是一个重要的安全加固特性但不改变内核栈固定大小、不可动态增长的本质。五、调试工具与关键命令用户空间# 查看进程内存映射区分 heap / mmap 区域cat/proc/pid/maps# 查看实际物理内存消耗RSS vs VSZcat/proc/pid/status|grep-EVmRSS|VmSize|VmPeak# 查看 overcommit 配置cat/proc/sys/vm/overcommit_memory# Valgrind 检测泄漏valgrind --leak-checkfull --track-originsyes ./program内核空间# 查看 slab 分配器使用情况cat/proc/slabinfosudoslabtop# kmemleak 扫描内核内存泄漏# 需要开启的 CONFIG_DEBUG_FSy CONFIG_DEBUG_KMEMLEAKy 内核参数和挂载 debugfsechoscan/sys/kernel/debug/kmemleakcat/sys/kernel/debug/kmemleak# 查看伙伴系统各阶空闲页数量高阶列下降 碎片化信号cat/proc/buddyinfo# 整体内存统计Slab 字段持续增长是内核泄漏信号cat/proc/meminfo|grep-ESlab|SReclaimable|SUnreclaim# 查看内存区域分布和水位线cat/proc/zoneinfo六、小结一条逻辑线贯穿所有差异两套分配体系的根本差异源于运行环境的不同用户进程有虚拟内存保护、有进程隔离、有操作系统兜底内核代码运行在最高特权级没有保护网错误直接影响整个系统。这个差异向上传导解释了所有具体行为的来龙去脉内核空间没有懒分配是因为中断路径不能处理缺页需要 GFP 标志是因为必须精确控制分配器是否允许睡眠vmalloc 代价远高于单纯的内存分配动作是因为 TLB 是全局资源需要全系统同步漏掉一个kfree比漏掉free严重得多是因为没有进程退出来收拾残局vmalloc 地址不能传给 DMA是因为物理连续是硬件约束而非软件选项建立这套思维映像的捷径是记住两个问题当前代码路径能否睡眠当前分配的内存是否需要物理连续这两个问题的答案决定了几乎所有的选型判断。