现代 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::nothrow new

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

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

operator new/delete

  • 单纯分配/释放资源;

  • 内部可调用 malloc()/free(),也可直接使用栈内存;

placement new

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

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

  • 传入指针,不分配内存,只调用构造函数;不能 delete,但需要调用析构函数;

  • 原对象的生命周期会终结,内存被覆盖;如果要继续使用,需通过 std::launder;

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

std::launder(C++17)

某些场景(内存复用/序列化)需要改变对象表示,之前只能强转或 reinterpret_cast(),但其实属于 UB,需要更安全的机制。

  • 内存布局改变后(如 placement newreinterpret_cast()),用于获取对象的合法指针;即:安全地销毁旧对象、并复用内存、原地构造新对象;

  • 顾名思义,”洗白“:绕过编译器指针溯源所做的优化假设(别管他从哪来的);

  • 不改变内存状态,需要保证已提前构造(placement new),否则仍是 UB。

auto data = load_file("classes.dex");
auto view = std::span<std::byte>(data);
new (view.data()) dex_header;//确保已构造
auto header = std::launder(reinterpret_cast<dex_header*>(view.data()));//洗白指针

start_lifetime_as(C++23)

  • 不是简单封装 std::launder() + reinterpret_cast(),明确开启生命周期(方便编译器做优化);

  • 对于平凡类型,无需再 placement new 构造;非平凡类型仍需 std::construct_at()placement new

auto header = std::start_lifetime_as<dex_header>(view.data());//直接开启生命周期

allocator

  • 提供内存管理接口(嵌入式设备没实现 malloc()/free()),将内存分配和构造隔离开:

    • 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

OOP 内存管理

构造

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

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

  • 子类构造函数体执行;

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

析构

  • 与构造顺序正好相反;

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

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

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

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

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

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

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

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

weak_ptr

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

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

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

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

  • 持有回调等场景可考虑;

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);