目录
开胃菜 - 浅析C/C++的内存分段
内存分段
各段说明
new和delete的基础用法
深度剖析new
定位new
浅析delete
malloc/free和new/delete的异同
这部分是计算机系统相关的知识,这里只是先浅谈一下,可能有些内容会有冲突,但重在理解。
从狭义上讲内存的分段可以分为堆、栈、数据段以及代码段(内存映射区比较复杂,暂不涉及),大致内容可以参考下图:
但其实细说的话其实并没有这么简单。在编译阶段,编译器生成的目标文件(.o或.obj)中至少包括编译后的机器指令代码、数据,以及链接时所需要的的一些信息(比如符号表、调试信息、字符串等)。其中,目标文件将这些信息按不同的属性以“ 段 ”(segment)的形式存储。如下是一些我们经常会提到的段:
这里我们主要分析表格中常见的段信息,虽然还有其他段,例如注释信息段( .comment )等,但它并不是我们关注的重点。
下图是一个目标文件的图例分析,主要分析代码和数据分别存放在哪些段中。图中并没有给出堆栈的信息,因为堆栈只有在程序被装载运行时才会分配内存,而在目标文件这个阶段还没有分配栈段的内存。而且堆栈相对来说比较熟悉,所以也就无伤大雅了。
图片来源: 实例说明代码段(.text)、数据段(.data)、bss段、只读数据段……堆和栈我们经常使用,这里就不多说了。堆区就是相当于直接在内存中进行操作,但实际上是在虚拟内存中操作的,并不会直接干扰物理内存。而栈则是程序运行时创建的栈帧。下面我们来介绍剩下数据区和代码区。
代码段:
程序的源代码(包括函数等)编译后的机器指令就会放在代码段,即 .text 段中。在经典的x86体系结构中,代码段通常包含了程序的函数、方法和一些固定的指令集等信息。
数据段:
数据段包括 .data 、 .bss 、 .rodata 三个部分,暂且认为程序中的全局变量和局部变量就是数据段。那么为什么数据段要分成 .data 、 .bss 、 .rodata 三个部分呢?
其主要因素有两个:是否占用内存空间、读写权限如何。
已初始化的全局变量和局部静态变量保存在 .data 段,未初始化的全局变量和局部静态变量一般放在 .bss 段。理论上讲,未初始化的全局变量也是应该放在.data段的,但由于它们都是0,所以为它们在.data段分配空间并且存放数据0也就没有必要了。其中,
.bss
段并不会占用磁盘空间,只会在程序加载到内存时分配相应的空间。这种懒加载的特性可以节省可执行文件的大小,特别是当程序中包含大量未初始化的全局或静态变量时。
.data段和 .bss 段中的都是可读写的数据,而 .rodata 存放的是只读数据,主要是一些const变量、字符串常量等。单独设立 .radata 段的好处是:在程序加载的时候可以将 .rodata 段的属性映射成只读,这样对这个段的任何修改操作都作为非法操作处理。另外在某些平台还可以将 .rodata 段存放在只读存储器,例如ROM,通过硬件保证只读。
那么为什么又要把 “代码段” 和 “数据段” 分开存放呢?
当程序被装载后,数据和指令分别被映射到两个虚拟内存区域。数据段对进程来讲是可读写的,而代码段对进程来说是只读的,所以这两个虚拟内存区域的权限可以被分别设置为可读写和只读,防止程序的指令被有意和无意地改写。
现代CPU的缓存一般被设计成数据缓存和指令缓存分离,程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
当系统中运行着多个该程序的副本时,例如多个线程同时都运行同一个程序,它们的代码段指令都是一样的,所以内存中只需要保存一份该程序的代码段,然后将每个副本进程的数据段区域分来,这样可以节省大量空间。
在C语言的时候我们在堆区申请内存时往往采用如下这种写法:
char* p = (char*)malloc(100 * sizeof(char));
// ...
free(p);
而在C++中往往采用如下的写法:
char* p = new char[100];
// ...
delete[] p;
这就是C++中new和delete关键字,具体用法格式如下:
// new的用法
new 类型名;
new 类型名(构造函数参数);
new 类型名{构造函数参数}; // C++11支持
// delete的用法
delete 指针;
delete[] 指针; // 对于数组的释放使用 delete[]
用法示例
// 隐式调用构造函数
MyClass* c1_x = new MyClass;
// 显示调用无参构造1
MyClass* c2_0 = new MyClass();
// 显示调用无参构造2
MyClass* c2_1 = new MyClass{};
// 显示调用单参构造1
MyClass* c3_0 = new MyClass(10);
// 显示调用单参构造2
MyClass* c3_1 = new MyClass{ 10 };
// 显示调有单参构造1
MyClass* c4_0 = new MyClass(10, 20);
// 显示调有单参构造2
MyClass* c4_1 = new MyClass{ 10,20 };
// 显示调用有参构造 - 数组
MyClass* c = new MyClass[5]{ {10,20},{20,30},{30,40} }; //部分初始化
// 数组无法这样初始化,只能上面这样初始化
//MyClass* c = new MyClass[5]( {10,20},{20,30},{30,40} ); //error
注意事项
- C++之所以要引入new和delete,一个好处是可以简化写法。但更重要的原因是为了适配C++中的自定义类型。
- 在C++中malloc和free等是不会自动调用构造和析构的,而new和delete可以自动调用构造和析构。
- malloc等函数申请空间失败时返回NULL,而new的申请空间失败会抛异常(抛异常更符合C++的胃口)
- delete释放单个数据,delete[]释放数组,要注意匹配使用。因为delete和delete[]在底层的实现机制是不一样的,如果不匹配使用会导致很危险的未定义行为。
在使用new
运算符时,它会执行以下步骤:
调用
operator new
:new
运算符首先会调用operator new
函数,用于在堆上分配内存。operator new
函数返回一个指向未初始化内存块的指针。这一步只涉及内存的分配,还没有创建对象。构造对象:接下来,
new
运算符会调用相应类型的构造函数来在已分配的内存上创建对象。这一步才是创建对象的过程。构造函数会对对象进行初始化,根据构造函数的逻辑,可能会分配额外的资源或设置对象的初始状态。返回指针:最后,
new
运算符返回指向已创建对象的指针,使得我们可以在代码中使用这个指针来访问和操作新创建的对象。
简言之,new
运算符的操作包含内存分配和对象构造两个步骤,确保对象在正确初始化的情况下在堆上动态创建。特别的,在使用new
运算符创建数组时,步骤中的第二步会重复调用相应类型的构造函数来创建数组中的每个元素。
new和operator new
也许看到new会调用operator new的时候感到很懵逼,但实际上new和operator new是两回事:new是一个语法糖运算符,它将申请空间与调用构造一并完成;而operator new是一个用于动态申请内存空间的函数,它的内部实际上就是封装了malloc等函数,只不过operator new在申请内存空间失败时会抛异常,这更符合C++的胃口。
(new的相关源代码:gcc/libstdc++-v3/libsupc++/new_op.cc (github.com))
下面是operator new的相关的介绍:
在C++中,
operator new
函数是用于动态分配内存的重要函数。它用于申请一块内存来存储对象或数据。而且,new
运算符在背后会调用operator new
函数来完成内存分配,并在构造对象时调用构造函数。
operator new
的函数定义如下:void* operator new (std::size_t size);
其中
size
参数是所需内存块的字节数。该函数返回一个指向已分配内存的指针,或者在无法满足分配请求时,抛出std::bad_alloc
异常。在使用
new
运算符分配对象时,例如:MyClass* ptr = new MyClass;
那么在底层,它被转换为:
MyClass* ptr = static_cast
(operator new(sizeof(MyClass))); /* 编译器为我们隐式调用构造函数,并构造对象 */ 如果需要自定义内存分配行为,可以重载全局的或类的
operator new
函数。
我们知道,C++可以显示调用析构,但是并不允许显示调用构造函数。但其实还有一种可以看作是显示调用构造的语法:定位new
定位new(placement new)是new运算符的变体,定位new的作用是在已分配空间的内存块上构造对象。定位new的语法格式如下:
// 隐式调用构造函数
new (place_address) type
// 显示调用构造函数
new (place_address) type(initializer-list)
// 列表初始化 C++11
new (place_address) type{initializer-list}
/* 注释:
place_address - 指针或是一块合法的内存地址
initializer-list - 初始化列表
*/
定位new的使用场景
定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化,所以如果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。
用法示例:
我们知道,malloc等函数只是单纯的动态分配空间,所以我们可以将malloc分配的内存用定位new显示的构造一个对象。可以说,某种意义上定位new可以理解成显示的调用构造函数进而构造出一个对象。demo如下:
#include
using namespace std;
class value
{
public:
value(int val = 0) : _val(val) {}
void show_val()
{
cout << _val << endl;
}
~value()
{
cout << _val << ": 析构" << endl;
}
private:
int _val;
};
int main()
{
// 先分配内存(堆区栈区都可以)得到指针
value* p1 = (value*)malloc(sizeof(value));
value* p2 = (value*)malloc(sizeof(value));
value* p3 = (value*)malloc(sizeof(value));
new(p1)value; // 隐式调用无参构造
new(p2)value(10); // 显示调用有参构造
new(p3)value{ 20 }; // 列表初始化,C++11支持
p1->show_val();
p2->show_val();
p3->show_val();
cout << endl;
delete p1;
delete p2;
delete p3;
cout << endl;
return 0;
}
delete和new一样,也是一个被封装好的运算符。不同的是,new运算符先通过operator new函数申请空间,然后调用构造函数。而delete则是先调用析构函数然后再调用operator delete函数。
我们知道operator new运算符是封装了malloc函数,而operator delete同理也是封装了free函数。
(delete的相关源代码:gcc/libstdc++-v3/libsupc++/del_op.cc (github.com))
共同点是:
都是从堆上申请空间,并且需要用户手动释放。
不同的地方是:
- malloc和free是函数,new和delete是运算符。
- new相较malloc写法上更简便。
- malloc申请的空间不会初始化,new申请空间的同时也会构造对象。
- 同理,free不会调用析构,但delete运算符是先调用析构再free当前对象。
- malloc的返回值为void*, 在使用时必须强转,new则不需要。
- malloc申请空间失败时,返回的是NULL,因此使用时需要判空。new申请空间失败时并不返回NULL,而是抛异常,因此new需要捕获异常。
- 申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数。而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理。