C++的内存管理

进程内存

堆、栈是C++中两种常见的内存段,均位于进程的虚拟内存空间中。

栈是所有局部变量存放的地方,包括函数参数,函数调用时,栈会自动分配内存空间,函数返回时,栈会自动释放内存空间。

每个线程都有自己的栈,所以栈内存可以认为是线程安全的。

堆是全局性的内存区域,会被进程内的所有线程共享,在使用new/delete/malloc/calloc等函数时进行堆操作。

通常,堆是自低地址向高地址增长的,而栈是自高地址向低地址增长的,方向相反。

栈内存的特点

  • 栈是连续的内存块。
  • 栈具有一个固定的最大容量,程序超过最大容量会导致崩溃,即栈溢出。
  • 栈内存永远不会出现内存碎片。
  • 栈内存的分配和释放速度非常快,有可能出现页缺失,但是非常少见。

堆内存的特点

  • 多线程共享,因此使用时需要注意线程安全问题。
  • 栈的内存分配具有顺序性,而堆的内存分配和释放具有随机性,出现内存碎片风险很大。

内存中的对象

C++程序中的所有对象都驻留在内存中,下面介绍内存的创建与销毁,以及对象的内存布局。

创建与销毁

new/delete用于在C++的堆上分配和释放内存,包含内存的分配过程以及构造函数的调用。

C++也允许我们将内存分配与对象的构造过程分开,如:

1
2
3
4
5
6
7
8
9
auto* memory = std::malloc(sizeof(User));
auto* user = ::new(memory) User("John");
```cpp
这里使用的是placement new,即在已经分配好的内存上构造对象,::new表示使用全局的new运算符,而不是类内部的new运算符,避免重载。

此时,我们需要手动调用析构函数来销毁对象:
```cpp
user->~User();
std::free(memory);

C++17在中引入了一组函数,用于在创建/销毁对象时不必分匹配或释放内存,可以使用其中以std::uninitialized_开头的函数进行构造、拷贝、移动对象到一个未被初始化的内存区域,不必调用placement new,如:

1
2
3
4
5
auto* memory = std::malloc(sizeof(User));
auto* user_ptr = reinterpret_cast<User*>(memory);
std::unininitialized_fill_n(user_ptr, 1, User("John"));
std::destory_at(user_ptr);
std::free(memory);

C++20引入了对等destroy_at的std::construct_at,可以取代unininitialized_fill_n。

运算符new delete允许重载,可以定制内存处理机制。

内存对齐

CPU每次从内存中读入一个字,64架构是64位。C++中的每一种类型都有其对齐要求。

可用alignof来查看对齐方式。

new和malloc()确保总是返回适当对其的内存,我们也可以手动指定内存对齐形式,决定两个对象间的内存间隙的大小。

还可以强制将两个变量分配到不同的缓存行中(假设其大小为64字节),如:

1
2
alignas(64) int x{};
alignas(64) int y{};

内存补齐

编译器有时需要为用户自定义的类型添加额外的字节进行补齐,对于Class和Struct中定义的数据成员,编译器会按照其声明的顺序补齐,如:

1
2
3
4
5
class Document{
bool is_cached{};
double rank{};
int id{};
};

编译器会将其补齐为:

1
2
3
4
5
6
7
8
class Document  /* size: 24, align: 8 */
{
bool is_cached; /* offset: 0, size: 1
char __padding[7]; size: 7 */
double rank; /* offset: 8, size: 8 */
int id; /* offset: 16, size: 4
char __padding[4]; size: 4 */
};

内存所有权

  • 局部变量是由当前作用域所有,离开作用域时自动销毁。
  • 静态变量由程序所有,程序结束时销毁。
  • 数据成员由所属的类的示例所有。
  • 只有动态变量才没有默认的所有者,需要注意控制其生命周期

借助RAII,使用自定义的类对象包装动态内存,可以确保在对象销毁时自动释放内存。

也可以借助于标准容器,容器本省拥有对象的所有权。还可以用std::optional来处理哪些可能存在也可能不存在的对象的生命周期。

智能指针

std::unique_ptr

独占所有权的智能指针,不能复制,使用完释放。

非常高效,与普通指针相比,增加的开销是由于std::unique的析构函数是一个非平凡析构函数,这意味着(与原始指针不同),当他被传递给一个函数时,不能在CPU的寄存器中传递,因此比原始指针慢。

std::shared_ptr

共享所有权的智能指针,可以复制,引用计数为0后释放。

