跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
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/10/2599 浏览

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 会直接优化成一次构造。

来看第二个,是一个匿名对象,匿名对象先调一次构造初始化,然后再拷贝构造给形参。也是先构造再拷贝构造,编译器同样会优化。

甚至,像刚才提到了激进的情况:

A aa1;
func(aa1);

编译器会跨行优化,把这个构造和拷贝构造也优化掉。

我们再来看传值返回的情况:

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;
}

然后把 func 函数换一个写法:

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);
}
  1. 选择题: 选项: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 语言里面的开空间和释放差不多,区别就是语法的使用和可以随意初始化。

new 的使用是这样,申请一个整形的空间:

int* pa = new int;

不需要类型转换,不需要自己算字节大小。

int* pa = new int(10);

这样的话就是把这个整形初始化为 10。

delete pa;

这样就释放空间了。

如果我要开一个数组怎么办,也很简单:

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 类为例:

A* pa = new A;
delete pa;

这样是申请了一个 A 类类型的空间,并且调用这个 A 的默认构造函数。当然,我们也可以手动给构造函数传参:

A* pa = new A(1);

我们还可以申请自定义类型的数组:

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:

new(pA)A(10);

这就是定位 new 的写法,先确定指针再确定要调用构造函数。

析构函数是可以在直接调用的,所以就可以:

pA->~A();
operator delete(pA);

那么这个时候就有人要问了,这样不是多此一举吗?其实还是有用的,new 和 delete 可以解决 99% 的问题,这个地方就是来解决 1% 的。

这里我们可以提前了解一下池的概念,池就是把一些东西分割出来一部分,单独给某个东西使用。举个例子,我们可以有内存池,线程池,连接池。我们以我们最熟悉的内存池为例。如果我要申请空间的话,我是向内存里面的堆去申请,如果我的要求次数比较多,高频地去申请空间,操作系统在这个地方处理起来就可能会处理不过来,因为计算机里面很多进程共用一个堆,在申请的时候也会有一些检查机制,而且多个进程过来了也可能排队,那么我就可以单独从堆里拉一块空间出来,这一块空间单独给我使用,我申请空间就先从这里申请,这样会更加的高效。而且,不用了我就释放掉,再还给这块空间还能循环利用。这块空间就是内存池。要注意,这块内存池里面的空间本质还是堆里的。如果我要申请一块超级大的空间,这个内存池没这么大,还是要去堆里面申请的。

C++ 里面提供了这个 new,我们可以从堆上 new 一个自定义类型的空间,然后初始化,但是我们在使用这个内存池的时候,只开空间不给调用构造的,调了一个 pool.Alloc(sizeof(Type)),所以这个定位 new 就排上用场了。

目录

  1. C++ 入门篇 (七): 对象拷贝优化与动态内存管理
  2. 1. 对象拷贝时编译器的优化
  3. 2. C++ 里的内存管理
  4. 1. 复习
  5. 2. C++ 里面的动态内存管理
  6. 3. new 和 delete 的底层
  7. operator new 和 operator delete
  8. 定位 new (placement)

更多推荐文章

查看全部
  • FLAC3D 边坡地震稳定性动力分析:基线校正与模拟实现
  • C++ 红黑树:原理、旋转与完整实现
  • C++ STL 容器:基于红黑树模拟实现 map 和 set
  • Linux 网络基础:局域网通信与跨网段传输
  • Arduino BLDC 电机串口远程控制工业巡检机器人设计
  • 基于 Netty 构建高性能 HTTP 服务器实战指南
  • Spring Cloud Sentinel 熔断降级实战:原理与配置详解
  • OpenClaw 配置与 QQ Bot 接入指南
  • 数据团队建设:核心角色与技能要求
  • OpenClaw 搭建飞书 AI 办公机器人:本地 Ollama 接入与 Skills 自动化
  • Claude-Code 2.1.88 源码结构深度解析:基于 Source Map 还原
  • Ubuntu 24.04 使用 Docker Compose 本地部署 Whisper 语音识别服务
  • 基于 Java Spring 的在线 OJ 系统竞赛管理模块实现
  • Logits 详解:与 Softmax 关系、使用场景及注意事项(中英双语)
  • C++ 日志系统设计:从基础实现到性能优化
  • 快速选择算法实战:求解数组第 K 大元素与最小 K 个数
  • WebSocket 如何实现全双工通信:从 HTTP 升级到协议切换
  • 昇腾 NPU 部署 CodeLlama 大模型实战指南
  • Java 实现的网格疫情扩散模拟算法
  • Spring AI MCP Server 集成与示例

相关免费在线工具

  • 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