《系统架构师成长之路》第 2 期|计算机基础:从一次 malloc 看懂内存管理
导读 :第 1 期我们画出了完整能力地图。这一期进入阶段一「计算机基础」的第一个硬核主题------内存管理。为什么服务端要区分虚拟内存和物理内存?为什么
malloc一个 16 字节的请求,实际占用的内存远不止 16 字节?为什么高并发下要换 jemalloc/tcmalloc?我们从"一次 malloc 的完整旅程"讲起,一步步推导出分页、堆、chunk、碎片化的原理,最后落到高并发与 GC 的工程实践。
一、虚拟内存:每个进程都拥有"独立的世界"
在 32 位系统上,一个进程能看到 4GB 的地址空间(0x00000000 ~ 0xFFFFFFFF),但这 4GB 不是它独占的物理内存,而是虚拟地址空间 。CPU 访问任何地址都要经过 MMU(内存管理单元) 的翻译:
虚拟地址 VA ──► 页表(Page Table)查表 ──► 物理地址 PA ──► 物理内存
内存按固定大小分页(Linux 默认 4KB),页表记录每个虚拟页映射到哪个物理页帧。如图 2-1 所示,翻译的核心就是一次"页号查表 + 页内偏移拼接"。

图2-1 虚拟地址翻译:VA 拆成「页号 + 页内偏移」,页号查页表得物理页帧号,拼接物理页帧号与偏移即得 PA。
推导(翻译过程,不跳步):设页大小 4KB = 2^12 B,虚拟地址 32 位:
- 低 12 位是页内偏移 (offset):
offset = VA & 0xFFF; - 高 20 位是虚拟页号 (VPN):
VPN = VA >> 12; - 用 VPN 查页表,得物理页帧号(PFN);
- 物理地址 = PFN × 4096 + offset ,即
PA = (PFN << 12) | offset。
技巧(位拆分):把地址看成"页码 + 页内偏移"两部分,是理解一切分页/分段系统的钥匙------TLB 缓存、缺页中断、内存映射文件(mmap)都建立在同一套地址结构上。
为什么要虚拟内存?三个理由:隔离 (进程 A 无法直接访问进程 B 的物理页,崩溃互不牵连)、按需加载 (代码段没用到就不调入物理内存,允许多个进程共享同一份只读代码)、简化链接(每个进程都能把代码加载到固定虚拟地址,链接器不用关心物理位置)。
二、进程的内存布局:堆与栈的分工
一个进程的虚拟地址空间从低到高依次为(如图 2-2):
┌─────────────────────────┐ 高地址
│ 栈(stack,向低地址生长) │
├─────────────────────────┤
│ ↓空闲↓ │ (栈堆相向生长)
│ 堆(heap,向高地址生长) │
├─────────────────────────┤
│ BSS 段(未初始化全局) │
│ 数据段(已初始化全局) │
│ 代码段(.text,只读) │
└─────────────────────────┘ 低地址

图2-2 进程内存布局:代码/数据/BSS 在低地址,堆向高地址生长,栈向低地址生长,中间为共享空闲区。
关键区别:
- 栈:函数调用自动分配/释放,速度快,容量小(默认 8MB),溢出会向下撞到堆;
- 堆 :
malloc/new动态分配,容量大,但分配/释放的时机、大小都不可预测------这正是内存管理器的用武之地; - 全局/静态变量在数据段/BSS,字符串常量在只读区。
三、malloc 的底层旅程(核心推导)
malloc(16) 发生了什么?glibc 的分配器(ptmalloc)按请求大小走两条路:
- 小块(< 128KB,默认阈值) :通过
brk/sbrk系统调用把堆的"程序断点"(break)向高地址推进,扩展堆顶; - 大块(≥ 128KB) :直接
mmap映射一块独立内存,释放时munmap立即归还内核。
无论哪条路,malloc 都不会"正好"给你 16 字节------它要把内存组织成 chunk,并在 chunk 头里记录管理信息。chunk 结构(64 位系统)大致为:
struct chunk {
size_t prev_size; // 前一个 chunk 的大小(若前块空闲)
size_t size; // 本 chunk 大小(低 3 位为标志位)
// 数据区从这里开始,用户拿到的是 data 指针
char data[]; // 至少 8 字节(64 位)
};

