4. 内存性能指标

内存分配这事儿,说简单也简单,说复杂能写一本书。我做了这么多年性能优化,发现很多线上问题最后都追到了内存分配这一层。你想想看,一个程序跑着跑着突然OOM了,或者内存碎片高得吓人,十有八九是分配策略没选对。

这一章,咱们就把内存分配的底裤扒干净。从静态分配到动态分配,从malloc的皮囊到brk/sbrk的骨架,再到mmap这把大杀器。看完你就能明白,为什么有时候new一个对象会触发一次系统调用,为什么频繁分配小对象会让你的程序变慢。

4.1 静态分配 vs 动态分配:两种活法

先看个最直观的对比。静态分配,说白了就是编译器在编译时就帮你算好了要多少内存。全局变量、static变量、栈上的局部数组,都属于这一类。

// 静态分配:编译时就确定了
int global_array[1024];          // 全局区,程序启动就分配好
static int counter = 0;          // 静态区,生命周期贯穿整个进程
void func() {
    int local_buffer[256];       // 栈上分配,函数返回就释放
}

动态分配呢?运行时才决定。你写代码的时候根本不知道用户会传多大的数据,那就得靠malloc、new这些家伙在堆上给你划一块出来。

// 动态分配:运行时才确定大小
void process_data(int size) {
    int* buffer = (int*)malloc(size * sizeof(int));
    if (!buffer) {
        // 处理分配失败
        return;
    }
    // 用完了记得释放
    free(buffer);
}
特性 静态分配 动态分配
分配时机 编译/链接时 运行时
大小确定 编译期已知 运行时决定
生命周期 作用域/程序生命周期 手动控制(malloc/free)
分配位置 数据段/栈 堆
性能开销 几乎为零 有系统调用和元数据开销
碎片问题 无碎片 可能产生内部/外部碎片

我个人习惯是:能静态分配就静态分配。别小看这个选择,我在一个嵌入式项目里见过,有人把所有缓冲区都改成动态分配,结果系统响应时间从2ms飙到了50ms。为什么?每次malloc都要进内核,这开销你扛不住。

4.2 malloc/free 的底层实现:披着羊皮的狼

很多初学者以为malloc是系统调用。其实不是。malloc是C标准库提供的一个封装,它背后调用的才是真正的系统调用。不同的libc实现(glibc、jemalloc、tcmalloc)策略不同,但核心思路差不多。

glibc的malloc实现,我简单拆解一下:

  1. 小对象(< 64KB):从线程本地缓存(tcache)或bins里找空闲块。如果找不到,就调用sbrk/brk从堆顶扩展。
  2. 大对象(>= 64KB):直接调用mmap分配一块匿名内存映射。
  3. 释放时:小对象放回缓存,大对象通过munmap归还给系统。
