《系统架构师成长之路》第 2 期|计算机基础:从一次 malloc 看懂内存管理

《系统架构师成长之路》第 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 位:

  1. 低 12 位是页内偏移 (offset):offset = VA & 0xFFF;
  2. 高 20 位是虚拟页号 (VPN):VPN = VA >> 12;
  3. 用 VPN 查页表,得物理页帧号(PFN);
  4. 物理地址 = 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)按请求大小走两条路:

  1. 小块(< 128KB,默认阈值) :通过 brk/sbrk 系统调用把堆的"程序断点"(break)向高地址推进,扩展堆顶;
  2. 大块(≥ 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 字节时------

  1. chunk 头 = prev_size(8B) + size(8B) = 16 字节;
  2. 数据区需满足 16 字节对齐(x86-64 对齐到 16B),16 字节请求已对齐;
  3. 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

  1. 高并发下的锁竞争 :ptmalloc 的 free list 是全局的,多线程 malloc 会抢同一把锁,高并发时分配成为瓶颈。tcmalloc 的解法是线程本地缓存(ThreadCache):每个线程优先从自己的缓存分配,用完了才去中心堆取------把"全局竞争"变成"局部命中"。jemalloc 类似(arena 分区)。

  2. GC 语言的内存分配 :JVM 的年轻代分配用 TLAB(Thread Local Allocation Buffer) ,Go 用 mcache------本质都是"每个线程一块私有缓冲区,减少跨线程同步"。GC 回收的本质是把"手动 free"变成"自动追踪",但代价是 STW 停顿与内存占用更高。

  3. 常见坑:

    • 内存泄漏:分配了不释放,长期运行内存只涨不降(用 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 异曲同工。

思考题

  1. 用"PA = (PFN << 12) | offset"推导:虚拟地址 0x12345 的页内偏移和虚拟页号分别是多少(页 4KB)?
  2. 你的服务 malloc(24) 一万次,按 64 位系统对齐规则,实际消耗堆内存约多少字节?管理开销占比多少?
  3. 线程池大小与内存分配有什么关系?为什么说"线程数越多,内存分配竞争越严重"?

下期预告

第 3 期「计算机网络:TCP 三次握手与可靠传输」------从 SYN 队列到拥塞控制,推导 RTT 与吞吐量的关系,以及它在高并发网关设计中的体现。

参考资源

相关推荐
2401_872418782 小时前
操作系统|进程管理、线程、系统调用、进程间通信与信号
操作系统·pcb·os
艾iYYY20 小时前
【无标题】
linux·开发语言·网络·操作系统
打不了嗝 ᥬ᭄2 天前
计算机系统基础:异常、中断与 I/O 的完整脉络
操作系统·计算机系统基础·linux系统调用·异常与中断·io与dma
doujiaozh3 天前
我在 Win7 老电脑上从零手搓了一个操作系统内核:32位分页、中断接管与 Shell 终端
操作系统·汇编语言
打不了嗝 ᥬ᭄4 天前
计算机系统基础:程序的表示、转换与链接
操作系统·计算机系统基础
Coisinier5 天前
docker命令管理镜像及容器
linux·运维·nginx·docker·容器·操作系统
云计算练习生6 天前
什么是内存分配算法?首次适应、最佳适应、最坏适应
操作系统·内存·内存分配算法·内存算法
imDwAaY6 天前
进程之间如何“对话”?一篇讲清六种进程间通信方式
后端·操作系统
MinterFusion6 天前
如何在开放麒麟(openKylin)下安装GTK(v0.1.0)
linux·操作系统·软件安装·gtk·明德融创·openkylin·linux图形软件开发