Skip to content

Memory and C++

内存模型

全局/静态区的区别

区域 生命周期 管理方式 存放内容
栈区(stack) 自动随函数调用创建/销毁 编译器自动分配 局部变量、函数参数
堆区(heap) 程序员手动分配/释放 new/deletemalloc/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/deletemalloc/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
  • 批量析构 💥: 从最后一个元素到第一个元素,按顺序调用 NType析构函数
  • 释放内存 🗑️: 调用底层对应的释放函数 operator delete[] 来释放整块内存(包括簿记信息)。

智能指针

uniqueptr

解决 deletedelete[] 配对问题的关键是使用了模板特化

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 是私有的。

对于私有继承来说:对外部代码来说,看不到 DBase 的继承关系

千万别在构造函数里调用 shared_from_this(),那时候 shared_ptr 还没完全建立好,内部 weak_ptr 还没初始化,也会抛同样的异常。

内存对齐

为什么要对齐

CPU 并不是按字节一个一个读取内存的,而是以固定的内存访问粒度(通常是 4 字节、8 字节,或整个缓存行,如 64 字节)批量加载数据。

  1. 对齐访问 (Aligned Access) 快: 如果一个 4 字节的整数(int)恰好从一个 4 的倍数的地址开始(例如地址 0x0004),CPU 只需要一次内存操作就能读取整个数据。
  2. 非对齐访问 (Unaligned Access) 慢: 如果同一个 4 字节的整数从一个非 4 的倍数的地址开始(例如地址 0x0005),那么这个数据会跨越两个 CPU 访问边界。CPU 必须执行以下操作:
  3. 进行两次内存读取。比如 0x5 的int, CPU从 0x0 load 到 0x7 , 到
  4. 将两次读取到的数据块进行位移和掩码 (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 节点的本地内存。”