现代 C++ 跨平台开发-内存篇:内存管理、智能指针

本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 2 篇,主要涉及:内存管理、智能指针等内容。

C 内存管理


内置的内存管理函数

malloc()

  • mmap:

    • 内存映射,以页为单位分配;

    • 容易产生缺页中断;

  • brk:

    • 调整 data segment 后第一个位置(即移动堆顶指针);

    • 容易产生内存碎片;

  • 返回指针前,创建了 metadata header,包含:

    • size

    • in-use flag;

    • 前/后块指针(用于合并空闲块)

    • 内存对齐信息;

  • malloc() 不会置零,即默认是随机值:

    • 如果不能保证后续会完全写入所有单元,必须 memset(),否则读取随机值属于 UB!

    • 反之如果确认会全部写入,则不置零反而效率更高;

calloc()

  • 会保证分配的空间全被置为 0;

  • 效果上 malloc() + memset(0) = calloc() 基本成立,但 calloc() 有独特优势:

    • 更高效:可直接从全零内存页映射,无需置零;

    • 更安全:

      • calloc() 会检查 n * size 是否溢出,溢出返回 NULL

      • malloc() 空间不够可能会返回较小的内存,后续 memset() 会越界!

    • 自带 n 参数,更适合分配数组。

realloc()

  • 重新调整通过 malloc()/calloc() 分配的堆内存大小。

free()

  • 基于 metadata header,只是做标记,并不保证真正释放。

第三方内存分配

glibc 的 malloc 的问题

  • 跨线程 free() 效率低;

  • 倾向于不断分配,而不是复用,碎片多;

Windows malloc 的问题

  • 分配速度慢。

微软的 mimalloc

  • 基于 thread_local 的 heap:

    • 更好的缓存局部性;

    • 避免跨线程内存操作的资源竞争,或同步操作的性能;

      • 直接放入当前线程的 free_list

      • 通过无锁队列传递回原 owner 线程;

  • 安全性:

    • 防止缓冲区溢出:通过保护页隔离内存区域,无法跨越页面破坏堆元数据;

    • 防止指针篡改:空闲列表指针使用页面密钥加密存储,无法预测或修改空闲链表;

    • 防止 Use-After-Free:释放的内存会被保护页隔离或填充特定字节,再次访问触发错误;

    • 防止 Double-Free:检测并忽略重复释放操作,避免堆元数据损坏;

    • 增加攻击难度:地址随机化,分配策略随机化,难以预测内存布局。

C++ 内存管理

内存分配/释放/重用

new/delete

  • 不仅分配/释放空间,还自动调用构造/析构函数;

  • 分配失败会抛 std::bad_alloc 异常,可通过 std::set_new_handler() 处理;

void handler() {
    std::cout << "Memory allocation failed\n";
    std::set_new_handler(nullptr);
}
 
int main() {
    std::set_new_handler(handler);
    try {
        while (true) new int[1000'000'000ul]();
    } catch (const std::bad_alloc& e) {
        std::cout << e.what() << '\n';
    }
}

std::nothrow new

  • 分配失败不抛异常而返回 nullptr

  • 只能避免内存分配本身的异常,不能避免构造函数内部抛出的异常;

operator new/delete

  • 单纯分配/释放资源;

  • 原型:

    • void* operator new(std::size_t sz)

    • void operator delete(void* ptr) noexcept

  • 可以重载/重写;

placement new

  • 传入指针,不分配内存,只调用构造函数;

  • 原型:

    • void* operator new(std::size_t sz, void *ptr)

    • void operator delete( void* ptr, void* place)

  • 能重载/重写;

  • 提供的内存必须对齐且足够大;

  • placement delete 在构造函数抛异常时由 C++ 运行时自动调用,回滚已分配的内存,防止泄漏,不应该手动调用;

  • STL 中的 allocator 内部使用 placement new 复用内存;

std::construct_at/std::destoy_at(C++20)

