十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

控制内存分配:从malloc底层机制到内存池与系统调优的完整实践

控制内存分配:从malloc底层机制到内存池与系统调优的完整实践 搞后端和客户端的兄弟应该都有过这种经历服务跑得好好的突然某个接口响应慢了一拍或者压测到一半内存曲线开始疯狂往上窜又或者进程明明没泄漏但 RSS 就是只涨不降。大部分人第一反应是查代码逻辑、查数据库慢查询但很少有人会第一时间想到——问题可能出在内存分配的“失控”上。“控制内存分配”这事翻译成人话就是不再把 malloc/free 和 new/delete 当成一个理所当然存在的黑盒而是主动去接管内存从申请、使用、释放到归还的整个生命周期。它解决的不是“内存不够用”那种低级问题而是延迟抖动、内存碎片化、锁竞争、RSS 虚高这一类在高并发、低延迟场景下特别要命的问题。这篇文章不是讲那种“别用 new、用对象池”的泛泛而谈而是从语言层面、数据结构层面、系统调用层面、工具层面完整拆解一套控制内存分配的方法论。适合 C/C 后端开发者、游戏引擎开发者、嵌入式开发以及正在为性能毛刺头疼的中间件维护者来读。就算你是做 Java 或 Go 的理解底层分配机制对你调优 GC 和内存池参数也有很大帮助。1. 为什么默认的内存分配不够“可控”1.1 一次 malloc 背后发生了什么很多人对 malloc 的理解停留在“向操作系统要一块内存”这个层面。但实际上当你调用 malloc(64) 的时候glibc 的 ptmalloc 分配器几乎不会立刻向操作系统发起系统调用它优先从自己维护的堆内存池里找空闲块。只有当池子里没有合适的内存块时它才会通过 brk 扩展堆或者通过 mmap 映射一块新的匿名内存。这个机制本身没毛病问题出在“它内部到底怎么找空闲块”这件事上。ptmalloc 内部维护了多个空闲链表bins还有一个为小对象准备的 fastbins 缓存区。为了兼顾各种大小对象的分配效率它会做切割、合并、 fragmentation 整理。这些操作在单线程下还好到了多线程环境多个线程同时去抢同一个分配区的锁就会产生严重的锁竞争。虽然 glibc 后来引入了 arena 机制每个线程默认最多 8 个分配区去分摊锁压力但一旦线程数上来或者某些线程频繁抢占同一个 arena竞争依然不可忽视。1.2 默认分配器的三个不可控点我把默认分配的“失控”总结成三个具体表现第一分配耗时不可控。malloc(64) 理论上应该很快但如果你在信号处理函数里调用 malloc或者在一段需要纳秒级延迟控制的代码路径上调用它得到的耗时可能从几十纳秒一路飙到几微秒——因为一次内存分配可能触发系统调用甚至触发内存页的缺页中断缺页时内核要分配物理页并清零这个开销在极端情况下能到几十微秒。这在低频逻辑里无所谓但在高频交易、实时音视频处理里就是灾难。第二物理内存占用不可控。malloc 返回给你的是一个虚拟地址这块内存对应的物理页面是当你真正写入数据时才分配的。如果你分配了 1GB 但只写了其中 100 个字节你的 RSS 不会涨 1GB这是好事。但反过来你频繁分配又释放大量内存ptmalloc 未必会把物理页归还给操作系统。它倾向于把释放的内存留在自己的池子里以便下次分配时快速复用。这就导致进程的 RSS 居高不下而你在监控里看到的内存“泄漏”实际上只是分配器扣着内存不还。第三内存布局不可控。默认分配器为了利用碎片化的空隙会把你的对象放到一个地址完全不确定的位置这会导致 CPU 缓存的命中率变得不可预测。你这次 malloc 出的对象可能和另一个热对象紧挨着下次又是另外的位置cache line 的亲和性全凭运气。注意我并不是说默认分配器一无是处。在通用场景下ptmalloc 是经过千锤百炼的实现稳定性和兼容性都是一流的。真正的“失控”指的是它不能为某些极致场景提供确定性和可预测性。所以“控制内存分配”并不是要消灭 malloc而是把关键路径上的分配行为从“看运气”变成“看规则”。2. 语言层面控制重载、分配器与栈上分配2.1 C 里重载 operator new 是第一步如果你用 C最简单的控制手段是重载类级别的 operator new 和 operator delete。比如你有一个非常热门的类 Frame你可以统计它创建和销毁的次数、累计分配字节数甚至直接把它的内存从你自己的内存池里分配class Frame { public: void* operator new(size_t sz) { // 这里可以换成你的内存池分配 std::cout Frame allocated, size sz std::endl; return ::malloc(sz); } void operator delete(void* ptr) { std::cout Frame freed std::endl; ::free(ptr); } }; int main() { Frame* f new Frame(); delete f; return 0; }看起来很简单但真要发挥作用有几个细节得注意。重载的 operator new 的参数是 size_t这个值是类对象的大小不一定是你 sizeof(Frame) 后直接得到的值——因为继承、虚函数表指针、对齐要求都会影响它。而且如果你重载了 operator new一定要成对重载对应的 operator delete否则对象构造中途抛异常时C 会去调匹配的 delete找不到就是未定义行为。更高级一点你可以在 operator new 里做“空间预留”。比如给 Frame 重载时额外分配 16 字节来存元信息分配线程 id、时间戳、对象大小方便后续崩溃时排查 live 对象。这种“定制”能力是默认分配器给不了你的。2.2 自定义分配器接入 STL 容器的正确姿势很多服务器代码里高频使用的是哈希表、vector、链表这些容器默认会使用 std::allocator底层走全局 ::operator new。如果你想针对容器做精细控制可以写一个自定义 Allocator把它传给容器模板参数。举一个最实用的场景你的服务里有一个并发哈希表每条消息进来都要插入一些 key-value消息结束后再清除。这种频繁插入删除会导致容器频繁扩容和缩容而每次扩容都要分配新内存、拷贝旧元素性能损耗很大。如果你给 unordered_map 传入一个“从不释放内存、只从一块大 buffer 里按序切分”的线性分配器那么整个会话期间哈希表的内存增长就只在 Arena 里进行销毁时一次性释放整块 Arena既减少分配次数又避免碎片templatetypename T class ArenaAllocator { public: using value_type T; ArenaAllocator(std::vectorchar arena) : arena_(arena) {} templatetypename U ArenaAllocator(const ArenaAllocatorU other) : arena_(other.arena_) {} T* allocate(std::size_t n) { // 在这里从 arena_ 中按 offset 分配不做释放直到 arena 整体销毁 // 注意必须对齐到 alignof(T)否则未定义行为 } void deallocate(T*, std::size_t) noexcept { // 什么都不做统一释放 } private: std::vectorchar arena_; };这个模式的核心思想是用小对象的“一次性释放”代替逐次释放。很多业务对象生命周期是一致的比如一次 RPC 请求里创建的所有临时对象它们天然适合用 Arena 生命周期来管理完全没有必要让每个对象单独走一遍 malloc/free。2.3 栈上分配与对齐控制也很关键除了接管堆分配还能把一部分分配挪到栈上。C 的 alloca 和 C 的变长数组VLA能在栈上按运行时大小分配内存函数返回自动释放速度几乎零成本。这在实现一些需要临时缓冲区的算法时特别有用。但 alloca 有栈溢出风险在递归函数里使用尤其危险。我个人不推荐生产代码里大量用 alloca除非你知道这是一条严格受限的短路径。对齐控制是另一个容易被忽略的点。当你的对象需要 64 字节对齐比如你要用 AVX-512 处理数据或者你要让对象正好占用一个 cache line 避免伪共享默认 malloc 只能保证 16 字节对齐。这时可以用 C11 的 aligned_alloc、C17 的 aligned new或者 posix_memalign 主动控制对齐// C17 对齐分配 struct alignas(64) CacheLine { int counter 0; }; auto* p new CacheLine();注意对齐不仅影响性能还影响正确性。走 misaligned 地址访问某些 SIMD 指令会直接触发 SIGSEGV。所以做自定义分配器时allocate 方法里必须显式处理对齐offset (offset align - 1) ~(align - 1)这一步省不得。3. 内存池设计把分配掌握在自己手里的通用方案3.1 内存池到底解决什么问题我见过很多团队上内存池的姿势是错的。他们觉得“内存池 预分配一堆内存”于是启动时分配 2GB运行时从里面切看起来很美但完全没有考虑对象大小分布、线程模型、释放时机最后内存池内部自己碎成一片性能反而不如 glibc。真正值得用内存池的场景有两个共性一是对象创建/销毁极其频繁二是对象大小相对固定。典型的比如网络连接对象的收发缓冲区、游戏里的子弹/粒子对象、数据库里的行缓存节点。这些对象生命周期短频繁 malloc/free 会导致大量的 global lock 竞争和 cache miss用内存池统一管理就能显著提升确定性。3.2 一个具备生产级思路的定长内存池我不写那种只能跑 demo 的玩具代码这里给一个“至少能理解核心思想并且拿去改一改能用”的方案。核心思路是用 free list 串联空闲块class FixedPool { public: explicit FixedPool(size_t blockSize) : blockSize_(blockSize), firstFree_(nullptr) {} void* alloc() { if (!firstFree_) { grow(); } Node* p firstFree_; firstFree_ p-next; return static_castvoid*(p); } void free(void* ptr) { auto* p static_castNode*(ptr); p-next firstFree_; firstFree_ p; } private: struct Node { Node* next; }; void grow() { // 每次扩展分配 64 块避免频繁 system call constexpr size_t batchSize 64; char* chunk new char[blockSize_ * batchSize]; for (size_t i 0; i batchSize; i) { free(chunk i * blockSize_); } chunks_.push_back(chunk); } size_t blockSize_; Node* firstFree_; std::vectorchar* chunks_; };这份代码的核心就是把“向系统要内存”变成“向自己维护的链表要内存”。alloc 操作只是移走链表头节点free 只是把节点插回链表头全程不需要系统调用、不需要加锁单线程场景下耗时是纳秒级的。几个可以继续完善的点加线程局部存储让每个线程拥有自己的 free list从根源上避免锁竞争把空闲链表指针直接放在空闲块内部省去额外元数据分配时用无锁栈atomic 的 fetch_add实现跨线程安全覆盖高并发下的对象分配。这些都是生产级内存池的标准动作。3.3 对象池比内存池更好使的一个场景对象池和内存池的区别在于对象池负责管理的是“构造/析构完成的对象”而不是“原始字节”。比如你有一个连接对象 Connection创建时要打开 socket销毁时要做一些资源清理。用定长内存池管理原始内存后你仍然需要每次 new/delete 来执行构造析构。这时对象池的优势就出来了——它把“分配原始内存”和“构造对象”拆开管理对象用完后回到池子里下次可以直接用 placement new 重新构造避免重复的构造析构开销。但对象池有一个容易被踩的坑池里的对象状态必须重置干净。如果对象里有一个 std::string 或者 std::vector归还到池子时没有 clear 干净下次复用就可能带着上次的数据。我用过的方案是定义一个 reset() 方法归还时统一调用并且把 reset 性能当作对象池的核心考核指标。3.4 直接用现成的 tcmalloc/jemalloc 不香吗如果不想自己写池子用 tcmalloc、jemalloc 替换全局分配器是更务实的选择。它们对多线程、小对象做了大量优化很多场景下能从“控制分配”这个角度获得肉眼可见的效果。选型建议是追求大规模多线程稳定性用 jemalloc配合 profiling 工具排查分配热点用 tcmalloc嵌入式环境内存紧张、代码体积敏感就用 mimalloc它在性能和内存占用之间平衡得不错。注意不要一上来就换分配器先做一段时间的 baseline 压测。jemalloc、tcmalloc 都有“分配器预取内存”的行为有时候会让你的 RSS 比 glibc 高不少代价是延迟更稳定。这个 trade-off 得结合你的业务判断。4. 系统层面的内存调控让内核配合你4.1 用 mlock 保护关键内存不被换出进程内存可以被系统交换到磁盘上一旦关键线程访问的内存被换出就要先触发缺页中断去磁盘上把数据读回来这个延迟可能持续毫秒级对低延迟系统是致命的。如果你有一些必须常驻物理内存的数据结构比如队列的环形缓冲、共享状态、锁外的热变量可以用 mlock 把它们锁在物理内存里#include sys/mman.h char* buf static_castchar*(mmap(nullptr, 64 * 1024 * 1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)); if (buf MAP_FAILED) { // handle error } if (mlock(buf, 64 * 1024 * 1024) ! 0) { // 很可能是 RLIMIT_MEMLOCK 限制不够需要调 ulimit }实际上我把 mlock 当“保险丝”用它不负责降低平时延迟只负责防止最坏情况发生。注意两点mlock 对内存大小有权限限制普通用户默认能锁落的量很小需要 root 或者调 RLIMIT_MEMLOCKmlock 的内存不能无限膨胀锁得越多系统可回收内存越少反而可能触发整机内存压力。在我的实践里锁定的范围只限于极少数核心结构绝不锁一切。4.2 主动把内存归还给系统malloc_trim 与 madvise前面说了默认分配器会扣着内存不还。有些场景下你能感知到高峰期过去了内存用在下跌但你不想被动等内核触发回收。glibc 提供了一个函数 malloc_trim可以尝试将堆顶的空闲内存归还给操作系统。在 Linux 上这个操作主要通过 madvise(MADV_DONTNEED) 或者调用 sbrk 收缩堆来实现// 建议在低峰期执行不要放在关键链路上 malloc_trim(0);更精细的做法是直接对 mmap 出来的大块匿名内存做 madvise// 归还 mmap 区域让内核释放相关物理页 madvise(buf, buf_size, MADV_DONTNEED);不过这里踩坑很多。malloc_trim 这个函数只对 glibc 的 ptmalloc 有效如果你用的是 tcmalloc 或 jemalloc它根本不认识这些 chunk归还动作不会发生。MADV_DONTNEED 对匿名内存的含义是“我现在不需要这些页了你可以丢”但如果你的业务代码还会继续访问这些内存那下次访问会重新触发缺页分配代价更大。所以我的原则是只有在确认这块内存短期内不会再访问时才用 MADV_DONTNEED。另外madvise 和 mmap 的地址、长度必须页对齐传一个非页对齐长度不会报错但实际执行的归还区域并不会包含你预期的末位部分容易造成“以为归还了实际没有”的假象。4.3 直接限制内存上限进程级与容器级兜底现实世界不会等你优雅地优化总有那么几次你的程序内存暴涨原因可能是某个 bug 或者某个极端流量。这时至少要在系统层面做“保险丝”给进程加上地址空间上限和物理内存上限。最直接的姿势是用 setrlimit 设置 RLIMIT_AS 和 RLIMIT_RSS不过 RLIMIT_RSS 在现代 Linux 上支持有限主要靠 cgroup。通过 ulimit 命令在启动脚本里设置地址空间上限就是典型做法ulimit -v 4194304 # 限制虚拟内存 4GB ./your_server容器场景下用 cgroup 更标准。cgroup v2 的 memory.max 限制会触发 OOM 时对进程做裁决而 cgroup v1 的 memory.limit_in_bytes 配合 memory.high 可以做更细腻的水位控制。具体数值要根据业务压测结果来定不要拍脑袋写一个数那样很容易在流量小高峰就触发 OOM Kill。我接手过一个案例服务里有一个缓冲模块正常情况下内存占用 300MB 左右。后来出过一次极端流量瞬时把系统内存打爆宿主机的其他服务也受了影响。加了一层 cgroup 限制后即使极端流量来了最多是这个模块崩溃重启不再把整台机器“拖下水”。这种“资源隔离式的有限可控”也是控制内存分配的一种高级姿势。5. 常见问题与排查技巧实录5.1 自定义分配器最容易踩的坑先列举我在实际代码 review 里见过最多的几个坑每个都是血泪换来的。一是忘记处理对齐。自定义内存池从原始 buffer 里切内存如果直接按字节偏移走分配出的地址可能没对齐到 8 字节或 16 字节。在 x86 上可能没那么敏感能容忍轻微 unaligned但放到 ARM 上很多指令在 unaligned 地址上直接崩。所以自定义分配器一定要写对齐代码并在单元测试里显式验证返回地址的 align 等级。二是分配与释放必须配对。从一个内存池里 alloc 出来的内存不要交给另一个池子 free更不能反过来交给真的 free()。我做过的项目中统一用一个返回池 id 的 handle 来管理防止跨池释放。但 C 的 RAII 很容易掩盖这类问题建议在 debug 构建里给每个池打 tagfree 时校验 tag不一致就 abort。三是内存池膨胀后不收回去。你的池子在高并发时扩展了一大块内存低峰期不自动收缩会导致进程常驻内存高得吓人。设计时要想清楚池子里的空闲块超过阈值后释放一部分 chunk 给系统还是保留作为“容量水位”我的建议是像 jemalloc 那样做 decaying空闲超过一定时间自动回收 pages。四是使用 alloca 或 VLA 导致栈溢出。这是我在一个递归解析器里遇到过的事递归深度不大但每次递归都分配几 KB 栈空间最终栈爆了。后来改成在堆上分配并按需复用问题消失。5.2 怎么排查“内存只涨不降”的老大难问题当你在监控里看到 RSS 缓慢上涨第一反应不要急着下“内存泄漏”的结论。先用下面这套顺序排查先用 pmap 看进程地址空间分布确认增长是来自堆还是 mmap。增长在堆上大概率是 ptmalloc 没归还增长在 mmap 上要重点关注大块分配和匿名映射可能是某些库自建的 arena。第二步用 valgrind 的 massif 工具对堆内存做采样它能把分配点按调用栈分层展示能看到是哪个模块、哪条路径分配了最多内存。如果你的程序并发高然后用 valgrind 跑会慢几十倍这时用 tcmalloc 的 profiling 功能做替换在编译时链接 libtcmalloc运行后用 environment variable 启动 heap profiler输出写堆栈快照对比不同时间点。这样在线就能定位热点分配路径不需要停下来单线程调。第三步也是最容易被遗漏的检查“扣内存不还”的分配器行为。如果你换过 tcmalloc 或 jemalloc其内部会保留一部分 cached 内存RSS 高但实际有效数据很低。这种场景下要调整 background_thread reduce cache 之类的参数而不是去代码里找并不存在的泄漏。5.3 分配器选型速查表场景推荐方案理由通用服务端高并发jemalloc / tcmalloc多线程伸缩性好分配延迟更平稳游戏/实时渲染mimalloc分配开销低缓存友好适合每帧大量小对象释放嵌入式/内存严格受限自研定长内存池可精确预测内存峰值杜绝系统调用低频业务/脚本工具默认 malloc/free没必要引入复杂度一次 RPC 内的大量临时对象Arena 线性分配一次性生命周期省去逐对象 free需要在线定位分配热点tcmalloc heap profiler可以低成本做线上 heap 采样这个表不是金科玉律但它覆盖了我这些年见过的绝大多数场景。选型的关键思路是“先测量再选型”跑一版压测分别记录 p50/p99 延迟、分配的 syscall 次数、cache miss 率拿数据说话不要拍脑袋换分配器。5.4 关于 free 之后内存去哪了的经典困惑很多人问我明明 free 了对象为什么 RSS 没降这问题值得再强调一次。free 只是把内存块归还给分配器的空闲链表分配器未必立刻把它还给操作系统。它这样做是为了让你下一次 malloc 时更快——不然你每分配一个对象都要触发内核调用那所有程序的性能都会崩溃。理解这个机制就能理解为什么“控制内存分配”本质上是在跟“延迟”“吞吐”“内存占用”三者做平衡。你想让 malloc 更快它就会囤内存你想让它及时归还就要多付系统调用成本。而“控制”的关键就是根据业务节奏决定哪个优先级更高。写在最后的实操体会我个人在优化一个消息队列模块时真正体会到“控制”的威力。当时每秒钟大概几万条消息进出每条消息创建/销毁一个封装对象用的默认 new/delete。延迟看起来平均只有 20 微秒但 p99 经常跳到 200 微秒以上。用 jemalloc profiling 定位后发现大部分毛刺来自分配器锁竞争和偶发的系统调用。后来把消息对象改成从固定大小的 slab 池里取madvise 控制大型页的回收节奏p99 从 200 微秒降到了 35 微秒而且内存曲线稳定得看不出波动。控制内存分配并不是让你把每个字节都要抓在自己手里而是让你在关键时刻拥有“确定性地获取内存”的能力。先用好现成的工具jemalloc、tcmalloc、内存剖析再考虑染指自研池子——顺序别反了。另外再分享一个细节无论是 mlock、malloc_trim 还是自定义池子所有控制手段都不要放进默认热路径它们适合放在明确的边界层比如对象生命周期切换点、业务高峰低峰切换点。把这些边界管好内存这块基本就踏实了。
返回列表