内存模型所要表达的内容主要是这么描述: 一个内存操作的效果,在其他线程中的可见性问题。

C++ 内存模型

C分为四个区:堆,栈,静态全局变量区,常量区

C++内存分为5个区域(堆栈全常代):

  1. 堆 heap : 由new分配的内存块,其释放编译器不去管,由我们程序自己控制(一个new对应一个delete)。如果程序员没有释放掉,在程序结束时OS会自动回收。涉及的问题:“缓冲区溢出”、“内存泄露”
  2. 栈 stack : 是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。 存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了。
  3. 全局/静态存储区 (.bss段和.data段) : 全局和静态变量被分配到同一块内存中。在C语言中,未初始化的放在.bss段中,初始化的放在.data段中;在C++里则不区分了。
  4. 常量存储区 (.rodata段) : 存放常量,不允许修改(通过非正当手段也可以修改)
  5. 代码区 (.text段) : 存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区)

根据c/c++对象生命周期不同,c/c++的内存模型有三种不同的内存区域,即

  1. 自由存储区,动态区、静态区。
  2. 自由存储区:局部非静态变量的存储区域,即平常所说的栈
  3. 动态区: 用operator new ,malloc分配的内存,即平常所说的堆
  4. 静态区:全局变量 静态变量 字符串常量存在位置

而代码虽然占内存,但不属于c/c++内存模型的一部分

各个段的关系

一个正在运行着的C编译程序占用的内存分为代码区、初始化数据区、未初始化数据区、堆区 和栈区5个部分。

(1)代码区(text segment)。代码区指令根据程序设计流程依次执行,对于顺序指令,则只会执行一次(每个进程),如果反复,则需要使用跳转指令,如果进行递归,则需 要借助栈来实现。

代码区的指令中包括操作码和要操作的对象(或对象地址引用)。如果是立即数(即具体的数值,如5),将直接包含在代码中;如果是局部数据,将在栈区 分配空间,然后引用该数据地址;如果是BSS区和数据区,在代码中同样将引用该数据地址。

(2)全局初始化数据区/静态数据区(Data Segment)。只初始化一次。

(3)未初始化数据区(BSS)。在运行时改变其值。

(4)栈区(stack)。由编译器自动分配释放,存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈。每当一个函数被调用,该函 数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。然后这个被调用的函数再为它的自动变量和临时变量在栈区上分配空间,这就是C实现 函数递归调用的方法。每执行一次递归函数调用,一个新的栈框架就会被使用,这样这个新实例栈里的变量就不会和该函数的另一个实例栈里面的变量混淆。

(5)堆区(heap)。用于动态内存分配。堆在内存中位于bss区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时有可能由OS 回收。

之所以分成这么多个区域,主要基于以下考虑:

一个进程在运行过程中,代码是根据流程依次执行的,只需要访问一次,当然跳转和递归有可能使代码执行多次,而数据一般都需要访问多次,因此单独开辟 空间以方便访问和节约空间。 临时数据及需要再次使用的代码在运行时放入栈区中,生命周期短。 全局数据和静态数据有可能在整个程序执行过程中都需要访问,因此单独存储管理。 堆区由用户自由分配,以便管理。

下面通过一段简单的代码来查看C程序执行时的内存分配情况。相关数据在运行时的位置如注释所述。

//main.cpp
int a = 0;        //a在全局已初始化数据区
char *p1;        //p1在BSS区(未初始化全局变量)
main()
{
int b;        //b在栈区
char s[] = "abc";  //s为数组变量,存储在栈区,
//"abc"为字符串常量,存储在已初始化数据区
char *p1*p2;    //p1、p2在栈区
char *p3 = "123456";  //123456\0在已初始化数据区,p3在栈区
static int c =0    //C为全局(静态)数据,存在于已初始化数据区
//另外,静态数据会自动初始化
p1 = (char *)malloc(10);//分配得来的10个字节的区域在堆区
p2 = (char *)malloc(20);//分配得来的20个字节的区域在堆区
free(p1);
free(p2);
}

C++内存配置器

标准库中包含一个名为allocator的类,允许我们将分配和初始化分离。使用allocator通常会提供更好的性能和更灵活的内存管理能力。

new有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete将对象析构和内存释放组合在了一起。我们分配单个对象时,通常希望将内存分配和对象初始化组合在一起。因为在这种情况下,我们几乎肯定知道对象应有什么值。当分配一大块内存时,我们通常计划在这块内存上按需构造对象。在此情况下,我们希望将内存分配和对象构造分离。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象的创建操作(同时付出一定开销)。一般情况下,将内存分配和对象构造组合在一起可能会导致不必要的浪费。

标准库allocator类定义在头文件memory中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。类似vector,allocator是一个模板。为了定义一个allocator对象,我们必须指明这个allocator可以分配的对象类型。当一个allocator对象分配内存时,它会根据给定的对象类型来确定恰当的内存大小和对齐位置。

#include <iostream>
#include <memory>
using namespace std;
//先熟悉一下提供的allocator用法
int main(int argc, char const *argv[])
{
    allocator<int> a;
    int *ptr=a.allocate(5);
    a.construct(ptr,3);
    a.construct(ptr+1,-3);
    a.construct(ptr+2,3);
    a.construct(ptr+3,-3);
    a.construct(ptr+4,3);
    for(int i=0;i<5;i++)
    {
        cout<<*(ptr+i)<<" ";
        a.destroy(ptr+i);
    }
    a.deallocate(ptr,5);
    return 0;
}

内存屏障

内存屏障 CPU乱序执行在单线程环境下是一种很好的优化手段,但是在多线程环境下,就会出现数据不一致的问题,因此就可以通过内存屏障这个机制来处理这个问题。

1.写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中最新数据更新写入主内存中,让其他线程可见。强制写入主内存,这种显示调用,不会让CPU去进行指令重排序 2.读内存屏障(Load Memory Barrier):在指令后插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存中加载数据。也是不会让CPU去进行指令重排。