Memory and C++
内存模型
全局/静态区的区别
| 区域 | 生命周期 | 管理方式 | 存放内容 |
|---|---|---|---|
| 栈区(stack) | 自动随函数调用创建/销毁 | 编译器自动分配 | 局部变量、函数参数 |
| 堆区(heap) | 程序员手动分配/释放 | new/delete、malloc/free |
动态分配对象 |
| 全局/静态区(data segment) | 整个程序运行期间 | 编译器管理 | 全局变量、static 变量 |
| 常量区(text / rodata) | 程序运行期间只读 | 编译器管理 | 字面量字符串、常量数据 |
int g = 1; // 全局区
void f() {
static int s = 2; // 静态区
int a = 3; // 栈
int* p = new int(4); // 堆
}
new 和 malloc
new/delete 与 malloc/free 的区别
new 是 C++ 的,
- 最核心的特点在于它们是与 C++ 对象生命周期绑定的
- new 表达式:首先调用底层的内存分配函数(通常是
operator new),然后对这块内存上的对象调用构造函数进行初始化。 delete表达式:首先调用对象的析构函数进行清理工作,然后释放内存。
对于对象的,会调用析构函数, 如果不够抛出异常 std::bad_alloc
malloc是C的,分配失败返回nullptr
重载operator new
auto operator new(std::size_t sz) -> void* {
}
auto operator delete(void* p, std::size_t sz) -> void {
}
new[]
- 分配额外的空间 💾: 它会分配比
N * sizeof(Type)略大的一块内存,多出来的空间用来存储数组的元素数量 N。 - 批量构造 🔨:它会启动一个循环,对这 \(N\) 块内存逐一调用
Type的默认构造函数 - 当你调用
delete[] p;时,C++ 运行时会执行以下三个关键步骤 - 读取大小 📖: 运行时通过
p的地址向前偏移,找到并读取之前存储的元素数量 N。 - 批量析构 💥: 从最后一个元素到第一个元素,按顺序调用 N 次
Type的析构函数 - 释放内存 🗑️: 调用底层对应的释放函数
operator delete[]来释放整块内存(包括簿记信息)。
智能指针
uniqueptr
解决 delete 和 delete[] 配对问题的关键是使用了模板特化
unique_ptr 有两种不同的模板形式:
| 声明形式 | T (单个对象) | T[] (数组) |
|---|---|---|
| 语法 | unique_ptr<MyClass> |
unique_ptr<MyClass[]> |
| 底层 Deleter | 默认使用 delete |
特化后默认使用 delete[] |
为什么要用 make_shared
auto a = shared_ptr<A>(new A{});
这里有2次内存分配,而用 make_shared 会一次性分配好
enable shared from this
std::shared_ptr<MyClass> original_ptr(new MyClass());
//...
class MyClass {
public:
std::shared_ptr<MyClass> get_ptr() {
// 错误:在已经由一个 shared_ptr 管理的对象内部,
// 再次从裸指针 (this) 创建一个新的 shared_ptr
return std::shared_ptr<MyClass>(this);
}
};
C++ 会为这个返回的新指针创建一个新的、独立的控制块,其共享计数从 1 开始。
此时,同一个对象 (this 指向的对象) 被两个独立的控制块管理。当这两个 shared_ptr(原始的那个和新创建的这个)都归零时,对象会被尝试销毁两次,导致程序崩溃(Double Free)。
正确写法
class B
: public std::enable_shared_from_this<B>
{
int x;
int y;
public:
B() {
std::cout << "construct" << std::endl;
}
~B() {
std::cout << "deconstruct" << std::endl;
}
auto func() -> void {
std::cout << "hello this is B" << std::endl;
}
auto get_ptr() -> std::shared_ptr<B> {
return shared_from_this();
}
};
如果不加public会 terminate called after throwing an instance of 'std::bad_weak_ptr' , 因为 std::is_convertible 是私有的。
对于私有继承来说:对外部代码来说,看不到 D 和 Base 的继承关系
千万别在构造函数里调用 shared_from_this(),那时候 shared_ptr 还没完全建立好,内部 weak_ptr 还没初始化,也会抛同样的异常。
内存对齐
为什么要对齐
CPU 并不是按字节一个一个读取内存的,而是以固定的内存访问粒度(通常是 4 字节、8 字节,或整个缓存行,如 64 字节)批量加载数据。
- 对齐访问 (Aligned Access) 快: 如果一个 4 字节的整数(
int)恰好从一个 4 的倍数的地址开始(例如地址0x0004),CPU 只需要一次内存操作就能读取整个数据。 - 非对齐访问 (Unaligned Access) 慢: 如果同一个 4 字节的整数从一个非 4 的倍数的地址开始(例如地址
0x0005),那么这个数据会跨越两个 CPU 访问边界。CPU 必须执行以下操作: - 进行两次内存读取。比如
0x5的int, CPU从0x0load 到0x7, 到 - 将两次读取到的数据块进行位移和掩码 (Shifting and Masking) 操作,然后重新组合成原始数据。
怎么对齐
将结构体成员按照其数据类型所占的字节数,从大到小进行排列。
内存碎片Memory Fragmentation
- External Fragmentation 外部碎片
操作系统的堆分配(如 malloc)是面向不同大小的块的。当频繁分配 / 释放不规则大小的内存时,会留下很多小空洞。长期运行后,大块内存无法连续分配 → 内存碎片化。
- Internal Fragmentation
内部碎片是由于内存分配时的粒度 (Granularity) 限制造成的。
为了提高分配效率,内存分配器(或操作系统)通常会以固定大小的块(例如,8 字节、16 字节、或操作系统的页大小 4KB)为单位进行分配。
如果你的程序请求 \(N\) 字节,但分配器必须给你一个更大的块 \(M\) (\(M > N\)),那么块内未使用的 \(M - N\) 字节就是浪费,这就是内部碎片。
内存泄漏
分配了内存,但是没有释放。
危害
- 降低内存分配的性能,增加内存碎片
- 进程占用物理内存变多,需要swap,导致访问速度下降
- 物理内存和交换空间耗尽会出发OOM(out of memory) , 程序终止
场景
- new,delete 没有配对使用
- 在异常try里面的时候,释放代码被跳过
- 释放职责不明确,(智能指针)
- 容器存储指针
- sharedptr循环引用
避免
RAII, 智能指针
检测
线上:内存持续增加
开发:
- Linux:
valgrind --leak-check=full --show-leak-kinds=all ./program, 原理是Dynamic Binary Instrumentation - Windows: Visual Studio 内置检测器
悬空指针(Dangling Pointer)
指针指向的对象已被销毁或释放,但指针仍在使用。
调试工具:AddressSanitizer(clang/gcc 选项 -fsanitize=address)
happens-before
false sharing
调优、能写自定义 allocator
Allocator
为什么要自定义allocator
allocator(分配器)是 STL 容器的一个“隐藏接口”——
一般人几乎不会改它,但在一些高性能、特殊环境下,自定义 allocator 能带来巨大收益。在高性能、受限、或特种场景下,自定义 allocator 可以:
- 减少内存碎片
- 避免频繁 new/delete
- 提高缓存命中
- 支持特殊内存区域(共享内存、GPU、PMR)
减少 new和delete的次数
new和delete会要同步(heap的lock), system call
提高缓存命中
连续内存布局
NUMA
NUMA = Non-Uniform Memory Access直译就是:“非一致性内存访问”。 意思是:不同 CPU 访问不同内存区域的速度不一样。
早期的多核计算机使用的是 UMA(Uniform Memory Access)
┌────────────┐
CPU →│ Shared RAM │← CPU
└────────────┘
优点:简单。缺点:当 CPU 数多了,会出现严重的 内存访问瓶颈(争抢内存总线)。
NUMA Node 0: NUMA Node 1:
┌────────┐ ┌────────┐
│ CPU 0 │───┐ │ CPU 1 │───┐
│ Cores │ │ │ Cores │ │
└────────┘ │ └────────┘ │
│ │ │ │
▼ │ ▼ │
[Local RAM] [Local RAM]
│ │
└────────── Interconnect ─┘
每个“节点(Node)”包含:
- 一组 CPU 核(core)
- 它自己的内存控制器和一部分 RAM
NUMA Locality 意思是:
“让线程尽量访问它所在 NUMA 节点的本地内存。”