现代 C++ 跨平台开发-内存篇:内存管理、智能指针
本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 2 篇,主要涉及:内存管理、智能指针等内容。
- 现代 C++ 跨平台开发-内存篇:类型转换、重载决议、内存模型
- 现代 C++ 跨平台开发-内存篇:内存管理、智能指针
- 现代 C++ 跨平台开发-内存篇:字节序、内存对齐、内存布局、虚函数表与多态
- 现代 C++ 跨平台开发-内存篇:拷贝、移动、可重定位
- 现代 C++ 跨平台开发-内存篇:函数调用、异常处理、异步等场景的内存管理
- 现代 C++ 跨平台开发-内存篇:STL 内存管理
- 现代 C++ 跨平台开发-内存篇:内存一致性、未定义行为、可观测性
- 现代 C++ 跨平台开发-内存篇:多平台跨层调用场景的内存管理
- 现代 C++ 跨平台开发-内存篇:内存问题分析工具
C 内存管理
内置的内存管理函数
malloc()
mmap:
内存映射,以页为单位分配;
容易产生缺页中断;
brk:
调整 data segment 后第一个位置(即移动堆顶指针);
容易产生内存碎片;
返回指针前,创建了 metadata header,包含:
size;in-useflag;前/后块指针(用于合并空闲块)
内存对齐信息;
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):分配空间来存储T的n个实例;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
共享所有权,基于引用计数;
引入控制块,牺牲一定性能换取灵活性:
采用类型擦除,将
deleter及allocator封装在控制块,通过虚函数或函数指针在运行时调用;此外还维护引用计数、弱引用数;
占用两个指针空间:指针对象、控制块;
std::make_shared():优势:
同时创建对象和控制块,避免两次内存分配;
控制快和对象内存连续,缓存局部性更优;
合并为一次原子化的内存分配,容易保证异常安全;
不适用的场景:
构造函数私有或者声明为
delete;自定义
deleter时,因为它不属于类型的一部分,默认直接delete;重载了
operator new,同上,默认直接调用全局::operator new;
真正原子化的只有引用计数更新,成员访问需要自己处理线程安全(使用 C++20 的
std::atomic<std::shared_ptr<T>>,或通过std::atomic_load()/std::atomic_store()模拟);如果要拿到
this的shared_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()返回this的weak_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_ptr和std::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);