可以看到,placement new/delete 更像是在 operator new/delete 基础上打补丁:

  • placement new 形式上只是 operator new 的重载,完全看不到调用构造函数的语义;

  • placement delete 则是构造失败自动调用,而不是语义上相对应的调用析构函数。

C++20 引入 std::construct_at()/std::destroy_at(),明确表达调用构造/析构的语义。

除此之外,还有以下优势:

  • 不依赖运算符重载,也不受 operator new/delete 重载干扰;

  • 泛型编程更友好,自动处理 CV 限定符;

  • 可用于 constexpr

std::launder(C++17)

  • 对于内存中已经存在一个有效对象,“洗白”一个因生命周期断裂而失效的指针;

  • 本质是绕过编译器指针溯源所做的优化假设(别管他从哪来的);

  • “洗白”得来的指针也无需释放,因为:内存不是它分配的,对象也不是它构造的。

#include <new>

class T {
    const int data; //注意这里是 const 成员,编译器假设整个生命周期不会改变
public:
    T(int value) : data(value) {}
    ~T() {}
    // 重载类专属的 operator new
    void* operator new(std::size_t size) {
        // 调用全局的 operator new 来实际分配内存
        return ::operator new(size);
    }
    // 重载类专属的 operator delete
    void operator delete(void* ptr) noexcept {
        ::operator delete(ptr); // 调用全局 operator delete
    }
};

int main() {
    // 使用标准 new 表达式
    auto p0 = new T(123);  // 自动调用 operator new + 构造函数
    delete p0;  // 自动调用析构函数 + operator delete

    // 调用 operator new 分配原始内存
    auto raw = T::operator new(sizeof(T));
    
    // 使用 placement new 在该内存上构造对象
    auto p1 = new(raw) T(456);
    p1->~T(); // 手动调用析构函数

    // 使用 construct_at 构造
    auto p2 = std::construct_at(static_cast<T*>(raw), 789);

    // 通过 launder “洗白”原始指针(若无 const 成员,则无必要)
    // T* bad = static_cast<T*>(raw);
    auto p22 = std::launder(static_cast<T*>(raw));

    // 通过 destroy_at 析构
    std::destroy_at(p2);

    // 调用 operator delete 释放内存
    T::operator delete(raw);

    return 0;
}

start_lifetime_as(C++23)

  • 对于一块还未构造对象的内存,显式开启对象生命周期;

  • 对于平凡类型,无需 placement new 构造;

  • 非平凡类型仍需 std::construct_at 或 placement new。

struct Data {
    uint32_t magic;
    uint32_t size;
};
static_assert(std::is_trivial_v<Data>);
static_assert(std::is_standard_layout_v<Data>);

std::span<std::byte> bytes = load_file("f");
// 确保内存对齐
assert(bytes.size() >= sizeof(Data));
assert(reinterpret_cast<uintptr_t>(bytes.data()) % alignof(Data) == 0);

// Data 是平凡类型,无需 placement new 构造
// 直接开启对象生命周期,完成序列化
auto* data = std::start_lifetime_as<Data>(bytes.data());

allocator

  • 基于模板,提供统一的的内存管理接口:

    • T* allocate(size_t n):分配空间来存储 Tn 个实例;

    • void deallocate(T* p, size_t n):释放分配的内存;

    • void construct(T* p, Args ...args):使用参数构造一个对象,C++20 已移除;

    • void destroy(T* p):调用 p 的析构函数,C++20 已移除;

  • STL 模板类可以使用自定义 allocator

template <typename T>
class MyAllocator {
public:
    using value_type = T;
    using pointer = T*;
    using const_pointer = const T*;
    using size_type = std::size_t;

    MyAllocator() = default;
    template <typename U> MyAllocator(const MyAllocator<U>&) {}

    T* allocate(std::size_t n) {
        return static_cast<T*>(std::malloc(n * sizeof(T)));
    }

    void deallocate(T* p, std::size_t) {
        std::free(p);
    }

