C++的内存管理
进程内存
堆、栈是C++中两种常见的内存段,均位于进程的虚拟内存空间中。
栈是所有局部变量存放的地方,包括函数参数,函数调用时,栈会自动分配内存空间,函数返回时,栈会自动释放内存空间。
每个线程都有自己的栈,所以栈内存可以认为是线程安全的。
堆是全局性的内存区域,会被进程内的所有线程共享,在使用new/delete/malloc/calloc等函数时进行堆操作。
通常,堆是自低地址向高地址增长的,而栈是自高地址向低地址增长的,方向相反。
栈内存的特点:
- 栈是连续的内存块。
- 栈具有一个固定的最大容量,程序超过最大容量会导致崩溃,即栈溢出。
- 栈内存永远不会出现内存碎片。
- 栈内存的分配和释放速度非常快,有可能出现页缺失,但是非常少见。
堆内存的特点:
- 多线程共享,因此使用时需要注意线程安全问题。
- 栈的内存分配具有顺序性,而堆的内存分配和释放具有随机性,出现内存碎片风险很大。
内存中的对象
C++程序中的所有对象都驻留在内存中,下面介绍内存的创建与销毁,以及对象的内存布局。
创建与销毁
new/delete用于在C++的堆上分配和释放内存,包含内存的分配过程以及构造函数的调用。
C++也允许我们将内存分配与对象的构造过程分开,如:
1 |
|
C++17在
1 |
|
C++20引入了对等destroy_at的std::construct_at,可以取代unininitialized_fill_n。
运算符new delete允许重载,可以定制内存处理机制。
内存对齐
CPU每次从内存中读入一个字,64架构是64位。C++中的每一种类型都有其对齐要求。
可用alignof来查看对齐方式。
new和malloc()确保总是返回适当对其的内存,我们也可以手动指定内存对齐形式,决定两个对象间的内存间隙的大小。
还可以强制将两个变量分配到不同的缓存行中(假设其大小为64字节),如:
1 |
|
内存补齐
编译器有时需要为用户自定义的类型添加额外的字节进行补齐,对于Class和Struct中定义的数据成员,编译器会按照其声明的顺序补齐,如:
1 |
|
编译器会将其补齐为:
1 |
|
内存所有权
- 局部变量是由当前作用域所有,离开作用域时自动销毁。
- 静态变量由程序所有,程序结束时销毁。
- 数据成员由所属的类的示例所有。
- 只有动态变量才没有默认的所有者,需要注意控制其生命周期
借助RAII,使用自定义的类对象包装动态内存,可以确保在对象销毁时自动释放内存。
也可以借助于标准容器,容器本省拥有对象的所有权。还可以用std::optional来处理哪些可能存在也可能不存在的对象的生命周期。
智能指针:
std::unique_ptr
独占所有权的智能指针,不能复制,使用完释放。
非常高效,与普通指针相比,增加的开销是由于std::unique的析构函数是一个非平凡析构函数,这意味着(与原始指针不同),当他被传递给一个函数时,不能在CPU的寄存器中传递,因此比原始指针慢。
std::shared_ptr
共享所有权的智能指针,可以复制,引用计数为0后释放。
std::shared_ptr是内部线程安全的,所以计数器需要原子更新以防止竞态条件。
推荐使用std::make_shared
1 |
|
std::weak_ptr
弱引用,不需要只为我而保留它,可用于打破循环引用(两个共享指针互相指着对方,通过彼此的引用保持不被销毁)。
为什么不直接使用原始指针?因为原始指针无法保证对象的生命周期,如果指针指向的对象被销毁,那么指针就变成了悬空指针。
小对象优化
在 C++ 编程中,容器如 std::vector
和 std::string
提供了灵活和高效的内存管理。然而,对于只包含少量元素的容器,动态内存分配可能会带来不必要的性能开销。小对象优化通过避免小对象的动态内存分配,提升性能。
核心:对于小对象,我们不在堆上申请,而是将其存储到栈上。
在std::string
的实现中,许多字符串在普通程序中通常是短小的。因此,为了提高效率,标准库通常会为短字符串提供一个内部的小缓冲区。当字符串长度较短时,直接使用这个缓冲区;而当字符串超出缓冲区大小时,则切换到动态分配的堆内存。
一种简单但浪费内存的方式是,在字符串类中始终保留一个固定的缓冲区。然而,这样即使不使用小缓冲区,也会增加字符串类的大小。
更节省内存的解决方案是使用联合体 。联合体允许字符串在短模式下使用内部缓冲区,而在长模式下切换到动态分配的缓冲区。这种方式既节省了内存,又提高了性能。
以下是一个简化的例子,展示了 LLVM 的 libc++ 中 std::string 在 64 位系统上的实现方式:
1 |
|
首先重载了全局的new
和delete
,以便跟踪内存分配。现在,测试空字符串,看看其性能,输出如下:
1 |
|
可以看到,std::string在栈上占用了24字节,没有使用堆内存,并且其容量为22。
对于长为22的字符串,输出相同,表明没有动态分配空间。
测试长为23字符的字符串:
1 |
|
此时,字符串超出了内部缓冲区的容量,切换到了动态分配的堆内存。它被分配了32个字节,容量为31,这是因为libc++总是在内部存储一个以零结尾的字符串,因此需要额外的1个字节表示来表示空字符串的结尾。
std::string
的实现方式是使用两种不同布局的联合体,一种用于短字符串,另一种用于长字符串。
长模式的布局大概如下:
1 |
|
每个成员都是8字节的,所以总大小为24字节。char指针data_只想用于存储长字符串的动态分配的内存。
短模式的布局大概如下:
1 |
|
长度只能在0到22之间,所以size_只需要1个字节。内部缓冲区有23个字节,总大小为24字节。
两种布局使用联合体组合:
1 |
|
还有一点!字符串如何知道是使用哪种布局?它存放在哪里?
结果表明,在长模式下,libc++在capacity_字段中使用了最低有效位,在短模式下,在size_字段中使用了最低有效位。
长模式下,这个位是多余的,因为字符串分配的内存大小总是2的倍数,而短模式下,可以只用7位存储大小,这样就有一位可以作为标志位!