内存分区

ZaynPei Lv6

程序的内存布局 (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
2
3
#include <new> // 包含 placement new 的头文件
void* raw = operator new(sizeof(MyClass)); // 分配内存
MyClass* p = new(raw) MyClass(123); // 构造对象
这第二行就是 placement new。它的语法形式为: new (地址) 类型(构造参数...);, 这里的“地址”是一个指向预先分配好的内存区域的指针。placement new 不会分配内存,它只是调用构造函数指定的内存位置上创建对象
1
2
char buffer[sizeof(int)];       // buffer是在栈上分配的一块内存, buffer本身是地址
int* p = new (buffer) int(42); // 在 buffer 这块内存上构造一个 int
同时, 因为你自己管理内存,所以也要自己调用析构函数: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
2
3
4
5
6
template <
typename Key,
typename T,
typename Compare = std::less<Key>,
typename Allocator = std::allocator<std::pair<const Key, T>> // <-- 就是它
> class map;

为了实现这个目标,我们需要自定义一个 Allocator,让它从 mmap 区域分配内存,而不是从堆上分配。然后将这个自定义的 Allocator 传递给 std::map 作为第二个模板参数

我们必须自己编写一个符合 C++ Allocator 规范的自定义配置器,让它的 allocate() 方法从我们 mmap 出来的共享内存区域中分配。

首先, 在进程启动时,先通过 mmap 申请一块足够大(例如 100MB)的共享内存区域。在这块内存的最开头,我们需要放置一个我们自己的“内存池/堆管理器”(例如一个简单的 free_list 管理器,或者一个 boost::interprocess::managed_shared_memory)。