Skip to content

堆、栈、数据段与代码段 ​

#系统 · #内存 · #堆 · #栈 · #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机器指令RX有
.rodata字符串常量、跳转表R-有
.data已初始化全局/静态变量RW-有
.bss未初始化全局/静态变量RW-无(零填充)
Heapmalloc 分配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 = 1

2.4 各架构页表方案对比 ​

架构页表层级虚拟地址位页大小大页选项
x86-64 (4级)4484KB2MB, 1GB
x86-64 (5级)5574KB2MB, 1GB
ARMv8 (4KB)4484KB2MB, 1GB
ARMv8 (64KB)34864KB512MB
RISC-V Sv393394KB2MB, 1GB
RISC-V Sv484484KB2MB, 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_adj

4. 栈(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~1016~80B单链表,LIFO
Small bins6216~1008B双链表,FIFO
Large bins63>1008B双链表,按大小排序
Unsorted bin1任意双链表(回收时暂存)

分配流程:

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 retained

jemalloc 为什么适合 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):
  直接 mmap

tcmalloc 的独特优势:

  • 快速路径极致:常见分配只需读 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 modeMI_SECURE=1 强制随机化、编码指针、检测 double-free(适合安全敏感场景)
Eager page reset尽快归还 OS,RSS 友好(相比 jemalloc 的 decay 模式)
与 C++ 标准库集成Windows 上已作为 Universal CRT 的后端

5.7 四大分配器对比总览 ​

维度ptmalloc2 (glibc)jemalloctcmallocmimalloc
设计目标通用、广泛兼容低碎片、多线程极致快路径全能最优
核心结构arena+bins+tcachetcache+arena+extentthread cache+central+pagepage 分片
每线程缓存tcache (2.26+)tcachethread cachepage local_free
arena/堆数量≤ 8×CPU可配置 (默认 4×CPU)1 个 page heap1 个 page heap
内部碎片中等低 (精细 size class)低低
外部碎片较高 (合并延迟)低 (extent 管理)中等低 (page 回收)
归还 OS慢 (trim 被动)可控 (decay)主动积极 (eager)
多线程性能一般优秀优秀极优
内存开销低中 (统计+元数据)中中
debug/profilemtracejeprofgperftools内置统计
典型用户所有 Linux 程序Redis, Rust, FirefoxChrome, 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.pdf

8.5 Go 内存分配器与 tcmalloc 的对应关系 ​

Go 的内存分配器直接借鉴了 tcmalloc 的设计,但针对 GC 和 goroutine 做了大量定制。理解两者的对应关系,能帮助你从"通用分配器"视角理解 Go runtime 的内存行为。

架构对应表 ​

tcmalloc 概念Go runtime 对应差异
Thread Cachemcache(每个 P 一个)tcmalloc 是每线程,Go 是每 P
Central FreeListmcentral(每个 size class 一个)功能相同,都是中间层
Page Heapmheap(全局唯一)Go 的 mheap 还管理 span 元数据
SpanmspanGo 的 span 额外存 GC 标记位
Size Class67 个 size classtcmalloc ~88 个,Go 67 个
Page (4KB)8KB pageGo 用 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 的对比:

维度Gojemalloctcmalloc
size class 数量67~200+~88
最小对齐8B8B8B
页大小8KB4KB4KB (span 可多页)
大对象阈值32KB14KB256KB
大对象处理直接从 mheap 分配 spanextent 管理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 栈的分配/增长/收缩需要和分配器深度集成
逃逸分析编译器决定对象在栈还是堆,需要分配器配合
scavengerGo 有自己的内存归还策略(madvise),需要精确控制

简单说:Go 的分配器不只是"分配内存",它还是 GC 的基础设施。

5. 性能对比 ​

操作Go mcache (无锁路径)tcmalloc thread cachejemalloc 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 sharingperf、malloc stats
尾延迟抖动堆分配、major fault、回收trace、perf、vmstat

9.8 一个实战原则 ​

text
先减少不必要的对象和指针,
再优化布局和对齐,
最后再考虑替换分配器或调内核参数。

大多数线上问题,根因不在“换个更快的 malloc 就好了”,而在于上层对象模型和访问模式本身就不友好。

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记