内存分区
程序的内存布局 (Process Memory Layout)
一个程序运行起来后,操作系统会为其分配一块虚拟内存空间。这块空间在逻辑上通常分为以下几个部分:
栈 (Stack):
用途:用于存储函数的局部变量(也叫临时变量)、函数参数、返回地址等。
特点:由编译器自动分配和释放。内存区域通常较小,且向下增长(即从高地址向低地址扩展)。每个线程都有自己独立的栈。
堆 (Heap):
用途:用于程序运行时动态分配的内存,例如通过 new (C++) 或 malloc (C) 创建的对象。
特点:由程序员手动分配和释放(delete/free)。空间较大,向上增长。
静态/全局存储区 (Static/Global Storage Area):
这块区域用于存储全局变量和静态变量,生命周期与整个程序相同。它内部又细分为两个子区域:
.data 段 (Initialized Data Segment):存储已初始化且初始值不为0的全局变量和静态变量。
.bss 段 (Uninitialized Data Segment):存储未初始化或初始化为0的全局变量和静态变量。
这部分内存在程序加载到内存时就已经分配好了,并且在程序的整个生命周期内都不会改变位置。因此,无论你何时访问一个全局变量,它的内存地址都是同一个。
常量存储区 (Constant Storage Area / .rodata):
用途:存储字符串字面量和被 const 修饰的常量。
特点:只读(Read-Only)。
代码段 (Code Segment / .text):
用途:存储程序的可执行二进制指令。
特点:只读且可共享。
变量在内存中的分布
全局/静态变量 vs. 临时变量: 全局变量和静态变量的内存地址是固定的,但临时变量的内存地址,往往不是固定的。”
为什么全局/静态变量地址固定: 因为它们被存储在静态/全局存储区(.data 或 .bss 段)。这部分内存在程序加载到内存时就已经分配好了,并且在程序的整个生命周期内都不会改变位置。因此,无论你何时访问一个全局变量,它的内存地址都是同一个。
为什么临时变量(局部变量)地址不固定: 因为它们被存储在栈 (Stack)上。当一个函数被调用时,系统会在栈顶为这个函数创建一个“栈帧”(Stack Frame),用来存放它的局部变量。当函数执行完毕返回时,这个栈帧就会被销毁。
如果你在一个循环中多次调用同一个函数,那么每次调用时,该函数内的局部变量都会在一个新的栈帧中被创建,其内存地址也因此会不一样。如果函数发生递归调用,同样会创建多个栈帧,局部变量的地址也各不相同。
静态变量与全局变量的相似性和差异性: 静态变量,除了作用域跟全局变量有所差异外,其存储原则、生命周期跟全局变量类似。”
相似点:存储原则和生命周期
存储位置:它们都存储在静态/全局存储区(.data 或 .bss)。
生命周期:它们的生命周期都是整个程序的运行期间。从程序开始执行时被创建,到程序结束时才被销毁。即使是定义在函数内部的静态局部变量(static local variable),它也只会被初始化一次,并且在函数调用结束后其值会一直保留,不会被销毁。
差异点:作用域 (Scope)
全局变量 (Global Variable):作用域是整个程序,可以被多个源文件通过 extern 关键字访问(除非被 static 修饰成文件作用域)。
静态变量 (Static Variable):
静态全局变量(在函数外定义):作用域被限制在定义它的单个源文件内,其他文件无法访问。
静态局部变量(在函数内定义):作用域被限制在定义它的函数或代码块内,但其生命周期依然是整个程序。
未初始化/零初始化变量与 .bss 段: 无论是全局变量还是静态变量,如果它们没有被初始化,或者被初始化为 0,都会被安置在未初始化数据段(.bss),一定程度上可以节省二进制文件 a.out 的存储空间。
这是一个非常巧妙的编译器和加载器优化。对于 .data 段中的变量(例如 int global_var = 100;),值 100 必须被实际地保存在可执行文件(如 a.out)中,因为它是一个非零的特定值。这会占用文件的体积。
但对于 .bss 段,可执行文件只需要记录这个段的总大小,而不需要存储所有这些0。当操作系统加载程序时,它会读取 .bss 段的大小,然后在内存中分配相应大小的区域,并自动将其全部填充为零。
因此,一个拥有大量未初始化或零初始化全局/静态变量的程序,其可执行文件的大小可以显著减小,因为这些“零”并没有被实际存储在文件中,而是由加载器在运行时“创造”出来的。
placement new 运算符
在 C++ 中,placement new 是一种特殊形式的
new
运算符,它允许你在预先分配好的内存区域上构造对象,而不是让
new
自己去分配内存。这对于需要精细控制内存布局和性能优化的场景非常有用。
对于普通的 new
运算符,例如MyClass* p = new MyClass(123);,
编译器其实做了两步:分配内存(调用 operator
new,默认从堆上分配)和调用构造函数(在那块内存上构造对象)
也就是说, 这一行代码等价于:
1 |
|
new (地址) 类型(构造参数...);,
这里的“地址”是一个指向预先分配好的内存区域的指针。placement new
不会分配内存,它只是调用构造函数在指定的内存位置上创建对象。
1 | char buffer[sizeof(int)]; // buffer是在栈上分配的一块内存, buffer本身是地址 |
p->~int();,
通过指针来调用析构函数,手动销毁对象。 > 注意: 你不应该对 placement
new 分配的内存调用 delete,因为 delete
会尝试释放内存,而这块内存并不是通过 new 分配的,
如果后续这块内存被其他用途重用, 那么调用 delete 会导致未定义行为。
一般来说, placement new 主要用于以下场景: -
内存池 (Memory
Pool):在预先分配的一大块内存中,按需构造对象,避免频繁的堆分配。 -
自定义内存布局:在特定的内存区域(如共享内存、内存映射文件)上构造对象。
-
性能优化:减少内存分配和释放的开销,特别是在高频率创建和销毁对象的场景中。
内存池
内存池是一种自定义的内存管理策略。旨在解决malloc/new频繁调用带来的性能开销和内存碎片化问题。它通过预先分配一大块内存,然后在这块内存中按需分配和释放小块内存(完全在用户态,无需加锁或使用轻量锁),从而提高内存分配的效率。
默认的内存分配器(new/malloc)是通用的,但很慢。
- 系统调用开销: 它们需要频繁陷入内核态(通过 brk/sbrk 或 mmap)向操作系统申请内存。
- 锁竞争: 堆是全局共享的,malloc 必须加锁(Mutex)以保证线程安全,这在多线程高并发时会导致严重的锁竞争(Contention)。
- 内存碎片: 通用分配器容易产生(外部)内存碎片。
std::allocator
std::allocator 是 C++
标准库中用于内存分配和管理的一个模板类。它定义了一组接口,用于在容器(如
std::vector, std::list
等)中分配和释放内存。std::allocator
提供了一种抽象的方式,使得容器可以独立于具体的内存分配策略,
将‘数据结构’(如 std::vector)与‘内存管理’(如何获取内存)解耦开来。
数据结构(如 vector)关心的是:
- “我需要 N 个 T 类型的连续内存。”
- “我现在要在 p 地址上构造一个 T 对象。”
- “我不再需要 p 地址上的对象了,我要析构它。”
- “我不再需要这块内存了,我要释放它。”
Allocator 负责回答这些需求:
- allocate(N):返回一块能容纳 N 个 T 的原始内存。(不构造)
- deallocate(p, N):释放 p 指向的内存。(不析构)
- construct(p, args…):在 p 指向的内存上,调用placement new 来构造一个对象。
- destroy(p):在 p 指向的对象上,显式调用析构函数。
也就是说, std::vector 只负责“扩容”、“元素移动”等逻辑,它自己不调用 new。它把“分配内存”和“构造对象”的请求委托给 Allocator 去办。
它的函数接口有: - pointer allocate(size_type n):
分配一块能容纳 n 个 T 类型对象的原始内存,返回指向这块内存的指针。 -
void deallocate(pointer p, size_type n): 释放之前通过
allocate 分配的内存 p,n 是分配时的对象数量。 -
template <class U, class... Args> void construct(U* p, Args&&... args):
在 p 指向的内存上,使用传递的参数 args 构造一个 U 类型的对象(通常 U ==
T)。 - template <class U> void destroy(U* p):
显式调用 p 指向的对象的析构函数。
STL 默认的 std::allocator
std::allocator 是 STL 提供的默认配置器。它非常简单,是最薄的一层封装。它的行为就是“直接使用全局的 new 和 delete”。
- std::allocator
::allocate(n):其内部实现等价于调用 ::operator new(n * sizeof(T))。它直接从全局堆(Heap)上分配原始内存。 - std::allocator
::deallocate(p, n):其内部实现等价于调用 ::operator delete(p)。它直接将内存归还给全局堆。 - construct 和 destroy:它内部调用 placement new (p) T(args…) 和 p->~T()。
总结: std::vector<int> v; 的行为等价于
std::vector<int, std::allocator<int>> v;。 当你
v.push_back(10) 时,vector(如果需要扩容)会调用
std::allocator,后者会调用 operator
new(malloc),从进程私有的堆上获取内存。
如果要把 std::map 的内存指向 mmap 区域,应该怎么做?
默认的 std::map 会在堆(Heap)上分配内存(new/malloc),而堆是进程私有的。我们希望 std::map 把它的所有节点(Node)都分配到一块共享内存(mmap 区域)上,从而实现一个“跨进程共享”的 std::map。
std::map的模板定义如下:
1 | template < |
为了实现这个目标,我们需要自定义一个 Allocator,让它从 mmap 区域分配内存,而不是从堆上分配。然后将这个自定义的 Allocator 传递给 std::map 作为第二个模板参数。
我们必须自己编写一个符合 C++ Allocator 规范的自定义配置器,让它的 allocate() 方法从我们 mmap 出来的共享内存区域中分配。
首先, 在进程启动时,先通过 mmap 申请一块足够大(例如 100MB)的共享内存区域。在这块内存的最开头,我们需要放置一个我们自己的“内存池/堆管理器”(例如一个简单的 free_list 管理器,或者一个 boost::interprocess::managed_shared_memory)。