Widget* p = new Widget(42); 看起来是一步,其实要过好几层才拿到能用的内存:标准库、glibc 的分配器、内核,最后还有 CPU 的缺页异常。每一层只在自己手里不够时才往下一层要,所以绝大多数 new 在用户态几十 ns 以内就返回了,偶尔一次要进内核;而真正的物理内存,要等你第一次往里写才分配。地址空间里堆、mmap 区各在哪,可以对照进程地图看。
1. new 表达式:先要内存,再构造对象
1.1 new 表达式(new-expression):编译器拆成两步
new 表达式要做两件事:拿一块够大的内存,在上面调构造函数。编译器把它展开成大致这样的代码(类自己重载了 operator new,就调类里的那个):
#include <new>
struct Widget { int v; explicit Widget(int v) : v(v) {} };
Widget* make() {
// Widget* p = new Widget(42); 大致等于:
void* mem = ::operator new(sizeof(Widget)); // 1. 拿内存,失败抛 std::bad_alloc
try {
return ::new (mem) Widget(42); // 2. 在这块内存上调构造函数
} catch (...) {
::operator delete(mem); // 构造抛了异常:内存还回去,再往外抛
throw;
}
}
void destroy(Widget* p) {
// delete p; 大致等于:
p->~Widget(); // 1. 析构
::operator delete(p); // 2. 还内存
}
要拆成两步,是因为构造函数可能抛异常。这时内存已经拿到了,对象却没建成,调用者手里连指针都没有,这块内存只能由编译器生成的这段代码还回去。delete p 反过来,先析构,再还内存。
你能替换的是第一步:operator new 只管要多少字节,不知道要建的是什么对象。 placement new 正好相反,跳过第一步,只做第二步(7.1 节)。
数组版多一个细节。new Widget[n] 遇到有非平凡析构函数的类型,会在数组前面多要 8 字节记下 n,delete[] 靠它知道要析构几个对象。所以 new[] 配 delete、new 配 delete[] 都是未定义行为:一个不知道前面有这 8 字节,一个会去读一个不存在的计数。
1.2 operator new:一层薄壳,调 malloc,拿不到就抛
libstdc++ 里默认的 operator new 很短,大致是:
void* operator new(std::size_t sz) {
if (sz == 0) sz = 1; // new 0 字节也要返回一个独一无二的地址
void* p;
while ((p = std::malloc(sz)) == nullptr) {
std::new_handler h = std::get_new_handler();
if (!h) throw std::bad_alloc(); // 没装处理函数:抛异常
h(); // 装了:给程序一次腾内存的机会,再试
}
return p;
}
malloc 返回空指针时,它先看程序有没有用 std::set_new_handler 装过处理函数。装了就调一下,让程序有机会释放一些缓存,然后再试;没装就抛 std::bad_alloc。new (std::nothrow) Widget 调的是另一个版本,失败时返回 nullptr,不抛异常。
所以 C++ 里 new 出来的内存最终都是向 malloc 要的,new 快不快,基本就是 malloc 快不快。 下面这张图是整条路,越往下越贵,也越少走到:
2. malloc:先在自己手里的空闲块里找
2.1 chunk(块):每块前面有 8 字节的头
glibc 的分配器(ptmalloc)把内存切成一个个 chunk。每个 chunk 前面有 8 字节记着它的大小和几个标志位,malloc 返回的指针指向这个头的后面。大小按 16 字节对齐,最小 32 字节:malloc(1) 实际占 32 字节,malloc(24) 也是 32 字节,malloc(25) 就变成 48 字节。
大小记在块头里,所以 free(p) 不用你告诉它这块多大:往前读 8 字节就知道。 反过来,写越界会把下一块的头写坏,等这块被 free 时 glibc 检查出大小对不上,直接 abort,报出 free(): invalid next size 这类错误。崩溃的地方是 free,写坏的地方却在更早。
2.2 找的顺序:tcache、空闲链表、top chunk
free 掉的块不还给内核,而是按大小挂在各种空闲链表(bin)上,等下一次 malloc 来取。malloc 从便宜的地方往贵的地方找。
第一站是 tcache(thread cache,glibc 2.26 起有)。它是每个线程自己的一组小链表,按 16 字节一档,覆盖到约 1 KiB 的块,每档最多存 7 块。因为只有本线程会碰它,取和放都不用加锁,几条指令就完成。一个循环里反复 new、delete 同样大小的对象,几乎每次都在这里命中。
tcache 里没有,才去 arena 的空闲链表里找。小块有 fastbin(不和相邻的空闲块合并,取放快)和 smallbin,大块在 largebin 里按大小排好,刚 free 的块先进 unsorted bin,等下一次 malloc 顺手分拣。这一层要拿 arena 的锁,可能还要把大块切小、把相邻的空闲块合并,比 tcache 慢一截。
空闲链表里都没有合适的,就从 top chunk 切。top chunk 是堆最顶上还没被切过的一整块,够大就从它头上切一块下来返回。
这三站都在用户态,不进内核:大多数 malloc 在这里就返回了。 只有 top chunk 也不够大,才要去找内核。
2.3 arena(分配区):线程多了不抢同一把锁
如果所有线程共用一套空闲链表,就得共用一把锁,线程一多就排队。glibc 的办法是开多个 arena:主线程用 main arena,别的线程碰上锁被占着,就新建或换到另一个 arena,之后一直用它。64 位上最多开到核数的 8 倍。每个 arena 有自己的锁和空闲链表,线程之间大多不再抢。
代价是内存更分散:线程 A 的 arena 里空着的块,线程 B 用不上。所以线程多的程序,RSS(实际占用的物理内存)常常比真正在用的多出不少,这时可以用 MALLOC_ARENA_MAX 环境变量限制 arena 的个数。tcache 管“不加锁”,arena 管“锁不扎堆”,两层叠起来,多线程下的小块分配才便宜。
3. 向内核要:brk 和 mmap
3.1 brk:小块不够时推高堆顶
main arena 的堆紧挨在 .bss 上面,往高地址长(见进程地图的各段)。堆顶的位置叫 program break,brk 系统调用就是把它往上推。top chunk 不够时,malloc 调一次 brk,推高的量是这次要的大小再加 128 KiB 余量(M_TOP_PAD),按页取整。
多推 128 KiB,是因为一次系统调用要几百 ns 到 1 µs,比在用户态切一块贵得多。多要一点,后面几十上百次小分配都从这块余量里切,不用再进内核。用 strace 看一个程序,开头常能看到这样一对调用:
brk(NULL) = 0x55d0c2a3e000 // 问当前堆顶在哪
brk(0x55d0c2a5f000) = 0x55d0c2a5f000 // 往上推 132 KiB
其他 arena 不能用 brk,因为一个进程只有一个堆顶。它们先用 mmap 占一段 64 MiB 的地址当自己的 heap,要用多少再改成可读写多少。
3.2 mmap:大块单独映射
请求不小于 M_MMAP_THRESHOLD(默认 128 KiB)时,malloc 不碰堆,直接 mmap 一段匿名内存,大小按页取整;free 时 munmap,立刻还给内核。
大块单独映射,是因为放在堆里不好还。堆只能从顶上用 brk 往回缩,大块在堆中间被 free,留下的洞还不回去。单独映射的块用完整段还掉,不留洞。但每次 mmap、munmap 都是系统调用,还回去的页下次再要,又得重新缺页、清零。所以这个阈值是动态的:free 一个 mmap 来的块时,glibc 把阈值提高到这块的大小(64 位上最高 32 MiB),以后同样大小的请求改从堆里分,反复分配、释放同样大小的大块,就不用每次都进内核。用 mallopt(M_MMAP_THRESHOLD, n) 显式设过阈值,这个自动调整就关掉了。
阈值以上单独 mmap,free 就还给内核;阈值以下从堆里切,free 了留着自己用。
3.3 内核这时只登记了一段地址
不管 brk 还是 mmap,内核做的都只是改进程的内存区域表(VMA,vm_area_struct),记下“这一段地址合法、可读写、是匿名内存”。它没有分配物理页,也没有填页表。所以 malloc(1 << 30) 一下就返回了,RSS 一点没涨。
new 拿到的是一段地址,不是内存。 物理页要等下面这一步。
4. 第一次写:缺页才拿到物理页
4.1 缺页(page fault):CPU 查不到,就进内核补上
程序第一次往 p[0] 写,CPU 拿虚拟地址查页表(先查 TLB,见硬件地图),发现这一页没有映射,于是触发缺页异常,自动切进内核。内核按地址找到它所在的 VMA,确认是合法的匿名内存,就分配一个物理页、把 4 KiB 清零、填好页表项,再回到用户态,重新执行那条写指令。这一次查得到了,写成功。
清零这一步省不掉:这个物理页上一刻可能还属于别的进程,不清零就会把别人的数据交给你。也正因为内核保证新页全是 0,calloc 拿到的如果是刚 mmap 来的内存,就不用再 memset 一遍,比 malloc 加 memset 省一遍写。
同一页后面的 4095 个字节再写,页表里已经有了,不再缺页;写到下一页,又缺一次。缺页按页算,每页一次,每次约 0.5–2 µs,大部分花在进出内核和清零上。 地址如果不在任何 VMA 里(空指针、越界太远),走的是同一个入口,只是内核查不到 VMA,就给进程发 SIGSEGV。
还有个细节:匿名内存如果先读后写,第一次读时内核不分配新页,而是把一个全局共享的全零页只读地映射给你;等你真的写,再缺一次页,这时才复制出自己的一页。这和 fork 之后的写时复制是同一套机制(见进程、线程和 fork)。
4.2 第一次用为什么慢
把这些连起来,一块新 new 出来的大缓冲区,第一遍写比第二遍慢得多:第一遍每 4 KiB 都要进一次内核,1 GiB 就是 262144 次缺页,加起来零点几秒;第二遍页表都填好了,只剩普通的访存。
慢的不是 new,而是 new 之后第一次碰每一页。 这也解释了一个常见的现象:std::vector<char> v(n) 构造就很慢,因为它要把 n 个元素初始化成 0,构造时就把每页写了一遍;new char[n] 不初始化,所以构造很快,慢被推迟到第一次使用。
大页能把次数降下来。透明大页(THP)开着时,2 MiB 对齐的匿名区域可以一次缺页拿到一个 2 MiB 的页,缺页次数少 512 倍。每次要清零的内存也多了 512 倍,但进出内核的次数省下来了。
4.3 在自己机器上看
下面这个程序 new 256 MiB,在 new 之后、写满之后、delete 之后,分别打印 RSS 和到这时为止的次缺页(minor fault)次数(Linux):
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#include <sys/resource.h>
long rss_kib() { // /proc/self/status 里的 VmRSS,单位 KiB
std::ifstream f("/proc/self/status");
for (std::string line; std::getline(f, line);)
if (line.rfind("VmRSS:", 0) == 0) return std::atol(line.c_str() + 6);
return -1;
}
long minor_faults() { // 进程到现在为止的次缺页次数
rusage ru;
getrusage(RUSAGE_SELF, &ru);
return ru.ru_minflt;
}
int main(int argc, char**) {
const std::size_t n = 256u << 20; // 256 MiB,远超 128 KiB,malloc 走 mmap
std::printf("开始 RSS %7ld KiB 缺页 %ld\n", rss_kib(), minor_faults());
char* p = new char[n]; // 不初始化:只拿地址
std::printf("new 之后 RSS %7ld KiB 缺页 %ld\n", rss_kib(), minor_faults());
std::memset(p, argc, n); // 每页第一次写
std::printf("写满之后 RSS %7ld KiB 缺页 %ld (p[n-1] = %d)\n",
rss_kib(), minor_faults(), p[n - 1]);
delete[] p; // mmap 来的,free 时直接 munmap
std::printf("delete 后 RSS %7ld KiB 缺页 %ld\n", rss_kib(), minor_faults());
}
memset 写的值取 argc、最后再读一个字节打印出来,是为了不让编译器发现这块内存没人用、把整段分配优化掉。
预期看到:new 之后 RSS 和缺页次数几乎不变;写满之后 RSS 涨 262144 KiB 左右,缺页多出约 65536 次(256 MiB ÷ 4 KiB),THP 开着时会少得多;delete 之后 RSS 掉回去,因为 256 MiB 远超阈值,这块是单独 mmap 的,free 时直接 munmap 了。
用 strace 跑同一个程序,前面一串 mmap 是动态链接器在加载 .so,最后能看到这一对映射和解除映射。大小比 256 MiB 多出一页,是 chunk 头按页取整多出来的:
$ strace -e trace=mmap,munmap ./a.out
...
mmap(NULL, 268439552, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f…
munmap(0x7f…, 268439552) = 0
perf stat -e page-faults ./a.out 报出的总缺页数里,也会有这 65536 次左右。
5. delete:内存回到哪
5.1 小块回到空闲链表,不还给内核
delete 析构之后调 free。小块 free 时先放进本线程的 tcache,满了再挂回 arena 的空闲链表,和相邻的空闲块合并成大块。这些内存还在进程手里,页也还映射着,所以 RSS 不降。
只有两种情况会真的还给内核。一是堆顶那块空闲的超过 M_TRIM_THRESHOLD(默认 128 KiB,随 mmap 阈值动态变成它的 2 倍)时,free 用 brk 把堆顶往回缩。二是手动调 malloc_trim(0),glibc 还会对堆中间整页空着的地方调 madvise(MADV_DONTNEED),把物理页还掉、地址留着。brk 只能从顶上缩,堆顶附近夹着一个还在用的小块,它下面空出来的一大片就缩不回去,这就是碎片让 RSS 降不下来的原因。
RSS 不降不等于泄漏:分配器只是把空闲块留着下次用。 判断泄漏要看 RSS 是不是随着时间一直涨,或者用 ASan、Valgrind 这类工具查哪些块一直没被 free。
5.2 大块直接 munmap
mmap 来的大块 free 时直接 munmap,地址和物理页一起还给内核,RSS 立刻降。下次再要同样大小,就得重新 mmap、重新缺页。mmap 阈值会随着 free 往上调,就是为了让反复出现的大块改走堆,省掉这一来一回。
6. 内存不够时:bad_alloc 和 OOM killer
6.1 overcommit(超额分配):malloc 几乎不会失败
既然 brk、mmap 只登记地址,内核就可以答应比实际物理内存更多的请求,赌大部分不会真的被写满。Linux 默认 vm.overcommit_memory = 0,只拒绝明显不可能的单次请求(比如一次要的比内存加交换区还大),其他都答应。
所以在默认设置下,new 很少因为内存不够而抛 std::bad_alloc:它在 new 那一刻已经成功了,真正缺内存是后来某次缺页时,内核找不到物理页可给。这时没有异常可抛,因为缺页发生在一条普通的写指令上,程序里没有哪个调用可以返回失败。内核只能启动 OOM killer,按每个进程的 oom_score 挑一个杀掉(SIGKILL),被杀的也未必是你的进程。
默认设置下,内存不够的表现不是 new 抛异常,而是某个进程被 OOM killer 杀掉,dmesg 里留一行 Out of memory: Killed process ...。
6.2 bad_alloc 什么时候真的会抛
想让 new 在要不到时如实失败,就得让内核在登记地址时就记账。vm.overcommit_memory = 2 是严格模式:所有进程答应出去的总量不能超过交换区加上物理内存的一定比例(vm.overcommit_ratio,默认 50%),超了 brk、mmap 直接失败,malloc 返回空指针,new 抛 bad_alloc。
默认模式下也有几种情况会抛。进程用 ulimit -v(RLIMIT_AS)限制了地址空间大小,地址先用完了;或者要的大小本身离谱,比如长度算错成一个巨大的数。new T[n] 里 n 大到 n * sizeof(T) 溢出时,抛的是它的子类 std::bad_array_new_length。
7. 第一次用慢:把代价挪到前面
malloc 偶尔要进内核,每页第一次写要缺页,这两笔开销都只在“第一次”发生。程序如果需要“第一次用和以后一样快”,通用的办法都是把这些代价挪到启动时一次付清。
7.1 placement new:内存要一次,对象可以建很多次
new 表达式拆成拿内存和构造两步,placement new 只做第二步:在你给的内存上构造对象,不分配。
#include <cstdio>
#include <new>
struct Point {
double x, y;
Point(double x, double y) : x(x), y(y) { std::puts("构造"); }
~Point() { std::puts("析构"); }
};
int main() {
alignas(Point) unsigned char buf[sizeof(Point)]; // 一块够大、对齐的内存
Point* p = new (buf) Point(1, 2); // 只构造,不分配
std::printf("%.0f %.0f\n", p->x, p->y);
p->~Point(); // 只析构,不释放
p = new (buf) Point(3, 4); // 同一块内存再建一个
p->~Point();
}
placement new 建出来的对象不能 delete,因为那块内存不是 operator new 给的;要显式调析构函数,内存由你自己管。对象池就是这么做的:启动时要一大块内存,切成等大的槽;要用时从空闲槽里拿一个,placement new;不用了就析构,把槽挂回空闲链表。之后反复建、删对象,都不再碰 malloc。完整的对象池和 arena 实现见内存池与分配器。
7.2 预先缺页:启动时把每页写一遍
内存拿到了,物理页还没有。启动时把整块按页写一遍(每 4 KiB 写一个字节,或者整块 memset),缺页就在启动时发生完;也可以在 mmap 时加 MAP_POPULATE,让内核当场把页都分配好。再用 mlock 锁住,这些页就不会被换出到交换区,之后也不会因为换回来而再缺页。
预先缺页之后,这块内存的每一次访问都是普通的访存,和第二遍一样快。 代价是启动变慢,RSS 一开始就占满。
7.3 大页和换分配器
开透明大页,或者对这段内存 madvise(MADV_HUGEPAGE),缺页次数少 512 倍,TLB 也更少 miss:一个 TLB 项管 2 MiB,而不是 4 KiB。如果慢在多线程的 malloc 本身,可以换成 jemalloc、tcmalloc 这类线程缓存更大、跨线程释放处理得更好的分配器,通常只要链接时换一个库,或者用 LD_PRELOAD 预加载,不用改代码。