C++堆内存分配
抽象与分层
c和c++的内存服务模型与计算机网络里面的协议分层模型有点类似。计算机网络协议大体分为5层:应用层、传输层、网络层、数据链接层和物理层。其中,上层仅仅只需在下层所提供的服务之上构建自己的服务,而不用关心它的下下层所提供的服务。例如,http应用要完成相应的功能就只需考虑传输层tcp所能提供的服务,不需要知道网络层能提供什么服务。
回到内存分配的问题上来,其实,应用程序的内存分配也有明确的层级概念。当应用程序需要内存时,它管c++标准库要。所以应用程序只需知道c++标准库能给它提供内存配置的功能就够了。c++的new算子(操作符)1 提供虚拟内存分配的能力,这一点,c++程序员们心知肚明 2。通过编译器解释之后,new算子被展开,然后调用c++标准库提供的全局operator new(std::sizt_t)函数申请内存。
当你写下:
Foo* p = new Foo();
经编译器解释,然后生成类似下面的代码:
Foo* p;
// don't catch exceptions thrown by the allocator itself
void* raw = operator new(sizeof(Foo)); //step 1:调用全局的operator new操作符申请虚拟内存。
// catch any exceptions thrown by the ctor
try {
//step 2:调用placement new函数在raw开始的虚拟地址位置构造一个Foo对象。
//等价于((Foo*)raw)->Foo::Foo();
p = new(raw) Foo(); // call the ctor with raw as this
}
catch (...) {
// oops, ctor threw an exception
operator delete(raw);//step 3:如果构造函数抛出异常,则将分配的内存返还。
throw; // rethrow the ctor's exception
}
值得注意的是,当构造函数出现异常时,并不会调用析构函数,不过,会调用对象内部的非指针成员变量的析构函数3。所以如果直接用原始指针作为成员变量,那么在对象构造函数为指针成员变量分配资源之后,成功返回之前,如果抛出了异常,那就会导致资源泄漏4。所以,强调一下Koening&Moo认为的c++最重要的三个建议之一: 避免使用指针。但是完全不用指针几乎是不可能的,所以,作为替代,可以而且必须选择智能指针。
仔细考察后发现new的关注点是替应用程序向堆索要内存,并向上层应用提供虚拟内存。如果不考虑细节,new的行为其实等同于malloc的行为5。既然new提供向堆索要内存的服务,那么,它可以把精力集中在怎么与堆交互上--如何扩展有效堆内存,如何维护有效堆内存。
如何扩展有效堆内存
每个进程都有标准的虚拟空间布局:代码段、数据段、向上增长的堆以及向下的栈还有最顶层的受保护的系统空间。这里,有效堆空间是可以动态增长的,栈区的大小一般初始化为1M,所以,就地址可访问性来说,如果不特别设置,栈区的大小是固定的。过小的栈空间容易导致段错误就是这个原因。你可以试试定义一个大数组,或者搞个深度非常大的递归调用来观察这个现象6。
进程某时刻的快照:
快照中位于有效堆顶与栈之间的虚拟地址区间(图中虚拟地址区间(0x080DE000, 0XBF7EC000])对进程来说是无效的,这意味着这部分的内存不可访问,也就是说,这时如果要执行一条访存的cpu指令,且被访问的内存地址落在(0x080DE000, 0XBF7EC000]区间的话,会引发一个段错误,应用程序就死掉了。这里发生了什么呢?
解释上面的现象需要一定的操作系统基础。当cpu拿到一条访存指令时,它会到指令所给出的虚拟地址处去取数据,我们知道,硬件部分有一个叫MMU的单元,它负责将一个虚拟地址翻译成对应的物理地址。在这个翻译过程中,MMU会检查地址