图2-3 malloc chunk:头部 16 字节(prev_size + size),数据区对齐后 16 字节------malloc(16) 总占用 32 字节,管理开销 100%。
推导(malloc 实际占用):请求 16 字节时------
- chunk 头 = prev_size(8B) + size(8B) = 16 字节;
- 数据区需满足 16 字节对齐(x86-64 对齐到 16B),16 字节请求已对齐;
- chunk 总大小 = 16(头) + 16(数据) = 32 字节。
所以 malloc(16) 实际占 32 字节,管理开销 100% 。同理 malloc(1) 也会占 32 字节------这就是"小对象要慎用"的原因。技巧(对齐上取整) :对齐后大小 = ((size + align - 1) / align) × align,即 ⌈(n + 15) / 16⌉ × 16。
分配器还维护空闲链表 (free list),释放时把 chunk 挂回对应大小的 bin;再次分配时用首次适应 (First Fit)或最佳适应 (Best Fit)找到合适块。若相邻块都空闲,会触发 consolidation(合并),把碎片拼成大块。
四、碎片化:内存分配的"熵"
碎片分两类:
- 内部碎片:分配器按对齐/最小粒度多给的内存(如 malloc(1) 实际占 32B,浪费 31B);
- 外部碎片 :空闲内存总量充足,但不连续,无法满足大请求。例如堆里有 3 块 10KB 空闲块,中间各夹着 5KB 的已分配块,此时
malloc(30KB)失败------即使空闲总量 30KB。
推导(外部碎片产生) :交替执行 malloc(5KB) → malloc(10KB) → free(5KB) → malloc(10KB) → free(10KB) → malloc(20KB)------前两次 10KB 空闲块被 5KB 和 10KB 占用切割,20KB 请求无法在连续区满足。工程对策:内存池(固定大小对象复用)、伙伴系统(Buddy,按 2 的幂分块)、使用 jemalloc/tcmalloc 等无碎片或低碎片分配器。
五、横向延展:内存管理如何影响高并发与 GC
-
高并发下的锁竞争 :ptmalloc 的 free list 是全局的,多线程 malloc 会抢同一把锁,高并发时分配成为瓶颈。tcmalloc 的解法是线程本地缓存(ThreadCache):每个线程优先从自己的缓存分配,用完了才去中心堆取------把"全局竞争"变成"局部命中"。jemalloc 类似(arena 分区)。
-
GC 语言的内存分配 :JVM 的年轻代分配用 TLAB(Thread Local Allocation Buffer) ,Go 用 mcache------本质都是"每个线程一块私有缓冲区,减少跨线程同步"。GC 回收的本质是把"手动 free"变成"自动追踪",但代价是 STW 停顿与内存占用更高。
-
常见坑:
- 内存泄漏:分配了不释放,长期运行内存只涨不降(用 valgrind/ASAN 检测);
- 踩内存:越界写破坏相邻 chunk 头,报错点远离出错点(ASAN 能精准定位);
- OOM :容器内存超限被内核 OOM Killer 杀掉------堆大小要按业务峰值 × 安全系数预留,并配置 swap/监控告警。
小结
- 虚拟内存通过页表翻译实现隔离、按需加载与共享,地址 = 页号 × 页大小 + 偏移;
- 进程布局:代码/数据/BSS 低地址,堆向上、栈向下相向生长;
malloc底层走 brk 或 mmap,chunk 头 16 字节------malloc(16)实际占 32 字节,管理开销 100%(对齐上取整技巧);- 碎片分内部/外部两类,内存池与 jemalloc/tcmalloc 是主流对策;
- 高并发与 GC 都靠"线程本地缓冲"缓解分配竞争:tcmalloc ThreadCache、JVM TLAB、Go mcache 异曲同工。
思考题
- 用"PA = (PFN << 12) | offset"推导:虚拟地址 0x12345 的页内偏移和虚拟页号分别是多少(页 4KB)?
- 你的服务
malloc(24)一万次,按 64 位系统对齐规则,实际消耗堆内存约多少字节?管理开销占比多少? - 线程池大小与内存分配有什么关系?为什么说"线程数越多,内存分配竞争越严重"?
下期预告
第 3 期「计算机网络:TCP 三次握手与可靠传输」------从 SYN 队列到拥塞控制,推导 RTT 与吞吐量的关系,以及它在高并发网关设计中的体现。
参考资源
- 《深入理解计算机系统》(CSAPP) 第 9 章:虚拟内存
- glibc malloc 源码剖析(ptmalloc):https://sourceware.org/glibc/wiki/MallocInternals
- jemalloc 官方文档:https://jemalloc.net/
- tcmalloc 设计文档:https://google.github.io/tcmalloc/design.html
- 《程序员自我修养:链接、装载与库》内存布局章节
- JVM TLAB 与 Go mcache 官方文档:https://docs.oracle.com/javase/specs/ 与 https://go.dev/doc/