    // list/map 等节点式容器会用到 rebind:
    // 通过 allocator_traits 获取能分配 Node 的分配器;
    // 对 C++20+,如果没有 rebind,尝试直接构造 Alloc<U>;
    //using rebind_alloc = typename Alloc::template rebind<U>::other;
    //using NodeAlloc = typename std::allocator_traits<Alloc>::template rebind_alloc<Node>;
    template <typename U>
    struct rebind {
        using other = MyAllocator<U>;
    };
};

std::vector<int, MyAllocator<int>> v;
v.push_back(8);

PMR:多态内存资源

C++17 引入的 PMR(Polymorphic Memory Resource),是基于多态的内存分配接口:

  • 无模板代码膨胀问题;虽引入一定的运行时开销,但提供了更多灵活性;

  • 自带两种开箱即用的分配器:

    • 内存池(pool_resource):

      • 内部维护多个不同尺度的桶,每次从刚好能容纳的桶分配内存,否则扩容(可配置策略);

      • 有同步和非同步版本,适配多线程/单线程场景;

    • 单调缓冲区(buffer_resource):

      • 预分配,单调增长(类似 vector);

      • 提供手动批量释放接口 release(),但它只负责释放原始内存,不负责元素析构,所以要注意避免悬垂指针:

        • 要么元素可平凡析构;

        • 要么保证 release() 前所有元素已析构;

  • 适配了协程的新特性;

STL 默认的 Allocator 没有池化/缓冲区等内存复用行为,而是直接调用 operator new

// 自定义 PMR:
class MyMemoryResource : public std::pmr::memory_resource {
   std::pmr::memory_resource* _mr;
public:
    MyMemoryResource() : _mr(std::pmr::new_delete_resource()) {}
protected:
    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        return std::malloc(bytes);
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
        std::free(p);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }
};

// 使用自定义 PMR:
MyMemoryResource mr;
std::pmr::vector<int> v(&mr);
v.push_back(9);

// 使用 PMR 内置的内存池:
//std::pmr::pool_options opts;
//opts.max_blocks_per_chunk = 64;
//opts.largest_required_pool_block = 1024;
//std::pmr::unsynchronized_pool_resource pool(opts, std::pmr::new_delete_resource());
std::pmr::unsynchronized_pool_resource pool;
std::pmr::vector<int> v(&pool);
for (int i = 0; i < 1000; ++i) v.push_back(i);

// 使用 PMR 内置的单调缓冲区:
std::pmr::monotonic_buffer_resource br{4096, std::pmr::new_delete_resource()};
{
    std::pmr::vector<int> v(&br);
    for (int i = 0; i < 1000; ++i) v.push_back(i);
}
// 特有的操作:批量释放,复用内存;
// 需要确保内存使用方容器已析构,否则悬垂指针;
br.release();

OOP 内存管理

构造

  • 父类构造(按继承声明顺序);

  • 子类成员变量按类中声明顺序初始化;

  • 子类构造函数体执行;

初始化列表只指定“用什么值初始化”,不改变实际初始化顺序。

析构

  • 与构造顺序正好相反;

  • 多态场景(父类指针指向子类对象),必须将父类析构声明为 virtual,否则会内存泄漏:

    • 关于 virtual 关键字的机制,后面讲内存布局的虚函数表部分会讨论;

    • RTTI 理论上能完整回溯继承树继而安全析构,但 C++ 基于零成本抽象原则,RTTI 默认并未启用,除非真正需要(多态对于继承并不是必须的);

    • 虽然 Base *p = new Derived() 创建时能感知子类类型,但 delete p 可能在很远的地方,单纯父类指针既缺乏完整类型信息,也难以感知完整上下文;

    • 栈上对象,不需要 delete,RAII 自动释放,不涉及到多态;

RAII

RAII 即 Resource Acquisition Is Initialization:

  • 资源绑定对象生命周期:构造函数申请资源(不限于内存),析构函数释放资源;

  • 作用域驱动自动清理:基于栈的作用域机制,保证对象离开作用域自动调用析构;

  • 零成本抽象:编译期静态内联调用,无运行时开销。

作用域的本质