std::shared_ptr是内部线程安全的,所以计数器需要原子更新以防止竞态条件。

推荐使用std::make_shared(),因为他相比new后分配更加高效,如下:

1
2
auto user = std::make_shared<double>(10.0);   //只需要一次内存分配
auto i = std::shared_ptr<double>(new double(10.0)); //需要两次

std::weak_ptr

弱引用,不需要只为我而保留它,可用于打破循环引用(两个共享指针互相指着对方,通过彼此的引用保持不被销毁)

为什么不直接使用原始指针?因为原始指针无法保证对象的生命周期,如果指针指向的对象被销毁,那么指针就变成了悬空指针。

小对象优化

在 C++ 编程中,容器如 std::vectorstd::string 提供了灵活和高效的内存管理。然而,对于只包含少量元素的容器,动态内存分配可能会带来不必要的性能开销。小对象优化通过避免小对象的动态内存分配,提升性能。

核心:对于小对象,我们不在堆上申请,而是将其存储到栈上。

std::string的实现中,许多字符串在普通程序中通常是短小的。因此,为了提高效率,标准库通常会为短字符串提供一个内部的小缓冲区。当字符串长度较短时,直接使用这个缓冲区;而当字符串超出缓冲区大小时,则切换到动态分配的堆内存。

一种简单但浪费内存的方式是,在字符串类中始终保留一个固定的缓冲区。然而,这样即使不使用小缓冲区,也会增加字符串类的大小。

更节省内存的解决方案是使用联合体 。联合体允许字符串在短模式下使用内部缓冲区,而在长模式下切换到动态分配的缓冲区。这种方式既节省了内存,又提高了性能。

以下是一个简化的例子,展示了 LLVM 的 libc++ 中 std::string 在 64 位系统上的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <string>

size_t allocated = 0;

void* operator new(size_t size) {
void* p = std::malloc(size);
allocated += size;
return p;
}

void operator delete(void* p) noexcept {
return std::free(p);
}

int main() {
allocated = 0;
auto s = std::string(""); // 使用空字符串测试
// autp s = std::string("1234567890123456789012"); // 使用长22字符的字符串测试
// autp s = std::string("12345678901234567890123"); // 使用长23字符的字符串测试

std::cout << "Stack space = " << sizeof(s)
<< ", Heap space = " << allocated
<< ", Capacity = " << s.capacity() << '\n';

return 0;
}

首先重载了全局的newdelete,以便跟踪内存分配。现在,测试空字符串,看看其性能,输出如下:

1
Stack space = 24, Heap space = 0, Capacity = 22

可以看到,std::string在栈上占用了24字节,没有使用堆内存,并且其容量为22。

对于长为22的字符串,输出相同,表明没有动态分配空间。

测试长为23字符的字符串:

1
Stack space = 24, Heap space = 32, Capacity = 31

此时,字符串超出了内部缓冲区的容量,切换到了动态分配的堆内存。它被分配了32个字节,容量为31,这是因为libc++总是在内部存储一个以零结尾的字符串,因此需要额外的1个字节表示来表示空字符串的结尾。

std::string的实现方式是使用两种不同布局的联合体,一种用于短字符串,另一种用于长字符串。

长模式的布局大概如下:

1
2
3
4
5
struct Long {
size_t capacity_{}; // 动态分配的容量
size_t size_{}; // 字符串长度
char* data_{}; // 指向堆内存的指针
};

每个成员都是8字节的,所以总大小为24字节。char指针data_只想用于存储长字符串的动态分配的内存。

短模式的布局大概如下:

1
2
3
4
struct Short {
unsigned char size_{}; // 字符串长度(最多 22)
char data_[23]{}; // 内部缓冲区
};

长度只能在0到22之间,所以size_只需要1个字节。内部缓冲区有23个字节,总大小为24字节。

两种布局使用联合体组合:

1
2
3
4
union StringStorage {
Short short_;
Long long_;
};

还有一点!字符串如何知道是使用哪种布局?它存放在哪里?

结果表明,在长模式下,libc++在capacity_字段中使用了最低有效位,在短模式下,在size_字段中使用了最低有效位。

长模式下,这个位是多余的,因为字符串分配的内存大小总是2的倍数,而短模式下,可以只用7位存储大小,这样就有一位可以作为标志位!


C++的内存管理
http://example.com/2025/02/21/Cpp_learning_0/
作者
icyyoung
发布于
2025年2月21日
许可协议