// 伪代码:malloc的简化流程
void* malloc(size_t size) {
    // 1. 检查size合法性
    if (size == 0) return NULL;
    
    // 2. 小对象走堆分配
    if (size < MMAP_THRESHOLD) {  // 默认128KB
        // 尝试从tcache/bins获取
        void* ptr = tcache_get(size);
        if (ptr) return ptr;
        
        // 没有空闲块,扩展堆
        ptr = sbrk(align_up(size + metadata_size));
        return setup_metadata(ptr, size);
    }
    
    // 3. 大对象走mmap
    void* ptr = mmap(NULL, size + metadata_size, 
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    return setup_metadata(ptr, size);
}

关键点:每次malloc调用,库函数都会在分配的内存块前面加一段元数据(通常16-32字节),记录块大小、状态、前后块指针等信息。这就是为什么你malloc(1)实际占用的内存远大于1字节。

我曾经排查过一个内存泄漏问题,用valgrind一查,发现有个模块每次malloc(8)却实际消耗了48字节。为什么?因为glibc的最小分配单元是32字节,加上元数据16字节,总共48。这就是内部碎片——你申请8字节,系统给了你48字节,剩下的40字节全浪费了。

4.3 brk/sbrk 系统调用:堆的守门员

brk和sbrk是Linux提供的最原始的内存分配系统调用。它们干的事很简单:移动程序堆的边界(program break)。

  • brk(void* addr):直接把堆边界设置到addr位置。
  • sbrk(intptr_t increment):在当前位置上增加increment字节,返回旧边界地址。
#include <unistd.h>

void* allocate_on_heap(size_t size) {
    // 对齐到页边界
    size_t aligned = (size + 0xFFF) & ~0xFFF;
    
    // 移动堆顶
    void* old_break = sbrk(aligned);
    if (old_break == (void*)-1) {
        return NULL;  // 分配失败
    }
    
    return old_break;
}

这里有个坑:sbrk是线程不安全的。多线程环境下,两个线程同时调用sbrk,返回的地址可能重叠。所以glibc的malloc在调用sbrk之前会加锁。这也是为什么多线程频繁分配小对象时性能会急剧下降——锁竞争太激烈了。

避坑指南:我曾经在一个高并发服务里,把线程数从4个加到16个,结果吞吐量反而下降了30%。用perf一分析,发现大量CPU时间花在__lll_lock_wait_private上——就是malloc内部的锁竞争。后来换成jemalloc,问题解决。记住:glibc的malloc在多线程场景下不是最优解。

4.4 mmap 映射:大块内存的直通车

当你要分配的内存超过一定阈值(glibc默认128KB),malloc就不走sbrk了,而是直接调用mmap。为什么?因为大块内存用sbrk管理会导致堆空间碎片化严重,而且释放后很难归还给操作系统。

mmap分配的内存有几个特点:

  • 独立映射:每个mmap分配的内存块在虚拟地址空间中是独立的,不会和其他块相邻。
  • 可归还:调用munmap后,内存立即归还给操作系统。
  • 页对齐:分配的内存起始地址是页对齐的(4KB或2MB)。
  • 有开销:每次mmap/munmap都是一次完整的系统调用,比sbrk慢得多。
// 使用mmap分配大块内存
void* allocate_large(size_t size) {
    // 对齐到页大小
    size_t page_aligned = (size + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1);
    
    void* ptr = mmap(NULL, page_aligned,
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS,
                     -1, 0);
    
    if (ptr == MAP_FAILED) {
        return NULL;
    }
    
    return ptr;
}

// 释放
void deallocate_large(void* ptr, size_t size) {
    munmap(ptr, size);
}

实用技巧:如果你需要分配超大块内存(比如几百MB),直接用mmap比用malloc更可控。你可以指定MAP_HUGETLB来使用大页(2MB或1GB),减少TLB miss。我在一个数据库项目里用这个技巧,把查询延迟降低了15%。

4.5 一张图看懂内存分配全景

下面这张SVG图,把整个内存分配路径画清楚了。从用户代码到内核,每一步都标出来了。

内存分配全景流程图 用户代码 (malloc/free) 大小 < 阈值? 是 (小对象) tcache/bins 查找 sbrk/brk 扩展堆 否 (大对象) mmap 匿名映射 内核内存管理 (VMA/页表) 物理内存 / 交换分区 注意 小对象走堆 (sbrk) 大对象走映射 (mmap) glibc 默认阈值 MMAP_THRESHOLD = 128KB

4.6 实战:如何选择分配策略

说了这么多理论,来点实际的。你在写代码时该怎么选?

核心原则:

  • 小对象(< 64KB):用malloc,让glibc帮你管理堆。别自己搞sbrk,容易出问题。
  • 大对象(>= 64KB):考虑直接用mmap,特别是那些生命周期长、需要对齐的内存。
  • 多线程场景:别用glibc的malloc,换jemalloc或tcmalloc。我测过,16线程下jemalloc比glibc快3-5倍。
  • 实时系统:尽量在初始化阶段把内存全部分配好,运行时避免malloc/free。不确定性的延迟是实时系统的噩梦。
// 一个实际的选择示例
void* smart_alloc(size_t size) {
    // 小对象:用malloc
    if (size < 64 * 1024) {
        return malloc(size);
    }
    
    // 大对象:用mmap,指定大页
    size_t aligned = (size + HUGE_PAGE_SIZE - 1) & ~(HUGE_PAGE_SIZE - 1);
    void* ptr = mmap(NULL, aligned,
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                     -1, 0);
    
    if (ptr == MAP_FAILED) {
        // 大页分配失败,回退到普通mmap
        ptr = mmap(NULL, aligned,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS,
                   -1, 0);
    }
    
    return ptr;
}

避坑指南:我曾经在一个网络框架里,看到有人用mmap分配每个网络连接的缓冲区(每个64KB)。结果连接数一多(10万+),mmap的虚拟地址空间消耗巨大,而且每次分配都要清零(mmap保证返回零填充的内存),性能反而比malloc差。后来改成连接池预分配+复用,问题解决。记住:mmap不是银弹,它适合大块、低频、长生命周期的分配。