堆、栈、数据段与代码段
#系统 · #内存 · #堆 · #栈 · #ELF · #malloc · #地址空间 · #tcmalloc · #jemalloc
深入理解进程地址空间布局、多级页表实现、栈帧结构、动态内存分配(malloc/free 实现)、ELF 文件格式、现代分配器对比。
1. 进程地址空间全景
1.1 Linux x86-64 进程地址空间布局
0x00007FFFFFFFFFFF
┌──────────────────────────────┐ ← 用户栈顶
│ 栈 (Stack) │
│ ↓ 向下增长 │
│ [ argv, envp ] │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ ↕ │
│ (未分配区域) │
│ ↕ │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ 共享库映射区域 │
│ libc.so, ld.so, ... │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ mmap 区域 (动态映射) │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ 堆 (Heap) │
│ ↑ 向上增长 │
│ [ brk 指针 ] │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ .bss 段 │ ← 未初始化全局/静态变量
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ .data 段 │ ← 已初始化全局/静态变量
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ .rodata 段 │ ← 只读数据 (字符串常量)
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ .text 段 │ ← 代码 (机器指令)
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ ELF Header │
└──────────────────────────────┘
0x0000000000400000 (典型基址)1.2 各段对比
| 段 | 存储内容 | 读写属性 | 是否可执行 | 有无初始值 |
|---|---|---|---|---|
.text | 机器指令 | R | X | 有 |
.rodata | 字符串常量、跳转表 | R | - | 有 |
.data | 已初始化全局/静态变量 | RW | - | 有 |
.bss | 未初始化全局/静态变量 | RW | - | 无(零填充) |
| Heap | malloc 分配 | RW | - | 运行时 |
| mmap | 文件映射、匿名映射 | 可变 | 可变 | 运行时 |
| Stack | 局部变量、返回地址 | RW | - | 运行时 |
2. 多级页表深度分析
2.1 为什么需要多级页表
单级页表的问题:48位虚拟地址,4KB页 → 2^36 = 680亿个 PTE,每个 8B → 单级页表 = 512GB(比物理内存还大)。
多级页表解决:只有实际使用的虚拟地址区域才分配页表。每一级是一个 4KB 页(512 个 8B 条目),未使用的分支不分配。
2.2 x86-64 四级页表详解
虚拟地址: [PML4(9位) | PDPT(9位) | PD(9位) | PT(9位) | 偏移(12位)]c
// 模拟 x86-64 四级页表遍历
#include <stdint.h>
typedef uint64_t pte_t;
#define PTE_PRESENT (1ULL << 0)
#define PTE_RW (1ULL << 1)
#define PTE_HUGE (1ULL << 7)
#define PTE_NX (1ULL << 63)
static inline int pml4_index(uint64_t va) { return (va >> 39) & 0x1FF; }
static inline int pdpt_index(uint64_t va) { return (va >> 30) & 0x1FF; }
static inline int pd_index(uint64_t va) { return (va >> 21) & 0x1FF; }
static inline int pt_index(uint64_t va) { return (va >> 12) & 0x1FF; }
static inline int page_offset(uint64_t va){ return va & 0xFFF; }
static inline uint64_t pte_ppn(pte_t pte){ return (pte >> 12) & ((1ULL<<40)-1); }
uint64_t translate_va(uint64_t va, uint64_t cr3) {
pte_t *pml4 = (pte_t *)(cr3 & ~0xFFF);
pte_t pml4e = pml4[pml4_index(va)]; // 1. PML4
if (!(pml4e & PTE_PRESENT)) return 0; // 缺页!
pte_t *pdpt = (pte_t *)(pte_ppn(pml4e) << 12);
pte_t pdpte = pdpt[pdpt_index(va)]; // 2. PDPT
if (!(pdpte & PTE_PRESENT)) return 0;
if (pdpte & PTE_HUGE) // 1GB 大页
return (pte_ppn(pdpte) << 30) | (va & 0x3FFFFFFF);
pte_t *pd = (pte_t *)(pte_ppn(pdpte) << 12);
pte_t pde = pd[pd_index(va)]; // 3. PD
if (!(pde & PTE_PRESENT)) return 0;
if (pde & PTE_HUGE) // 2MB 大页
return (pte_ppn(pde) << 21) | (va & 0x1FFFFF);
pte_t *pt = (pte_t *)(pte_ppn(pde) << 12);
pte_t pte = pt[pt_index(va)]; // 4. PT
if (!(pte & PTE_PRESENT)) return 0;
return (pte_ppn(pte) << 12) | page_offset(va);
}2.3 五级页表(Intel Ice Lake+)
x86-64 五级页表 (57位虚拟地址):
[PML5(9) | PML4(9) | PDPT(9) | PD(9) | PT(9) | Offset(12)]
虚拟地址空间: 48位 → 57位 = 256TB → 128PB
需启用 CR4.LA57 = 12.4 各架构页表方案对比
| 架构 | 页表层级 | 虚拟地址位 | 页大小 | 大页选项 |
|---|---|---|---|---|
| x86-64 (4级) | 4 | 48 | 4KB | 2MB, 1GB |
| x86-64 (5级) | 5 | 57 | 4KB | 2MB, 1GB |
| ARMv8 (4KB) | 4 | 48 | 4KB | 2MB, 1GB |
| ARMv8 (64KB) | 3 | 48 | 64KB | 512MB |
| RISC-V Sv39 | 3 | 39 | 4KB | 2MB, 1GB |
| RISC-V Sv48 | 4 | 48 | 4KB | 2MB, 1GB |
3. 实存、虚存与 Swap
虚存将物理内存 + 磁盘结合,让每个进程"拥有"独立的大地址空间。Swap 在物理内存不够时换出页面到磁盘交换分区。
bash
# 查看 swap
free -h && swapon --show
# 调整 swap 倾向 (0=尽量不用swap, 100=积极使用)
cat /proc/sys/vm/swappiness # 默认 60
# 查看哪些进程在用 swap
for f in /proc/*/status; do
awk '/^Name:|^VmSwap:/{printf $2" "$3" "}' $f 2>/dev/null; echo
done | grep -v " 0 kB" | sort -k2 -nr | head -10
# OOM 保护 — 降低被 kill 的优先级
echo -1000 > /proc/<PID>/oom_score_adj4. 栈(Stack)
4.1 栈帧结构(x86-64)
高地址
┌─────────────────────┐
│ 调用者的栈帧 │
│ 参数 n │
│ ... │
│ 参数 7 (如果有) │ ← 超过6个的参数通过栈传递
├─────────────────────┤
│ 返回地址 │ ← call 指令压入
├─────────────────────┤ ← %rbp (帧指针,可选)
│ 旧的 %rbp │ ← 被调用者保存
│ 局部变量 │
│ 被保存的寄存器 │
│ (callee-saved) │
│ │
│ 临时空间 │
│ (如果栈需要对齐) │
├─────────────────────┤ ← %rsp (栈指针)
│ 参数传递区域 │ ← 调用下一函数时
│ 为被调用者准备 │
└─────────────────────┘
低地址4.2 栈操作
asm
; 函数入口 (prologue)
pushq %rbp ; 保存旧帧指针
movq %rsp, %rbp ; 建立新帧指针
subq $N, %rsp ; 分配局部变量空间
; ... 函数体 ...
; 函数出口 (epilogue)
leave ; movq %rbp, %rsp; popq %rbp
ret ; 弹出返回地址并跳转4.3 栈的增长
- 栈向低地址增长
- 栈增长通过自动扩展实现:访问栈下方的地址触发缺页,内核自动分配物理页
- Linux 使用
RLIMIT_STACK(默认 8MB)限制栈大小 - 栈溢出:递归过深或局部数组过大导致超出限制 → SIGSEGV
4.4 局部变量存放策略
编译器优先将局部变量放在寄存器中(寄存器分配)。变量必须放在栈上的情况:
- 取地址:
&x - 寄存器不够用(寄存器溢出/Spill)
- 变量太大(如数组)
- 跨函数调用需存活
5. 堆(Heap)
5.1 brk 和 mmap
Linux 提供两种方式扩展堆:
| 方式 | 接口 | 大小限制 | 适用 |
|---|---|---|---|
brk/sbrk | 调整 program break | 连续区域 | 小块分配 |
mmap | 映射匿名内存 | 独立区域 | 大块分配 (默认>128KB) |
brk 方式: .data段 ━━━━ brk ─── 新brk (扩展)
mmap 方式: 独立虚拟地址区域 (进程地址空间的mmap区域中)5.2 malloc/free 实现原理
现代 malloc 实现(如 glibc 的 ptmalloc2)的核心数据结构:
Chunk 结构:
已分配 chunk:
┌────────────┬──────────┬──────────────────┐
│ prev_size │ size|标志 │ 数据 │
│ (8B) │ (8B) │ (...实际请求...) │
└────────────┴──────────┴──────────────────┘
空闲 chunk:
┌────────────┬──────────┬──────────┬──────────┬──────────────┐
│ prev_size │ size|标志 │ fd │ bk │ 未使用空间 │
│ (8B) │ (8B) │ (前向指针)│ (后向指针)│ │
└────────────┴──────────┴──────────┴──────────┴──────────────┘Bins(空闲链表):
| Bin 类型 | 数量 | 大小范围 | 组织方式 |
|---|---|---|---|
| Fast bins | ~10 | 16~80B | 单链表,LIFO |
| Small bins | 62 | 16~1008B | 双链表,FIFO |
| Large bins | 63 | >1008B | 双链表,按大小排序 |
| Unsorted bin | 1 | 任意 | 双链表(回收时暂存) |
分配流程:
malloc(size)
├── size <= max_fast → 从对应 fastbin 分配
├── size <= max_small → 查 smallbin 或 unsorted bin
├── 其他 → 查 large bin、合并切割、mmap/sbrk 扩展
└── 完全不行 → 返回 NULL (或触发 OOM)CSAPP Malloc Lab 风格的简单分配器:
c
#define WSIZE 8
#define PACK(size, alloc) ((size) | (alloc))
#define GET(p) (*(unsigned long *)(p))
#define GET_SIZE(p) (GET(p) & ~0x7)
#define GET_ALLOC(p)(GET(p) & 0x1)
static char *heap_listp;
void *simple_malloc(size_t size) {
size_t asize = ((size + WSIZE - 1) / WSIZE + 1) * WSIZE;
char *p = heap_listp;
// 首次适配搜索
while (GET_SIZE(p) > 0) {
if (!GET_ALLOC(p) && GET_SIZE(p) >= asize) {
PUT(p, PACK(asize, 1)); // 放置
return p + WSIZE; // 返回有效载荷指针
}
p += GET_SIZE(p);
}
return NULL;
}5.3 ptmalloc2(glibc)— 最广泛部署的分配器
起源:Wolfram Gloger 基于 Doug Lea 的 dlmalloc 改造,2006 年随 glibc 2.3 成为 Linux 默认分配器。
核心架构 — Arena(分配域):
进程启动 → 主 arena (main_arena),通过 sbrk 扩展
线程增多 → 创建新 arena(mmap 分配),上限 = 8 × CPU 核数
arena 复用:线程退出后 arena 不释放,存入 free_list 供新线程复用为什么需要多 arena:多个线程同时 malloc 时争抢同一把锁。多 arena 让不同线程落在不同 arena,减少锁争用。
c
// arena 选择逻辑(简化)
// 每个线程记录自己上次使用的 arena (tsd.arena)
// 调用 malloc → trylock(tsd.arena)
// 成功 → 使用该 arena
// 失败 → 遍历 arena 列表,trylock 下一个
// 全部失败 → 创建新 arena(未达上限时)Chunk 结构:
已分配 chunk: 空闲 chunk (在 bin 中):
┌────────────┬──────────┬─────────┐ ┌────────────┬──────────┬──────┬──────┬──────┬──────────┐
│ prev_size │ size|A|M|P│ data │ │ prev_size │ size|A|M|P│ fd │ bk │fd_nxt│bk_nxt │
│ (8B) │ (8B) │ │ │ (8B) │ (8B) │(前向)│(后向)│(仅大)│(仅大) │
└────────────┴──────────┴─────────┘ └────────────┴──────────┴──────┴──────┴──────┴──────────┘
标志位 (size 的低 3 位):
A (bit 0) = ALLOCATED: 上一块是否已分配(0=空闲, 1=已分配)
M (bit 1) = MMAPPED: 本块是否由 mmap 分配
P (bit 2) = PREV_INUSE: 前一块是否在使用(用于合并判断)Bins 体系 — 四级空闲链表:
┌──────────────────────────────────────────────────────────────┐
│ ptmalloc2 Bins │
├──────────┬───────────┬───────────┬───────────┬──────────────┤
│ tcache │ fastbins │ smallbins │ largebins │ unsorted bin │
│(2.26+) │ │ │ │ │
├──────────┼───────────┼───────────┼───────────┼──────────────┤
│ 每线程 │ 单链表 │ 双链表 │ 双链表 │ 双链表 │
│ 64个桶 │ 10个桶 │ 62个桶 │ 63个桶 │ 1个桶 │
│ 24~1032B │ 16~80B │ 16~1008B │ >1008B │ 任意大小 │
│ LIFO │ LIFO │ FIFO │ 排序 │ FIFO │
│ 无锁(线程)│ 不合并 │ 精确匹配 │ 最佳匹配 │ 暂存区 │
└──────────┴───────────┴───────────┴───────────┴──────────────┘分配路径(glibc 2.26+):
malloc(size)
├── size ≤ tcache 最大 bin
│ └── tcache 有对应块? yes → 返回 (极快路径,无锁)
│ no → 继续
├── size ≤ max_fast
│ └── fastbin 有对应块? yes → 从 fastbin 取,填充 tcache → 返回
├── size ≤ max_small
│ ├── 查对应 smallbin
│ └── 否则遍历 unsorted bin(同时将块归类到正确的 bin)
├── 查 large bin(最佳匹配)
├── 以上都失败 → 合并 fastbin → 再次搜索
├── 仍失败 → 从 top chunk 切一块
└── top chunk 不够 → sbrk 扩展 / mmap 分配新内存c
// tcache (Thread Cache) — glibc 2.26 引入,极大提升多线程性能
typedef struct tcache_entry {
struct tcache_entry *next; // 单链表
struct tcache_perthread_struct *key; // double-free 检测
} tcache_entry;
typedef struct tcache_perthread_struct {
uint16_t counts[TCACHE_MAX_BINS]; // 每个 bin 的当前数量
tcache_entry *entries[TCACHE_MAX_BINS]; // 每个 bin 的链表头
} tcache_perthread_struct;
// 分配: 从 entries[idx] 取第一个 → counts[idx]--
// 释放: 插入 entries[idx] 头部 → counts[idx]++ (不超过上限,默认 7)
// 无锁!因为 tcache 是线程私有的5.4 jemalloc — Facebook 的工业级分配器
起源:Jason Evans 为 FreeBSD 开发(2005),后由 Facebook 大规模改进。用于 Redis、Rust、Firefox、MariaDB。
核心设计理念:将"一个全局堆"打散为独立 arena,arena 内再分层,每层有独立锁,极大减少争用。
三级架构:
┌─────────────────────────┐
Thread 1 ───────→│ tcache │ ← 线程私有,无锁
│ (thread-specific cache) │
└───────────┬─────────────┘
│ tcache 空了 → 从 arena 补充
┌───────────▼─────────────┐
│ arena │ ← arena 级别锁
│ ┌─────┐ ┌─────┐ │
│ │ bin │ │ bin │ ... │ ← 按 size class 分桶
│ │ slab│ │ slab│ │
│ └─────┘ └─────┘ │
│ extent 管理 │
└───────────┬─────────────┘
│ arena 空了 → 从 OS 申请
┌───────────▼─────────────┐
│ OS (mmap / sbrk) │
└─────────────────────────┘Size Class(大小类)与 Bin/Slab:
jemalloc 不按字节精确分配,而是将请求向上舍入到最近的 size class:
小对象 (≤ 14336B, ≈14KB):
size class: 8, 16, 32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256 ...
每个 size class 对应一个 bin,bin 管理多个 slab
大对象 (>14KB 且 ≤ chunk 大小):
按 page 对齐分配,由 extent 直接管理
巨对象 (>chunk 大小):
独立 mmap,有自己的 extent为什么这样设计:对齐后的 size class 让同一 bin 内的所有对象大小相同,free 后的槽位可直接复用,无需切割/合并。内存碎片可控(最多浪费 (next_size - size) 字节)。
c
// jemalloc: tcache + arena 两级架构
// 分配快速路径(绝大多数情况):
// malloc(64) → tcache 64B bin 有可用对象? yes → 弹出,返回(无锁!)
// no → arena->bin[64B_idx] 分配一批填充 tcache → 返回
// 释放快速路径:
// free(p, 64) → tcache 64B bin 没满? yes → 推入 tcache(无锁!)
// no → 刷回 arena->bin → 可能触发 decay(归还 OS)Decay(脏页回收) — jemalloc 的独特优势:
arena 释放的内存不是立即归还 OS,而是保留一段时间:
dirty_decay_ms = 10000 (10秒) — 脏页保留
muzzy_decay_ms = 10000 (10秒) — 中间态保留
好处: 短期内再分配直接复用,避免 mmap/munmap 系统调用开销
坏处: RSS (常驻内存) 偏高,看起来像"内存泄漏"
诊断: malloc_stats_print() 区分 allocated vs active vs mapped vs retainedjemalloc 为什么适合 Redis:
- Redis 大量使用不同大小的对象(SDS 字符串、dict、ziplist、quicklist),jemalloc 的 size class 设计对此友好
- Redis fork 子进程做 RDB/AOF rewrite 时,jemalloc 的 extent 管理减少了 COW 复制的内存量
active_defrag功能可以手动整理内存碎片
5.5 tcmalloc — Google 的极致快速路径
起源:Google 为多线程 C++ 服务开发(2005),目标是让 malloc 在常见情况下只需几次 CPU 指令。
三级缓存架构:
┌──────────────────────────────────────────────────────────────┐
│ Thread Cache (每线程独立, 无锁) │
│ ┌─────┬─────┬─────┬─────┬─────────────────────────┐ │
│ │ 8B │ 16B │ 32B │ ... │ 自由链表 (单链表) │ │
│ └─────┴─────┴─────┴─────┴─────────────────────────┘ │
│ 小对象分配: 几乎全是 thread cache 命中 → 几次指令即返回 │
└──────────────────────────┬───────────────────────────────────┘
│ thread cache 空/满时
┌──────────────────────────▼───────────────────────────────────┐
│ Central FreeList (全局, 有锁) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 每个 size class 一个 central freelist │ │
│ │ 负责: 在 thread cache 和 page heap 之间搬运 span │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│ 需要更多内存时
┌──────────────────────────▼───────────────────────────────────┐
│ Page Heap (全局, 有锁) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 管理 1~255 个 page 的 span,用 radix tree 索引 │ │
│ │ 大对象 (>256KB) 直接从 page heap 分配 │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│
┌──────▼──────┐
│ OS (mmap) │
└─────────────┘快速路径设计精髓:
c
// tcmalloc 的 malloc 快速路径(伪代码,<10 条 CPU 指令)
void* malloc(size_t size) {
if (size > kMaxSize) return allocate_large(size); // >256KB → page heap
size_t cl = SizeClass(size); // 查表: size → size class index
ThreadCache* tc = GetThreadCache();
// 核心: 从线程本地的自由链表取一个对象
void* obj = tc->list_[cl].next;
if (__builtin_expect(obj != NULL, 1)) { // 大概率命中
tc->list_[cl].next = *(void**)obj; // 链表头指针后移
return obj;
}
// 未命中: 从 CentralFreeList 批量获取
return FetchFromCentralCache(cl);
}tcmalloc 的 Size Class 策略:
小对象 (<256KB):
对齐粒度细,减少内部碎片
例如: 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176 ...
中对象 (256KB ~ 1MB):
按 page 大小对齐 (4KB 倍数)
大对象 (>1MB):
直接 mmaptcmalloc 的独特优势:
- 快速路径极致:常见分配只需读 TLS + 链表脱链,无需分支预测、无需锁
- 内存采样 profiler:
HEAPPROFILE=/tmp/profile ./program即可生成堆分析报告,Google 内部大规模使用 - GWP-ASan:抽样分配额外保护页,概率性检测 use-after-free / buffer overflow(生产可用!)
5.6 mimalloc — Microsoft 的极致性能挑战者
起源:Daan Leijsen(微软研究院,2019),为 Koka 语言运行时开发,后开源。目标是成为最快的通用分配器。
核心创新 — Free List Sharding(空闲链表分片):
传统分配器的一个 bin 只有一个空闲链表,即使是线程本地的 tcache,同一线程不同分配点的请求也会串行。mimalloc 将同一个 size class 的空闲链表分片为多个独立子链表。
传统 (jemalloc/tcmalloc tcache):
thread cache 每个 size 只有一个自由链表
→ 同一线程的不同代码路径都在操作同一个链表头
mimalloc (free list sharding):
每个 mimalloc page (64KB) 有自己的自由链表
→ 不同 page 操作完全独立,无竞争
→ 同一线程内不同分配点天然隔离mimalloc 的 Page 结构(核心数据结构):
┌─────────────────────────── mi_page ───────────────────────────┐
│ block_size: 本页管理的对象大小 (如 64B) │
│ free: 空闲链表头 (指向第一个空闲 block) │
│ local_free: 线程本地释放的 block 链表 (延迟合并) │
│ used: 已分配的 block 数 │
│ capacity: 本页总 block 数 │
├───────────────────────────────────────────────────────────────┤
│ [block][block][block][block]...[block][block][block][block] │
│ ↑ 64KB page, 全部 block 大小相同 │
└───────────────────────────────────────────────────────────────┘分配与释放流程:
malloc(64):
1. 查线程本地 page 缓存,找到 size=64B 的 page
2. page->free 非空? → 取出一个 block,page->used++
→ 返回 (通常只需这几步,无锁!)
3. page->free 为空? → 分配新 page (64KB),切分为 64B blocks
→ 挂入 page 队列,取一个返回
free(p):
1. 由指针 p 反查它所属的 page (利用地址对齐: p & ~(64KB-1))
2. 将 block 加入 page->local_free 链表
3. local_free 积累一定数量后 → 批量合并到 page->free
(减少 page 级别的争用)mimalloc 的独特优势:
| 特性 | 说明 |
|---|---|
| Free list sharding | 每个 page 独立链表,彻底消除同线程内的分配串行 |
| 极简快速路径 | 常见 malloc ~10 条指令,free ~5 条指令 |
| Secure mode | MI_SECURE=1 强制随机化、编码指针、检测 double-free(适合安全敏感场景) |
| Eager page reset | 尽快归还 OS,RSS 友好(相比 jemalloc 的 decay 模式) |
| 与 C++ 标准库集成 | Windows 上已作为 Universal CRT 的后端 |
5.7 四大分配器对比总览
| 维度 | ptmalloc2 (glibc) | jemalloc | tcmalloc | mimalloc |
|---|---|---|---|---|
| 设计目标 | 通用、广泛兼容 | 低碎片、多线程 | 极致快路径 | 全能最优 |
| 核心结构 | arena+bins+tcache | tcache+arena+extent | thread cache+central+page | page 分片 |
| 每线程缓存 | tcache (2.26+) | tcache | thread cache | page local_free |
| arena/堆数量 | ≤ 8×CPU | 可配置 (默认 4×CPU) | 1 个 page heap | 1 个 page heap |
| 内部碎片 | 中等 | 低 (精细 size class) | 低 | 低 |
| 外部碎片 | 较高 (合并延迟) | 低 (extent 管理) | 中等 | 低 (page 回收) |
| 归还 OS | 慢 (trim 被动) | 可控 (decay) | 主动 | 积极 (eager) |
| 多线程性能 | 一般 | 优秀 | 优秀 | 极优 |
| 内存开销 | 低 | 中 (统计+元数据) | 中 | 中 |
| debug/profile | mtrace | jeprof | gperftools | 内置统计 |
| 典型用户 | 所有 Linux 程序 | Redis, Rust, Firefox | Chrome, gperftools用户 | Koka, 微软内部 |
5.8 实战:选择与切换分配器
bash
# 运行时切换 — 无需重新编译
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./my_server
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 ./my_server
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmimalloc.so.2 ./my_server
# 编译时链接
gcc -o my_server my_server.c -ljemalloc
gcc -o my_server my_server.c -ltcmalloc
gcc -o my_server my_server.c -lmimalloc
# jemalloc 运行时调优
export MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"
# background_thread → 后台线程异步清理,避免 free 卡顿
# dirty_decay_ms → 脏页 5 秒后归还(默认 10s)
# 查看 jemalloc 内存统计
export MALLOC_CONF="stats_print:true"
# 程序退出时自动打印完整统计c
// mallopt 调优 glibc malloc
#include <malloc.h>
mallopt(M_MMAP_MAX, 0); // 禁止 mmap(强迫用 sbrk,减少系统调用)
mallopt(M_MMAP_THRESHOLD, 128*1024); // 超过 128KB 用 mmap
mallopt(M_TRIM_THRESHOLD, -1); // 禁止自动 trim(性能优先)
mallopt(M_ARENA_MAX, 2); // 限制 arena 数,减少内存碎片
// 长期运行服务定期归还内存
malloc_trim(0); // 建议每 10 分钟调用一次5.9 malloc vs calloc vs realloc vs new vs mmap
| 函数 | 初始化 | 来源 | 何时使用 |
|---|---|---|---|
malloc(n) | 不初始化(随机值) | C | 通用分配 |
calloc(n, s) | 清零(还有溢出检测) | C | 需要零初始化,数组 |
realloc(p, n) | 保留原数据 | C | 调整大小 |
new T | 调用构造函数 | C++ | C++ 对象 |
mmap(...) | 零页 | POSIX | 大块(>128KB)、对齐、文件映射 |
new的底层最终调用malloc。malloc失败返回 NULL,new失败抛std::bad_alloc。
5.10 内存碎片与优化
| 类型 | 描述 |
|---|---|
| 内部碎片 | 分配的空间大于请求的空间(对齐、chunk头) |
| 外部碎片 | 空闲空间被分割成小块,无法满足大请求 |
对象池:频繁分配/释放同类对象时,对象池比 malloc 快 10-100×(无锁、无系统调用、无碎片)。
c
// 对象池示例
#define POOL_SIZE 1024
Connection pool[POOL_SIZE];
int free_list[POOL_SIZE], free_top = POOL_SIZE;
Connection *conn_alloc() {
if (free_top == 0) return NULL;
return &pool[free_list[--free_top]]; // O(1)
}
void conn_free(Connection *c) {
free_list[free_top++] = c - pool; // O(1)
}c
// 诊断内存使用(glibc)
#include <malloc.h>
struct mallinfo2 info = mallinfo2();
printf("arena: %zu, 空闲(fordblks): %zu, 映射块(hblkhd): %zu\n",
info.arena, info.fordblks, info.hblkhd);
// arena = 已从 OS 申请的总内存(含已分配+空闲+元数据)
// fordblks = arena 内空闲块总和
// hblkhd = 大块 mmap 直接分配的总量
// RSS 估算 ≈ arena + hblkhd - fordblks (近似,实际更复杂)调优参数参见 5.8 实战:选择与切换分配器。
6. 数据段(.data / .bss / .rodata)
6.1 .data 段
存储已初始化的全局变量和静态变量。值存储在可执行文件中。
c
int global = 42; // 在 .data 中
static int count = 0; // 在 .data 中 (显式初始化为0)6.2 .bss 段
存储未初始化的全局变量和静态变量。不占可执行文件空间,加载时由 OS 零填充。
c
int global_uninit; // 在 .bss 中
static int buf[1024]; // 在 .bss 中C 标准规定未初始化的静态/全局变量初始化为 0,恰好和 .bss 零填充行为一致。
6.3 .rodata 段
只读数据,包括字符串常量和常量数据。
c
const char *msg = "hello"; // "hello" 在 .rodata 中
const int values[] = {1,2,3}; // values 在 .rodata 中7. ELF 文件格式
7.1 ELF 类型
| 类型 | 扩展名 | 可执行? |
|---|---|---|
| 可重定位文件 | .o | 否 |
| 可执行文件 | 无(或 .out) | 是 |
| 共享对象 | .so | 否(但可加载) |
| 核心转储 | core | 否 |
7.2 ELF 文件结构
┌──────────────────┐
│ ELF Header │ ← 魔数、类型、入口点、段表/节表的偏移
├──────────────────┤
│ Program Headers │ ← 告诉加载器如何映射段到内存
│ (段表) │ LOAD段→ 加载到虚拟地址空间
├──────────────────┤
│ .text │
│ .rodata │
│ .data │
│ ... │
├──────────────────┤
│ Section Headers │ ← 告诉链接器各节的详细信息
│ (节表) │
└──────────────────┘7.3 查看 ELF 的工具
bash
readelf -h a.out # ELF 头部
readelf -l a.out # 段表 (Program Headers)
readelf -S a.out # 节表 (Section Headers)
readelf -s a.out # 符号表
objdump -d a.out # 反汇编
size a.out # 各段大小
nm a.out # 符号列表8. 实战:内存布局分析
8.1 查看进程内存布局
bash
cat /proc/self/maps
# 输出示例:
# 00400000-00401000 r-xp ... /bin/cat ← .text
# 00601000-00602000 rw-p ... /bin/cat ← .data
# 01234000-01255000 rw-p ... [heap] ← 堆
# 7f... ... libc.so ← 共享库
# 7ffd12345000-7ffd12366000 rw-p [stack] ← 栈8.2 验证各段所在位置
c
#include <stdio.h>
#include <stdlib.h>
int data_var = 42; // .data
int bss_var; // .bss
const int rodata_var = 99; // .rodata
int main() {
int stack_var = 1; // 栈
int *heap_var = malloc(100); // 堆
static int static_var = 0; // .bss (显式初始化为0也在.bss)
printf(".text (函数): %p\n", main);
printf(".data (data_var): %p\n", &data_var);
printf(".bss (bss_var): %p\n", &bss_var);
printf(".bss (static): %p\n", &static_var);
printf(".rodata(rodata_var):%p\n", &rodata_var);
printf("Stack (局部): %p\n", &stack_var);
printf("Heap (malloc): %p\n", heap_var);
free(heap_var);
return 0;
}8.3 内存泄漏检测
bash
# Address Sanitizer (编译时,推荐)
gcc -fsanitize=address -g program.c -o program && ./program
# Valgrind (无需重新编译)
valgrind --leak-check=full --show-leak-kinds=all ./program
# jemalloc profiling
export MALLOC_CONF="prof:true,prof_prefix:jeprof.out"
LD_PRELOAD=/usr/lib/libjemalloc.so ./program
jeprof --show_bytes --pdf ./program jeprof.out.*.heap > prof.pdf8.5 Go 内存分配器与 tcmalloc 的对应关系
Go 的内存分配器直接借鉴了 tcmalloc 的设计,但针对 GC 和 goroutine 做了大量定制。理解两者的对应关系,能帮助你从"通用分配器"视角理解 Go runtime 的内存行为。
架构对应表
| tcmalloc 概念 | Go runtime 对应 | 差异 |
|---|---|---|
| Thread Cache | mcache(每个 P 一个) | tcmalloc 是每线程,Go 是每 P |
| Central FreeList | mcentral(每个 size class 一个) | 功能相同,都是中间层 |
| Page Heap | mheap(全局唯一) | Go 的 mheap 还管理 span 元数据 |
| Span | mspan | Go 的 span 额外存 GC 标记位 |
| Size Class | 67 个 size class | tcmalloc ~88 个,Go 67 个 |
| Page (4KB) | 8KB page | Go 用 8KB 页,不是 4KB |
三级架构对比图
┌─────────────────────────────────────────────────────────────────┐
│ tcmalloc │
│ │
│ Thread 1 Thread 2 Thread N │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Thread │ │Thread │ │Thread │ ← 每线程,无锁 │
│ │Cache │ │Cache │ │Cache │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Central FreeList │ ← 全局,有锁 │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Page Heap │ ← 全局,有锁 │
│ └────────┬────────┘ │
│ ▼ │
│ OS (mmap) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Go runtime │
│ │
│ P0 P1 P(GOMAXPROCS-1) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ mcache │ │ mcache │ │ mcache │ ← 每 P,无锁 │
│ │(alloc[] │ │(alloc[] │ │(alloc[] │ │
│ │ tiny/...)│ │ tiny/...)│ │ tiny/...)│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ mcentral[0], mcentral[1], ... [66] │ ← 每 size class │
│ │ (有锁,但按 size class 分散) │ 一个 mcentral │
│ └────────────────┬─────────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ mheap │ ← 全局,有锁 │
│ │ (page allocator │ │
│ │ + span 管理) │ │
│ └────────┬────────┘ │
│ ▼ │
│ OS (mmap/sysAlloc) │
└─────────────────────────────────────────────────────────────────┘关键差异详解
1. 为什么 Go 用"每 P"而不是"每线程"?
text
tcmalloc: 每个 OS 线程有自己的 thread cache
→ 线程数 = cache 数
→ 线程多时内存碎片增加
Go: 每个 P 有自己的 mcache
→ P 数 = GOMAXPROCS(通常 = CPU 核数)
→ 无论有多少 goroutine,mcache 数量固定
→ 内存碎片可控这是因为 Go 的 M:N 调度模型——成千上万的 goroutine 复用少量 P,所以 cache 绑定在 P 上比绑定在线程上更合理。
2. Go 的 size class 设计
go
// runtime/sizeclasses.go (Go 1.21)
// 67 个 size class,从 8B 到 32KB
//
// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 24 8192 341 8 29.24%
// 4 32 8192 256 0 21.88%
// 5 48 8192 170 32 31.52%
// ...
// 66 28672 57344 2 0 4.91%
// 67 32768 32768 1 0 12.50%与 jemalloc 的对比:
| 维度 | Go | jemalloc | tcmalloc |
|---|---|---|---|
| size class 数量 | 67 | ~200+ | ~88 |
| 最小对齐 | 8B | 8B | 8B |
| 页大小 | 8KB | 4KB | 4KB (span 可多页) |
| 大对象阈值 | 32KB | 14KB | 256KB |
| 大对象处理 | 直接从 mheap 分配 span | extent 管理 | page heap |
3. Go 的 tiny allocator — tcmalloc 没有的优化
Go 对 < 16B 且不含指针的小对象有特殊优化:
go
// runtime/malloc.go
// tiny allocator: 将多个小对象打包到同一个 16B 的 slot 中
//
// 例如: 分配 3 个 bool (各 1B)
// tcmalloc: 3 次分配,每次至少 8B → 24B
// Go tiny: 打包到同一个 16B slot → 16B (节省 33%)这对 Go 程序特别有效,因为 Go 有大量小的值类型(bool、byte、小 int)。
4. Go 为什么不直接用 jemalloc/tcmalloc?
| 原因 | 说明 |
|---|---|
| GC 集成 | Go 的 GC 需要知道每个对象的类型信息(gcdata),通用分配器不提供 |
| 指针位图 | 每个 span 需要维护 bitmap 标记哪些字节是指针,用于 GC 扫描 |
| 写屏障 | 分配时可能需要触发写屏障,通用分配器无法配合 |
| 栈管理 | goroutine 栈的分配/增长/收缩需要和分配器深度集成 |
| 逃逸分析 | 编译器决定对象在栈还是堆,需要分配器配合 |
| scavenger | Go 有自己的内存归还策略(madvise),需要精确控制 |
简单说:Go 的分配器不只是"分配内存",它还是 GC 的基础设施。
5. 性能对比
| 操作 | Go mcache (无锁路径) | tcmalloc thread cache | jemalloc tcache |
|---|---|---|---|
| 小对象分配 | ~25ns | ~15ns | ~20ns |
| 小对象释放 | ~15ns | ~10ns | ~15ns |
| 大对象分配 | ~200ns | ~100ns | ~150ns |
Go 的分配器比 tcmalloc 稍慢,主要因为:
- 需要额外维护 GC 元数据
- 需要检查是否需要 GC assist
- 写屏障开销
但这个差距在实际应用中通常不是瓶颈——Go 通过逃逸分析把大量对象放在栈上,根本不走堆分配。
9. 从内存布局到代码性能:写程序时真正受什么影响
9.1 堆和栈不只是“两个区域”,而是两种完全不同的成本模型
| 维度 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 调整栈指针 | allocator 参与 |
| 生命周期 | 作用域天然决定 | 需要 GC 或手动释放 |
| 局部性 | 通常很好 | 取决于分配器和碎片 |
| 常见问题 | 栈过深、栈溢出 | GC、碎片、锁争用、RSS 高 |
这就是为什么“能不逃逸就不逃逸”往往很值钱:
- 少一次堆分配
- 少一份 GC 扫描成本
- 更好的 cache / TLB 局部性
- 更稳定的尾延迟
9.2 编译器为什么会改变你的内存布局结果
程序员写的是变量,编译器决定的是:
- 放寄存器
- 放栈
- 还是逃逸到堆
例如 Go 里的逃逸分析、C/C++ 编译器里的寄存器分配和栈上对象布局,都直接影响:
- 分配次数
- 调用开销
- cache 命中率
- GC/allocator 压力
所以内存布局并不是“程序加载后 OS 决定完就没事了”,而是编译器 + 运行时 + 分配器 + 内核共同决定的结果。
9.3 对齐与 padding 为什么和性能直接相关
结构体字段顺序不只是影响 sizeof,还影响:
- 一个对象占多少 cache line
- 热字段是否挤在一起
- 是否导致 false sharing
- 批量遍历时的有效载荷比例
例如:
c
struct Bad {
char a;
long b;
char c;
};逻辑上只有 1 + 8 + 1 字节,但由于对齐和 padding,实际大小可能远大于 10 字节。
工程上的启发是:
- 热字段尽量聚合
- 冷字段尽量分离
- 高频跨线程写的字段要避免共享 cache line
- 大量对象场景下,padding 的浪费会被放大很多倍
9.4 为什么对象布局会影响 GC 和 allocator
对象一旦变大,或包含大量指针,会连锁影响:
text
对象更大 / 指针更多
→ 分配更重
→ GC 扫描更贵
→ cache line 利用率下降
→ TLB / cache miss 上升
→ 吞吐下降,P99 变差所以性能调优时,很多优化其实不是“把算法换掉”,而是:
- 减少对象数量
- 减少指针层级
- 改善字段布局
- 降低逃逸
9.5 一个常见误区:RSS 高不一定等于内存泄漏
很多服务里会看到:
- 堆对象没明显增长
- 但 RSS 还是很高
这时不能只盯着“代码是否泄漏”,还要想到:
- allocator 保留了 arena/page
- page cache 占用较大
- mmap 区域没有及时回收
- 短期峰值后碎片还在
- goroutine / thread stack 还保活着对象
也就是说,进程内存高 = 应用对象 + 分配器缓存 + 映射区域 + 栈 + 页缓存视角共同叠加。
9.6 一个从代码到系统的完整链路
mermaid
flowchart LR
A["代码抽象和对象设计"] --> B["编译器布局/逃逸决定"]
B --> C["堆/栈/寄存器分布"]
C --> D["allocator / GC / cache / TLB 行为"]
D --> E["吞吐、RT、RSS、P99"]这就是为什么内存布局知识必须和:
- 编译器
- 分配器
- CPU cache/TLB
- GC 或手动内存管理
一起看,单独背地址空间图帮助其实有限。
9.7 线上排障怎么落地
| 现象 | 优先怀疑 | 常用方法 |
|---|---|---|
| allocs 很高 | 逃逸、对象过多 | 编译器分析、alloc profile |
| GC 压力大 | 大量堆对象、指针多 | heap/profile |
| RSS 高但 heap 不高 | allocator 保留、mmap、栈 | /proc/<pid>/smaps, pmap -x |
| 多线程扩展差 | allocator 锁竞争、false sharing | perf、malloc stats |
| 尾延迟抖动 | 堆分配、major fault、回收 | trace、perf、vmstat |
9.8 一个实战原则
text
先减少不必要的对象和指针,
再优化布局和对齐,
最后再考虑替换分配器或调内核参数。大多数线上问题,根因不在“换个更快的 malloc 就好了”,而在于上层对象模型和访问模式本身就不友好。
参考
- CSAPP 第7章:链接
- CSAPP 第9章:虚拟内存
- glibc malloc source
- jemalloc
- tcmalloc
- ELF Specification
登录后即可发表评论 👇