每个函数调用会创建一个栈帧,局部变量就分配在这个栈帧上;

所以,“作用域”本质就是“栈帧的生命周期”;“离开作用域”在底层就是“栈指针回退”。

为何不支持堆对象

  • 栈对象地址 = 栈指针偏移 -> 生命周期由函数调用/返回隐式管理;

  • 堆对象地址 = 动态分配 -> 生命周期与任何作用域无关,必须显式释放。

工程实践建议

  • 优先考虑创建栈对象(避免手动管理堆内存);

  • 利用对象封装资源管理逻辑,控制流越复杂,RAII 优势越明显(避免漏掉分支)。

并非万能

  • new 创建的堆内存,仍然需要显式 delete

  • 析构函数不能抛异常;

  • 多态场景(基类指针指向子类),基类虚函数必须声明为 virtual,否则基类指针必须手动转换为子类指针去释放,避免内存泄露。

智能指针

智能指针可以理解为:堆内存的 RAII 包装器。

  • 仅限于管理堆内存,某些局部内存指针(如 JNIEnv*)不能用智能指针;

  • 内存所有权要清晰,智能指针不是 GC/ARC。

auto_ptr

  • 可赋值拷贝,但赋值后原有对象再次访问会异常;已被废弃。

unique_ptr

  • 不能拷贝,只能移动:

    • 一般场景,独占所有权,RAII 自动释放;

    • 作为参数,表示放弃所有权;

    • 作为返回值,用于工厂方法;

  • 真正的零成本开销:

    • 基于模板的编译期 RAII 包装器+类型绑定(deleter 也是类型一部分),此外没有运行时数据结构;

    • 尽量使用空 struct 或无捕获 lambda 声明 deleter,可被优化为内联;

    • 移动操作是 constexpr 的指针赋值;

    • 适合绝大多数场景替换裸指针,结合 deleter 尤其适合自动管理 C API 相关资源;

  • 资源释放:

    • reset() :

      • 释放并销毁原生指针;

      • 如果参数为一个新指针,将管理这个新指针;

    • release():

      • 仅释放所有权、但不销毁,返回原生指针;

      • 用于和 C 接口交互,避免 double-free;

源码示例
// 默认删除器,EBO
template<typename T>
struct default_delete {
    constexpr default_delete() noexcept = default;

    template<typename U, typename = std::enable_if_t<std::is_convertible_v<U*, T*>>>
    constexpr default_delete(const default_delete<U>&) noexcept {}

    void operator()(T* ptr) const {
        delete ptr;
    }
};

// 核心实现
template<typename T, typename Deleter = default_delete<T>>
class unique_ptr {
public:
    using pointer      = T*;
    using element_type = T;
    using deleter_type = Deleter;

private:
    pointer ptr_;
    // 实际标准库用 __compressed_pair 做 EBO
    [[no_unique_address]] deleter_type del_;

public:
    constexpr unique_ptr() noexcept : ptr_(nullptr) {}
    constexpr explicit unique_ptr(pointer p) noexcept : ptr_(p) {}
    constexpr unique_ptr(pointer p, const deleter_type& d) noexcept : ptr_(p), del_(d) {}
    constexpr unique_ptr(pointer p, deleter_type&& d) noexcept : ptr_(p), del_(std::move(d)) {}

    // 禁用拷贝
    unique_ptr(const unique_ptr&) = delete;
    unique_ptr& operator=(const unique_ptr&) = delete;

    // 移动
    constexpr unique_ptr(unique_ptr&& other) noexcept
        : ptr_(other.release())
        , del_(std::forward<deleter_type>(other.del_))
    {}
    unique_ptr& operator=(unique_ptr&& other) noexcept {
        if (this != &other) {
            reset(other.release()); // 释放当前资源,接管新指针
            del_ = std::forward<deleter_type>(other.del_); // 移动删除器
        }
        return *this;
    }

    ~unique_ptr() {
        if (ptr_ != nullptr) {
            del_(ptr_);  // 调用删除器(编译期已知类型,可内联)
        }
    }

