跳到主要内容 C++ 入门篇 (七): 对象拷贝优化与动态内存管理 | 极客日志
C++
C++ 入门篇 (七): 对象拷贝优化与动态内存管理 C++ 对象拷贝时的编译器优化机制,如 RVO/NRVO 在传值和返回中的应用。详细讲解了 C++ 内存布局(栈、堆、数据段、代码段),对比了 C 语言 malloc/free 与 C++ new/delete 的区别,指出后者会调用构造函数和析构函数。深入分析了 new/delete 底层实现为 operator new/delete,涉及异常处理及大小记录。最后介绍了定位 new(placement new)语法及其在内存池等高性能场景下的应用,强调了手动管理内存时的注意事项。
DevOpsTeam 发布于 2026/3/30 更新于 2026/4/13 0 浏览C++ 入门篇 (七): 对象拷贝优化与动态内存管理
1. 对象拷贝时编译器的优化
编译器在进行编译的时候,会对一些需要拷贝的情况进行优化,像我们之前提到了构造 + 拷贝构造优化成直接构造。当然,这个优化是根据编译器的情况来决定的,一些老的编译器可能就没有,而一些新的编译器优化力度更大一些,或者说更激进一些,可能会存在跨行优化。在 release 版本下,优化力度会更大。
优化主要是在拷贝的时候发生,比如传值传参,或者传值返回。之前我们讲到的是隐式类型转换的时候,'1' 先去构造一个临时对象,然后再拷贝构造到初始化的对象里面,编译器会直接优化成用 1 去构造。
我们先讲传值传参,如果是内置类型的话,就不会那么麻烦,直接就拷贝了 (类型转换,传值调用,传值返回都是)。类类型的对象才有优化拷贝这一说。
class A {
public :
A (int a = 0 , int b = 2 ) : _a(a) { cout << "A(int a)" << endl; }
A (const A& aa) : _a(aa._a) { cout << "A(const A& aa)" << endl; }
~A () { cout << "~A" << endl; }
A& operator =(const A& a) { _a = a._a; cout << "operator=" << endl; return *this ; }
void Print () { cout << _a << endl; }
private :
int _a = 1 ;
};
我们定义好了这样一个类以后,我们来看这个例子:
void func (A a1) {}
int main () {
func (1 );
func (A (1 ));
return 0 ;
}
我们来看这两个例子,首先 1 先隐式类型转换,调一次构造,构造一个临时对象,然后临时对象拷贝构造给形参。和之前一样,vs2019 会直接优化成一次构造。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown 转 HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
HTML 转 Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
来看第二个,是一个匿名对象,匿名对象先调一次构造初始化,然后再拷贝构造给形参。也是先构造再拷贝构造,编译器同样会优化。
A func2 () {
A aa;
return aa;
}
我们之前提到过,传值返回也会先生成临时对象,然后再看情况要不要拷贝构造回来。
int main () {
A aa;
func2 ().Print ();
}
如果这样写的话,就是用了临时对象。临时对象和匿名类的生命周期都是只在这一行里面。
这样的写其实就是,先构造一个 aa,然后 aa 在拷贝构造一个临时对象,编译器同样会优化成一个直接构造一个临时对象,会把 aa 省略掉。这是怎么看出来的呢?我们只要看一下析构函数在什么时候调用就行,这里一个很好的标杆。如果生成了 aa 的话,那么在 func2 结束的时候会析构一下。但是,从运行结果是来看并没有析构。所以跳过了 aa。
int main () {
A a1 = func2 ();
}
如果这样写的话,在不开优化的情况下,会先构造一个 aa,然后再拷贝构造一个临时变量,再用临时变量拷贝构造 a1。在编译器优化的时候,连续构造会被优化为直接构造,直接用 aa 来拷贝构造 a1。
在 vs2019 这个编译器里面的优化开的是很大的。
举个例子,我们来给 A 这个类来一个 ++ 的重载:
A& operator ++() {
++_a;
return *this ;
}
A func2 () {
A aa;
++aa;
return aa;
}
这样编译器会不会优化呢?也会优化的。会自己分析后面的语句,然后直接用 aa 拷贝构造。
int main () {
A ret;
ret = func2 ();
return 0 ;
}
这里就不是拷贝构造了,是赋值重载,vs2019 debug 版本是不会优化的。
但是 release 版本还是会优化的,相当于是直接构造临时对象,然后直接赋值重载。可以理解为构造了 aa 把 aa 直接当临时对象。
编译器为什么会做优化,因为不是所有的情况都能用传引用,必须有地方要传值,所以在语义方面不能用引用优化了,那就只能在传值的地方优化。
还要注意的时,怎么优化没有标准,编译器自己来,能合并尽可能合并,前提要保持正确性。
2. C++ 里的内存管理
1. 复习 C++ 里面的内存划分其实和 C 语言里面的是一样的,都分为栈,堆,数据段 (静态区),代码段。
数据段时用来储存全局变量和静态变量,代码段是储存指令和常量值。
int globalVar = 1 ;
static int staticGlobalVar = 1 ;
void Test () {
static int staticVar = 1 ;
int localVar = 1 ;
int num1[10 ] = { 1 , 2 , 3 , 4 };
char char2[] = "abcd" ;
const char * pChar3 = "abcd" ;
int * ptr1 = (int *)malloc (sizeof (int ) * 4 );
int * ptr2 = (int *)calloc (4 , sizeof (int ));
int * ptr3 = (int *)realloc (ptr2, sizeof (int ) * 4 );
free (ptr1);
free (ptr3);
}
选择题:
选项:A.栈 B.堆 C.数据段 (静态区) D.代码段 (常量区)
globalVar 在哪里?____
staticGlobalVar 在哪里?____
staticVar 在哪里?____
localVar 在哪里?____
num1 在哪里?____
char2 在哪里?____
*char2 在哪里?___
pChar3 在哪里?____
*pChar3 在哪里?____
ptr1 在哪里?____
*ptr1 在哪里?____
这里都比较简单,我说几个容易记错的。char2 在栈,是数组首元素的地址。数组里面是 "abcd"。*char2 ,也在栈上。pChar3 在栈上。*pChar3 在代码段里面。
这是为什么呢?首先 "abcd" 这个字符串的字面量存在代码段里面,数组 char2 创建的时候,是把这个字符串拷贝到栈里面,然后数组名是首元素的地址。而 pChar3 是指向这个代码段里面字符串的指针,所以,它解引用就是访问代码段里面的字符串。
C 语言里面的动态内存管理,用到的是 malloc,calloc,realloc 三个函数。要注意它们的不同点。这里要重点提一下 realloc,realloc 是扩容,可以原地扩,也可以异地扩,传空指针的话行为类似 malloc。
还有一件事,free 的时候不能从中间的部分开始 free,要从头开始 free。
2. C++ 里面的动态内存管理 C++ 里面的动态内存管理用的是 new 和 delete。对于内置类型来说,它们的作用和 C 语言里面的开空间和释放差不多,区别就是语法的使用和可以随意初始化。
int * pa = new int [10 ] = {0 ,1 ,2 };
delete [] pa;
这样就好了,顺带着初始化一下。这里要注意,上面的例子里面是不完全初始化,十个整形剩下的都初始化成了 0。但是如果不去初始化,来不完全初始化也不写,就直接结束的话,是不会初始化的为 0 的,也就是随机值。
这里注意一下,new 和 delete 是关键字,而不是函数。编译的时候遇到关键字,会转化成对应的一系列汇编指令。那么 C++ 里面的内存管理和 C 语言里面的有什么不同呢?
当然是在处理类对象的时候。malloc 不会调用构造函数,free 也不会调用析构函数。但是 new 和 delete 会调用。这就是区别。
class A {
public :
A (int a = 0 ) : _a(a) { cout << "A(int a)" << endl; }
A (const A& aa) : _a(aa._a) { cout << "A(const A& aa)" << endl; }
~A () { cout << "~A" << endl; }
A& operator =(const A& a) { _a = a._a; cout << "operator=" << endl; return *this ; }
void Print () { cout << _a << endl; }
private :
int _a = 1 ;
int _b = 1 ;
};
A* pa = new A;
delete pa;
这样是申请了一个 A 类类型的空间,并且调用这个 A 的默认构造函数。当然,我们也可以手动给构造函数传参:
A* pa = new A[10 ];
delete [] pa;
对于这个数组来说,每一个自定义变量都要调用一次默认构造函数,释放之前都要调一次析构函数。
A aa1;
A aa2;
A aa3;
A* pa = new A[3 ];
如果申请失败了怎么办?这里 new 返回的就不再是空指针了。因为 C++ 是面向对象的,面向对象的语言里面要用到 throw , try /catch 的方式。这个后面会细讲,这里简单提一下。(抛异常)
try {
char * ch = new char [1024 *1024 *1024 ];
} catch (const exception& e) {
cout << e.what () << endl;
}
抛异常,抛出异常出来我们要捕获,捕获一个叫 exception 的东西,这是库里面的一个类,然后 .what() 来访问这个异常。这里的话报的异常是 bad_alloc。就是申请失败。当然,我们也可以吧申请空间放一个函数里面:
void func () {
char * ch = new char [1024 *1024 *1024 ];
}
...
try {
func ();
} catch (const exception& e) {
cout << e.what () << endl;
}
其实一般情况下都不会有申请失败的问题。一兆就是一百万字节,1G 就是十亿字节。当然我们这里申请空间都是虚拟内存。
在三十二位环境下,虚拟内存的大小是 4G 左右,内核占一个 G,堆占了不到 2G,其余的区域分剩下的空间,所以堆这个地方是非常大的,(linux 里面的栈只有 8M)。
如果是六十四位的环境下,虚拟内存的大小就是 16EB,当然这么大肯定是用不了的,堆的话占 21G 左右。
那么这里的虚拟内存是什么意思,计算机组成里面有地址总线,可以产生地址,一个内存单元一个字节,就是虚拟地址。所以,三十二位环境下,有 2^32 个地址,那虚拟内存就是这么大,四十二亿字节。六十四位环境下就是 16EB。
但是我们的电脑硬件上的内存肯定没有这么大,是因为虚拟内存要分块经过映射来对应实际的内存。具体的映射规则到操作系统讲。
3. new 和 delete 的底层
operator new 和 operator delete 之前提到过 new 既开辟的空间,又调用了构造函数。因为 new 是一个关键词,到这里就完成这两个指令。
其中开空间就是 operator new 干的。operator new 这个函数的底层其实就是 malloc,用这个函数再套一层就是为了上面提到的抛异常。
operator delete 的里面其实就是 free。
当我们开多个数据和释放多个数据的时候,用的是 operator new[], operator delete[]。这两个和上面两个区别就是,给它们传了大小,指定一次性开多少次空间,和调用多少次构造函数,或者析构函数。
如果 new 和 delete 的底层是 malloc 和 free 的话,那么我们用的时候可不可以混搭。
答案是最好不要,有的时候会有问题有的时候没问题,既然可以搭配使用不会错,那为什么要混搭呢?
int main () {
A* pA = new A[3 ];
delete pA;
int * pb = new int [3 ];
delete pb;
}
这样使用的话就是 pA 会报错,pb 没问题。那么是为什么呢?我们之前提到过,free 空间要从头开始,不能从中间开始 free。
这里的 new A[3] 看着是开了三个 A 的空间,然后返回一个指针指向这三个 A 的空间的开头,其实在这个指针之前,还开了一个整形,也就是四个字节,来储存大小。储存这个大小是为了调用多少次析构函数用的。所以 free 这里的 pA 其实是从中间开始 free 的所以会报错。但是,对于内置类型来说,不需要考虑调用析构函数的事,所以没问题。
定位 new (placement) 这里还有一个点,就是定位 new,这个是什么意思呢?我们之前提到过 new 这个关键词的指令就是调用一个 operator new 的一个全局函数。因为这个函数是全局的,所以我们也可以直接调用这个函数:
A* pA = (A*)operator new (sizeof (A));
这样单独使用这个的话是不会调用构造函数的,也就不会初始化,但是构造函数又不可以显式调用,那么我们怎么办呢?
这就是定位 new 的写法,先确定指针再确定要调用构造函数。
pA->~A ();
operator delete (pA) ;
那么这个时候就有人要问了,这样不是多此一举吗?其实还是有用的,new 和 delete 可以解决 99% 的问题,这个地方就是来解决 1% 的。
这里我们可以提前了解一下池的概念,池就是把一些东西分割出来一部分,单独给某个东西使用。举个例子,我们可以有内存池,线程池,连接池。我们以我们最熟悉的内存池为例。如果我要申请空间的话,我是向内存里面的堆去申请,如果我的要求次数比较多,高频地去申请空间,操作系统在这个地方处理起来就可能会处理不过来,因为计算机里面很多进程共用一个堆,在申请的时候也会有一些检查机制,而且多个进程过来了也可能排队,那么我就可以单独从堆里拉一块空间出来,这一块空间单独给我使用,我申请空间就先从这里申请,这样会更加的高效。而且,不用了我就释放掉,再还给这块空间还能循环利用。这块空间就是内存池。要注意,这块内存池里面的空间本质还是堆里的。如果我要申请一块超级大的空间,这个内存池没这么大,还是要去堆里面申请的。
C++ 里面提供了这个 new,我们可以从堆上 new 一个自定义类型的空间,然后初始化,但是我们在使用这个内存池的时候,只开空间不给调用构造的,调了一个 pool.Alloc(sizeof(Type)),所以这个定位 new 就排上用场了。