    pointer release() noexcept {
        pointer old = ptr_;
        ptr_ = nullptr;
        return old;  // 转移所有权给调用者
    }

    void reset(pointer new_ptr = nullptr) noexcept {
        pointer old = ptr_;
        ptr_ = new_ptr;
        if (old != nullptr) {
            del_(old);  // 销毁旧对象
        }
    }

    explicit operator bool() const noexcept {
        return ptr_ != nullptr;
    }

    typename std::add_lvalue_reference<T>::type operator*() const {
        return *ptr_; // 解引用
    }

    pointer operator->() const noexcept {
        return ptr_;
    }
};
使用示例
static sqlite3* allocDB(std::string_view file) {
    sqlite3 *db{nullptr};
    if (const auto rc = sqlite3_open_v2(std::string(file).c_str(), &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr); rc != SQLITE_OK || db == nullptr) [[unlikely]] {
        if (db != nullptr) {
            sqlite3_close(db);
        }
        return nullptr;
    }
    return db;
}

struct DBDeleter {
    void operator()(sqlite3 *db) const noexcept {
        if (db != nullptr) {
            sqlite3_close(db);
        }
    }
};

std::unique_ptr<sqlite3, DBDeleter> db{allocDB(file), DBDeleter{}};

shared_ptr

  • 共享所有权,基于引用计数;

  • 引入控制块,牺牲一定性能换取灵活性:

    • 采用类型擦除,将 deleterallocator 封装在控制块,通过虚函数或函数指针在运行时调用;

    • 此外还维护引用计数、弱引用数;

    • 占用两个指针空间:指针对象、控制块;

  • std::make_shared()

    • 优势:

      • 同时创建对象和控制块,避免两次内存分配;

      • 控制快和对象内存连续,缓存局部性更优;

      • 合并为一次原子化的内存分配,容易保证异常安全;

    • 不适用的场景:

      • 构造函数私有或者声明为 delete

      • 自定义 deleter 时,因为它不属于类型的一部分,默认直接 delete

      • 重载了 operator new,同上,默认直接调用全局 ::operator new

  • 真正原子化的只有引用计数更新,成员访问需要自己处理线程安全(使用 C++20 的 std::atomic<std::shared_ptr<T>>,或通过 std::atomic_load()/std::atomic_store() 模拟);

  • 如果要拿到 thisshared_ptr,需继承 std::enable_shared_from_this

    • 因为当前类可能已通过 shared_ptr 管理,直接拿 this 构造 shared_ptr 会重复创建控制块,导致双重释放;

    • 避免在构造函数中调用 share_from_this(),因为控制块还未创建。

  • 切勿滥用:

    • 本质上属于内存所有权不清晰,绝大多数情况可通过调整架构设计来避免;

    • 有一种特例,lambda 需要捕获指针,通过 shared_ptr 延长生命周期。

错误实践
  • 持有裸指针,可能变成悬垂指针:
T* t{nullptr};

void onCllback(std::shared_ptr<T> data) {
   t = data.get();//data 离开作用域可能释放
}
  • 通过裸指针再次构造共享指针,会产生新的控制块,最终导致双重释放:
auto p0 = std::make_shared<T>();
//auto p1 = std::shared_ptr<T>(p0.get());//错误:产生新的控制块
auto p2 = std::shared_ptr<T>(p0);//正确:直接拷贝,复用控制块,仅仅会增加引用计数
  • 直接通过 this 构造共享指针,也会导致双重释放;
class Worker : public std::enable_shared_from_this<Worker> {
public:
    void startWork() {
        //auto self = this;//直接捕获 this 可能悬垂指针
        //auto self = std::shared_ptr<Worker>(this);//直接拿 this 构造共享指针可能双重释放
        auto self = shared_from_this();//复用控制块,增加引用计数,安全
        std::thread([self]() {
            self->doWork();
        }).detach();
    }
private:
    void doWork() {
        std::cout << "thread running, object alive\n";
    }
};

int main() {
    auto worker = std::make_shared<Worker>();
    worker->startWork();
    return 0;
}
  • 回调场景持有了共享指针,造成循环引用,可能引发延迟释放甚至内存泄漏:
std::vector<std::shared_ptr<T>> dataset;

void onCllback(std::shared_ptr<T> data) {
   //回调通常 app 退出才取消注册,会造成内存延迟释放甚至泄露;
   //典型的所有权不清晰,短期改为持有 weak_ptr,长期应考虑改善设计;
   dataset.push_back(data);
}

weak_ptr

  • 通过 shared_ptr 创建,配合使用,避免循环引用;

  • __shared_weak_count 类(就是 shared_ptr 的控制块)维护强引用和弱引用计数:

    • 若强引用计数为 0,释放托管的指针内存;

    • 若弱引用计数为 0,释放控制块本身;

  • 继承 std::enable_shared_from_this 后,还可通过 weak_from_this() 返回 thisweak_ptr

std::out_ptr(C++23)

  • 安全地将输出型(二级)指针绑定到智能指针;
std::unique_ptr<FILE, decltype(&fclose)> file(nullptr, &fclose);

////////// 原始写法:
FILE* raw_ptr;
if (fopen_s(&raw_ptr, "test.txt", "r") == 0) {
    file.reset(raw_ptr); // 手动接管
}

////////// 使用 std::out_ptr
// 无需临时裸指针,直接绑定智能指针,安全拿到二级指针
if (fopen_s(std::out_ptr(file), "test.txt", "r") == 0) {
    // 无需手动接管
}
// 自动析构,无需手动 fclose

std::inout_ptr(C++23)

  • 处理智能指针已有值、需要重新分配或修改内存的情况;
bool safe_realloc_string(char** buf, size_t new_size) {
    if (buf == nullptr) return false;
    free(*buf); // 先释放旧内存(即使 *buf == NULL 也安全)
    *buf = (char*)malloc(new_size);
    return (*buf) != nullptr;
}

std::unique_ptr<char[], decltype(&free)> str(nullptr, &free);
str.reset((char*)malloc(100)); // 第一次分配

//////////////////// 原始写法:
// 重分配:需要小心处理
auto temp = str.release(); // 必须用 release 转移所有权,否则 double-free!
if (safe_realloc_string(&temp, 200)) {
    str.reset(temp); // 接管新内存
} else {
    free(temp); // 失败时手动清理(因为 safe_realloc 可能已 free 旧内存但 malloc 失败)
}

//////////////////// 使用 std::inout_ptr
// 安全重分配,无需临时裸指针,自动 get() 后输入给临时指针
if (safe_realloc_string(std::in_out_ptr(str), 200)) {
    // str 自动接管新内存
    // 旧内存已被安全释放
} else {
    // 失败:str 被置为 nullptr(因为 in_out_ptr 在失败时会 reset)
    // 无需手动清理
}

智能指针类型转换

仅支持 std::shared_ptr,用于更新引用计数(虽产生新的对象,但仍共享控制块),避免不安全的裸指针转换导致的双重释放等内存问题。
std::unique_ptrstd::weak_ptr 不存在控制块和引用计数更新问题:前者可直接转换裸指针,后者可先拿到 std::shared_ptr 再安全转换。

std::shared_ptr<Base> base = std::make_shared<Derived>();

// 直接拿裸指针转换(危险!)
auto raw = base.get();
auto derivedRaw = static_cast<Derived*>(raw);
std::shared_ptr<Derived> derived0(derivedRaw);

// dynamic_pointer_cast:安全向下转型
auto derived1 = std::dynamic_pointer_cast<Derived>(base);

// static_pointer_cast:假设你知道类型是对的(更快,但不安全)
auto derived2 = std::static_pointer_cast<Derived>(base);

// const_pointer_cast:去除 const
auto cptr = std::make_shared<const Derived>();
auto non_const = std::const_pointer_cast<Derived>(cptr);

// reinterpret_pointer_cast(慎用!)
// auto vptr = std::reinterpret_pointer_cast<void>(base);