基于现代 C++ 的跨平台开发 - 内存篇
从前些年零星参与 Android/iOS C/C++ 跨平台项目,到 24 年真正从零搭建完整的跨所有主流平台的 C++ 项目,再到去年底进入某老牌软件大厂见识横跨近 30 年的庞大 C++ 项目,对 C++ 跨平台开发有了越来越深刻的认识。
虽然之前也有写一些相关的文章,但大多属于管窥蠡测;这里尝试做一个体系化、结构化的梳理。
涉及的知识点颇多,但每个又都不可或缺,否则就无法全面地认识 C++ 这门既古老而又年轻的系统编程语言,以及诸多内存问题的来龙去脉。
借用我对 C++ 之父的一句经典言论的演绎开篇:
C makes it easy to shoot yourself in the root.
C++ makes it harder.
Rust takes away your gun and gives you a bible.
类型系统
类型是内存的“契约”:
内存只是字节,类型赋予其意义(没有类型的void*,就不知道如何解释它)。
指针与引用 - 内存地址的抽象
指针
指针 = 地址 + 偏移(类型信息)
char*,short*,int*虽指向同一地址,但解引用或++/--时需考虑类型信息确定偏移量;
引用
- 内部实现也是指针,只是做了编译期限制(必须初始化、不允许重新赋值);
nullptr
C 语言
NULL定义为((void*)0),C++ 中就是魔法数0;函数重载和类型推导时会出问题,无法区分指针和数字。
实现:
const class nullptr_t {
public:
template<class T>inline operator T*() const { return 0; } //实现解引用
private:
void operator&() const; //禁止取地址
}
指针别名限制
两个指针不会内存重叠(指向同一内存),否则就是 UB;便于编译器做向量化(SIMD)和指令预取等优化;
C
提供了
restrict关键字,限制同类型指针;嵌套调用等场景会有问题;
C++
默认并不支持
restrict关键字,但各大编译器提供了类似扩展;不仅限于同类型指针,非相关类型指针也不行(
int*/char*/std::byte*除外,但int*/float*不行);
类型转换 - 改变对内存的解释方式
static_cast()
常用于有关系的类型之间转换,编译器会校验,绝大多数情况适用;
用于基本类型间的转换,不能用于基本类型指针间的转换 ;
有继承关系类对象间的转换和类指针间的转换(如 CRTP);
用于左右值引用转换;
T*<->void*(有类型检查,比reinterpret_cast()更安全);
dynamic_cast()
有继承关系的类指针间的转换;
运行时多态,有开销;如果确认类型,直接
static_cast();具有类型检查的功能,转换失败抛出
std::bad_cast异常;
dynamic_pointer_cast()
dynamic_cast的智能指针版本;避免悬空指针和内存泄漏;
reinterpret_cast()
指针间的类型转换,不如
static_cast()安全,尽量避免;整数和指针间的类型转换;
可以加上
const和volatile,但不能去掉。不能用于将
nullptr转换为其他类型,只能用static_cast()转;
const_cast()
仅用于指针或引用;
仅用于去掉
const;(加上不需要 cast);如果需要多次 cast,一般先用其他 cast,最后
const_cast();运行时转换,不安全(可能崩溃,或属于未定义行为);
模板元编程场景的类型标识处理
编译时行为,安全高效。
const 标识
仅处理顶层
const标识。
std::is_const<T>();std::add_const<T>();std::remove_const<T>();
volatile 标识
std::is_volatile<T>();std::add_volatile<T>();std::remove_volatile<T>();
引用标识
std::is_reference_v<T>();std::is_lvalue_reference<T>();std::is_rvalue_reference<T>();std::add_lvalue_reference<T>();std::add_rvalue_reference<T>();std::remove_reference<T>();
std::decay_t()
完全模拟函数按值传参过程的类型退化。
移除(顶层)
const、volatile、引用标识;数组类型退化为指针类型;
函数签名退化为函数指针类型;
std::remove_cvref(C++20)
安全地获取裸类型。
- 仅移除(顶层)
const、volatile、引用标识,不搞类型退化。
重载决议 - 隐式类型转换优先级
精确匹配
类型完全相同;
加上
const/volatile;数组 -> 指针;
std::function-> 指针;
类型提升
bool/char/short->int;float->double;
标准转换
int->long/float;double->float;Derived*->Base*;指针/数字 ->
bool;
用户定义的转换
重载构造函数,如
string的const char*构造函数;重载运算符:
class A {
public:
operator int() const {};
operator double() const {};
};
void foo(int) {};
void foo(double) {};
foo(A{}); // Error: ambiguous
RTTI - 运行时类型元数据
编译器遇到
typeid(T)或dynamic_cast()或虚函数时,自动为类型T生成一个type_info对象,保存在只读数据段(.rodata/.rdata)中的全局静态对象;
可通过-fno-rtti禁用,则所有使用了typeid(T)或dynamic_cast()的代码都会编译失败;
class type_info {
public:
virtual ~type_info();
bool operator==(const type_info& rhs) const;
bool operator!=(const type_info& rhs) const;
const char* name() const;
private:
const char* __name; //类型的 mangled name(如"i"表示 int)
}
静态类型信息
类型判断
template
struct is_floating_point_type {
static const bool value = false;
};
template <>
struct is_floating_point_type {
static const bool value = true;
};
template <>
struct is_floating_point_type {
static const bool value = true;
};
类型比较
template
struct is_same {
static constexpr bool value = false;
};
// 偏特化:当 T 和 U 相同时
template
struct is_same<T, T> {
static constexpr bool value = true;
};
类型标识
template <typename T>
void* get_type_id() {
static char token; //针对每个类型特化,生成唯一的静态类型标识
return &token;
}
template <typename T>
T* any_cast(any* a) {
// 直接比对类型标识,不依赖 RTTI 的 type_info
if (a && a->type_id_ == get_type_id<T>()) {
return static_cast<T*>(a->ptr_);
}
return nullptr;
}
内存模型
栈
栈是向下生长的(高地址在上);
函数内局部变量;
效率高:存在寄存器,且有 push/pop 指令;
容量小:每个线程都有固定大小的栈空间;
函数调用前后,由系统自动分配和释放;
堆(自由存储区)
堆是向上生长的(低地址在上);
需手动释放;
几乎无空间限制;
容易产生碎片;
全局/静态区
static
作用于全局变量/函数:
改变链接属性:只能在模块内使用,外部无法通过
extern访问;既能实现封装性,也能避免符号冲突;
C 语言中,静态变量在代码执行前就会初始化,不能用变量赋值;
C++ 更推荐使用匿名
namespace实现隐藏内部符号;
作用于函数内局部变量:
- 改变存储属性:保存在静态区的(
.data或.bss段),不会随着函数执行完销毁;
- 改变存储属性:保存在静态区的(
作用于 C++ 类:
静态成员变量:存在静态区;
静态成员函数:不依赖
this指针,通过类名直接访问,仅能访问静态成员;
常量区
const相关成员一般存储在只读段(.rodata);若某些情况被优化为编译期常量,则不占运行时存储。
const
强调运行期不可修改;
const变量:局部:
可能被优化为编译期常量,否则放栈上;
const_cast()可能侥幸成功,但属于未定义行为;
全局:
放在只读段;
const_cast()会导致运行时崩溃,因为只读段读写权限不可逾越;
字符串字面量:
相同字符串只存一份;
永远在只读段;
从右向左读( * 读成 pointer to );
const在类型前后是等价的;
char * const cp; //cp is a const pointer to char;
const char * p; //p is a pointer to const char;
char const * p; //同上;constexpr
consexpr
强调编译期可求值,便于编译器深入优化;
修饰变量:
不仅跟
const一样表示是不可变的,而且是编译期可确定的;不占用运行时存储。
修饰函数:
若参数编译期可确定,则会在编译期计算出函数返回值;
否则,等同于普通函数;
consteval(C++20)
- 用于修饰函数,比
constexpr更严格,强制编译期可求值,否则编译错误。
编译器默认初始化行为
全局/静态成员,执行零初始化(Zero-initialization):
数值类型置零,指针置空;
调用默认构造函数;
普通局部成员:
数值和指针类型,不做任何处理,也就是垃圾值!
自定义类型,调用默认构造函数(如果构造函数并未初始化成员,则其非静态成员仍是垃圾值);
读取未初始化的垃圾值属于未定义行为,虽不一定会崩溃,但表现不符合预期,可能不同平台/不同编译类型的行为都不一样。
静态局部成员:
在首次调用时初始化;
C++11 开始,编译器保证线程安全,可用于实现单例;
为何编译器不保证始终初始化
C++ 的哲学是“零成本抽象”,尤其是默认行为更应该零成本:
for (auto i = 0; i < 1000000; ++i) {
int temp; // 如果自动初始化为 0,就要执行 100 万次写内存
// ... 实际马上会被赋值
temp = compute(i);
}
最佳实践
int a; // 我知道风险,不要初始化(高性能场景)
int b = 0; // 我要确定值
int c{}; // C++11 引入的值初始化,安全且明确
std::vector<int> v{}; // 空 vector
MyStruct s{}; // 所有成员零初始化
内存管理
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::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 new、reinterpret_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):分配空间来存储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;
OOP 内存管理
构造
父类构造(按继承声明顺序);
子类成员变量按类中声明顺序初始化;
子类构造函数体执行;
初始化列表只指定“用什么值初始化”,不改变实际初始化顺序。
析构
与构造顺序正好相反;
多态场景(父类指针指向子类对象),必须将父类析构声明为
virtual,否则会内存泄漏:RTTI 理论上能完整回溯继承树继而安全析构,但 C++ 基于零成本抽象原则,RTTI 默认并未启用,除非真正需要(多态对于继承并不是必须的);
栈上对象,不需要
delete,RAII 自动释放,不涉及到多态;虽然
Base *p = new Derived()创建时能感知子类类型,但delete p可能在很远的地方,单纯父类指针既缺乏完整类型信息,也难以感知完整上下文。
RAII (Resource Acquisition Is Initialization)
资源绑定对象生命周期:构造函数申请资源(不限于内存),析构函数释放资源;
作用域驱动自动清理:基于栈的作用域机制,保证对象离开作用域自动调用析构;
零成本抽象:编译期静态内联调用,无运行时开销。
作用域的本质
每个函数调用会创建一个栈帧,局部变量就分配在这个栈帧上;
所以,“作用域”本质就是“栈帧的生命周期”;“离开作用域”在底层就是“栈指针回退”。
为何不支持堆对象
栈对象地址 = 栈指针偏移 -> 生命周期由函数调用/返回隐式管理;
堆对象地址 = 动态分配 -> 生命周期与任何作用域无关,必须显式释放。
工程实践建议
优先考虑创建栈对象(避免手动管理堆内存);
利用对象封装资源管理逻辑,控制流越复杂,RAII 优势越明显(避免漏掉分支)。
并非万能
new创建的堆内存,仍然需要显式delete;析构函数不能抛异常;
多态场景(基类指针指向子类),基类虚函数必须声明为
virtual,否则基类指针必须手动转换为子类指针去释放,避免内存泄露。
智能指针 - 堆内存的 RAII 包装器
仅限于管理堆内存,某些局部内存指针(如
JNIEnv*)不能用智能指针;
内存所有权要清晰,智能指针不是 GC/ARC。
auto_ptr
- 可赋值拷贝,但赋值后原有对象再次访问会异常;已被废弃。
unique_ptr
不能拷贝,只能移动:
一般场景,独占所有权,RAII 自动释放;
作为参数,表示放弃所有权;
作为返回值,用于工厂方法;
真正的零成本开销,适合绝大多数场景替换裸指针:
基于模板的编译期 RAII 包装器+类型绑定(
deleter也是类型一部分);尽量使用空
struct或无捕获 lambda 声明deleter,可被优化为内联;移动操作是
constexpr的指针赋值;没有运行时数据结构(如引用计数);
特别适合自动管理 C API 相关资源;
资源释放:
reset():释放并销毁原生指针;
如果参数为一个新指针,将管理这个新指针;
release():仅释放所有权、但不销毁,返回原生指针;
用于和 C 接口交互,避免 double-free;
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不属于类型的一部分;占用两个指针空间:指针对象、控制块(引用计数、弱引用数,
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:- 避免在构造函数中调用
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)
// 无需手动清理
}
函数调用过程中的内存管理
函数调用过程
调用指令(如
call):将控制权转移到函数入口;返回值地址入栈;
参数依次入栈;
保存当前寄存器的值(保存现场);
栈帧指针(即
RSP,用于返回上层调用方)和局部变量入栈(静态变量不入栈);执行函数体;
函数返回过程
恢复寄存器的值(恢复现场);
释放局部变量的栈空间,栈帧指针恢复到上一层函数;
弹出参数和返回值地址;
内存布局与二进制兼容
ABI
C 有稳定的 ABI,C++ 没有。
为保证二进制兼容,跨平台场景一般通过extern "C"导出 C 接口对外提供稳定的 ABI。
ABI 定义了二进制层面的规范:
数据布局:数据类型大小、结构体对齐方式、类成员布局等。
函数调用约定:参数传递方式、栈的使用、返回值处理等。
名称修饰规则:函数符号命名方式(C++ 支持函数重载,编译生成的函数名带有参数类型);
异常处理机制:异常如何传递和处理(C++ 涉及对象自动析构)。
系统调用方式:应用如何与操作系统交互。
结构体内存布局
内存布局,不仅影响存储空间,而且影响访问效率。
自然对齐规则
对齐值 = 默认取最大成员的对齐值;
偏移量 = 成员对齐值的整数倍;
总大小 = 对齐值的整数倍(不足时填充);
alignof()可用于读取对齐值;
如果内存对齐设置不合理,轻则浪费空间,严重的出现非对齐访问(属于 UB,某些情况直接崩溃)。
设置对齐
alignas
标准方式,强制提升对齐(不能降低)。
struct alignas(16) S {
char a;
int b;
};
// 原本最大成员对齐值为 4 字节,强制提升为 16 字节对齐(不能降低为 2 字节)
pack
属于编译器扩展,用于限制最大内存对齐值;
适用场景:严格限制字段填充或保证二进制兼容。
#pragma pack(2)
struct S {
char a; // align=min(1,2)=1
double b; // align=min(8,2)=2,被强制压缩为为 2 字节对齐
};
#pragma pack() // 恢复默认
结构体布局优化
字段排序优化
大多数情况,推荐基于自然对齐规则,通过调整字段顺序,优化内存布局:
struct Data0 {
double d; //占 8 位
int i; //占 4 位
char c; //占 1 位,填充 3 位
}; //sizeof(Data0) = 16;填充在结尾,缓存局部性更优!
struct Data1 {
int i; //占 4 位,填充4
double d; //占 8 位
char c; //占 1 位,填充 7 位
}; //sizeof(Data1) = 24;浪费空间!同样存在内部填充!
struct Data2 {
int i; //占 4 位
char c; //占 1 位,填充 3 位
double d; //占 8 位
}; //sizeof(Data1) = 16;填充在中间,破坏缓存局部性!
字段聚合方式优化
SIMD(Single Instruction Multiple Data)
并行处理多条相同类型数据,而不是同时处理多个不同字段。
AoS (Array of Structures)
[x0, y0, z0, x1, y1, z1, x2, y2, z2, x3, y3, z3]
- 方便操作单个实体;
SoA (Structure of Arrays)
[x0, x1, x2, x3, y0, y1, y2, y3, z0, z1, z2, z3]
- 适合向量化(SIMD);
AoSoA (Array of Structures of Arrays)
- 混合策略:把数据分成小块,每个块内部是 SoA,块与块之间是 AoS;
constexpr auto LEN = 4;
constexpr auto COUNT = 1024;
// SoA:
struct Chunk {
// 位置:每个分量连续存储 4 个值
float x[LEN], y[LEN], z[LEN];
// 颜色:每个通道连续存储 4 个值
unsigned r[LEN], g[LEN], b[LEN], a[LEN];
};
int main() {
// AoSoA:
Chunk chunks[COUNT];
}
查看内存布局
clang++ -Xclang -fdump-record-layouts -stdlib=libc++ -std=c++11 -fsyntax-only AoSoA.cpp
*** Dumping AST Record Layout
0 | struct Chunk
0 | float[4] x
16 | float[4] y
32 | float[4] z
48 | unsigned int[4] r
64 | unsigned int[4] g
80 | unsigned int[4] b
96 | unsigned int[4] a
| [sizeof=112, dsize=112, align=4,
| nvsize=112, nvalign=4]
C++ 对象内存布局
基本规则
如果不含有虚函数,非静态变量起始地址和类对象地址一样;
静态变量和非
const全局变量一样(只是作用域不同):若初始化为非默认值,存储在
.data(已初始化数据)段;若未赋值,或赋予默认值,存储在
.bss(未初始化数据)段,节省磁盘空间,加载时自动清零;成员函数无论是否静态,都存在二进制文件的代码段;
父类的成员变量排在前面,多继承时按继承顺序排,子类成员变量在最后;
菱形继承时,子类会包含两份公共父类的,除非虚继承;
虚函数表与多态
虚函数表的内存布局
每个类维护一张虚函数表
vtable(实际上就是数组,多继承类似二维数组),它存在只读数据段.rodata(OC 也有类似机制:方法列表编译期写入只读的class_ro_t);虚表指针
vptr存在于对象实例中最前面的位置,通过对象指针就能访问虚表,进而调用虚函数;编译时根据类的声明创建虚表,当对象被构造时,将虚表地址写入对象内存起始位置;
派生类的虚函数地址依声明顺序放在第一个基类虚表最后(如果属于
override,则直接替换槽位);访问控制(
public/protected/private)不影响vtable内容,仅编译期限制调用合法性。
下面是模拟数组方式访问虚函数表调用虚函数,实际上存在严重的兼容性问题(32 位/64 位指针差异,不同编译器虚函数表实现差异等),属于 UB。
//单继承:
pFun = (Fun) * ((int*) * (int*)(&obj)); //第一个函数(单继承)
(Fun) * ((int*) * (int*)(&bObj) + 1); //第二个函数
(Fun) * ((int*) * (int*)(&bObj) + 2);//第三个函数
//多继承:
int** pVtab = (int**)& dObj;
pFun = (Fun)pVtab[0][0];//第一个父类的第1个函数
pFun = (Fun)pVtab[0][1];//第一个父类的第2个函数
pFun = (Fun)pVtab[0][2];//第一个父类的第3个函数
当时引入虚函数表的背景
既然虚函数表也是数组,那为啥不直接给每个类新增一个二维函数指针数组成员呢?
自己维护函数指针数组,需考虑 CPU 架构、编译器差异(ABI 兼容)、处理
this指针偏移等各种细节;虚函数表集中存放,更容易命中 CPU cache;分散的函数指针会导致内存访问随机化;
虚函数表存在的问题
内存布局侵入性:
vptr始终占据头部 8 字节,破坏缓存局部性;调用开销:
vptr->vtable-> 最终实现,还要处理this偏移,严重影响指令预测和内联优化;完全基于继承和虚函数的多态,没有
interface概念,多继承/虚继承使上述问题更严重;
CRTP(Curiously recurring template pattern)
避免虚函数 RTTI 开销:
子类中将基类声明为友元,以便基类调用子类私有实现方法;
不用
dynamic_cast(),而用static_cast();
template
class ZBase {
public:
Bytes ZBase ::process() {
static_cast<T *>(this)->processImp();
}
};
class Zip final : public ZBase {
private:
friend class ZBase;
void processImp() {};
};
C++20 Concept 模拟静态多继承
C++ 20 的 concept 能用于定义编译期的行为约束(并且编译报错信息友好),组合多个 concept 可模拟编译期静态多继承:
// 定义接口(行为约束)
template<typename T>
concept DrawableT = requires(T t) {
t.draw();
};
template<typename T>
concept ResizableT = requires(T t, float f) {
t.resize(f);
};
// 直接组合 `concept`
template<typename T>
concept DrawableAndResizableT = DrawableT<T> && ResizableT<T>;
// 使用
template<DrawableAndResizableT T>
void process(T& obj) {
obj.draw();
obj.resize(1.5);
}
单纯就静态继承而言,相比 CRTP 更直观、优雅,但不能动态调用。
proxy:下一代多态框架
template<typename Facade, std::size_t BufferSize = 32>
class proxy {
private:
// 调度表指针(所有 proxy 实例共享同一张表,按具体类型 T 生成)
const vtable_type* vtable_ = nullptr;
// 借鉴了 STL 广泛使用的小对象优化
union {
alignas(std::max_align_t) char buffer_[BufferSize]; // SBO 缓冲区
void* heap_ptr_; // 堆指针(大对象)
};
// 可选的辅助标志:用最低位标记是否在堆上
// 或直接通过模板特化:由 vtable 中的函数隐式知道
};
类型擦除:编译期通过模板捕获类型信息并转换为函数指针;
零侵入:
vptr保存在proxy的控制块,不侵入原有类型内存布局;基于行为:不关注类型(可封装任意数据类型),只关注行为(能做什么),并且可以组合多种行为,更契合现代 OOP 强调组合优于继承的理念;
灵活而高效:直接通过函数指针表动态调用,不依赖继承/虚函数;
更现代:利用
consteval和if constexpr做编译期优化;为何另起炉灶:
二进制兼容:原生虚函数表强耦合内存布局,不会轻易做破坏式更新;
目前已进入标准委员会评议阶段,主要纠结的点是大量使用宏(后续静态反射支持以后会改善);
// 利用 `operator()` 空基类,巧妙实现函数指针的零成本“封装”:
// 编译器 EBO 优化后,没有类型信息,只剩静态函数入口地址,比 `std::function` 更高效;
// 仍使用上面 `concept` 定义的行为规范。
struct DrawableImpl {
void operator()(DrawableT auto& self) const { self.draw(); }
};
struct ResizableImpl {
void operator()(ResizableT auto& self, float f) const { self.resize(f); }
};
//定义外观,注册函数指针
PRO_DEF_FACADE(DrawableFacade, DrawableT);
PRO_DEF_FACADE(ResizableFacade, ResizableT);
// 组合两种外观
using DrawableAndResizableFacade = ::pro::combine_t<DrawableFacade, ResizableFacade>;
// 直接定义具体类型,零侵入(无继承、无虚函数)
struct Circle {
void draw() { std::cout << "Drawing a circle\n"; }
void resize(float f) { std::cout << "Resizing circle by " << f << std::endl; }
};
struct Image {
void draw() { std::cout << "Rendering image\n"; }
void resize(float f) { std::cout << "Scaling image to " << f << std::endl; }
};
int main() {
//统一用 proxy 动态调用,模拟多态
pro::proxy<DrawableAndResizableFacade> cir = Circle{};
pro::proxy<DrawableAndResizableFacade> img = Image{};
cir.draw();
cir.resize(2.0);
img.draw();
img.resize(0.5);
}
影响 class 内存大小的因素
final、override不影响;非静态成员变量;
对齐方式:包含填充位;
继承关系:存储基类非静态成员;
虚函数:新增
sizeof(void*)存储虚表;空类(无成员、无虚函数):
空基类:会被优化为 0 空间;
普通空类:为保证地址唯一,空间为 1 字节;
内存布局兼容
POD(Plain Old Data)
C98/03 笼统地描述为与 C 语言兼容的简单数据类型,不区分平凡类型和标准布局类型(更严格,相当于取交集);
可通过
std::is_pod_v<T>()判断;
标准布局类型
保证内存布局与 C 兼容,可用于与 C 代码交互(如
extern "C"、结构体映射到硬件寄存器等)。
对构造/析构/拷贝/移动操作无限制;
不能有虚函数或虚基类;
所有非静态成员和基类都是 standard-layout;
所有非静态成员具有相同的访问控制(例如全
public);继承相关限制:
继承树中最多一个类有非静态成员:防止数据成员分散在多个层级类;
第一个非静态成员不能是基类类型:避免跟基类内存布局重叠;
可通过
std::is_standard_layout_v<T>()检测;
std::has_unique_object_representations()(C++17)
每个字段在内存中拥有唯一表示:即严格限制内存布局不能有填充位;
常用于序列化/反序列化;
std::is_layout_compatible() (C++20)
两个类型布局兼容(但不代表
sizeof()一样,可能有填充位);相互之间可通过
std::memcpy()拷贝;
调用约定 - 函数调用过程中的内存管理策略
决定参数传递方式和堆栈清理方式,声明在返回值和函数名之间:
int __stdcall func(int a, int b);
__cdel
默认的约定,可不声明;
参数从右到左入栈;
调用方负责清理堆栈;
__stdcall
参数从右到左入栈;
被调用方清理堆栈;
__fastcall
前两个整型或指针参数存在寄存器,其余参数从右到左入栈;
被调用方清理堆栈;
__thiscall
C++ 特有,用于类成员函数,将
this指针作为隐式参数传递;若参数个数确定,类似
__stdcall:this指针存寄存器;被调用方清理堆栈;
若参数个数不确定,类似
__cdel:this指针在其他参数入栈后再最后入栈;
调用方清理堆栈;
拷贝语义
C 只有值语义,
struct赋值就是逐字段赋值(浅拷贝),如果要保证安全,必须手动strcpy()或memcpy();
C++ 引入拷贝语义,主要是为了封装深拷贝逻辑,让任意类型能像基本类型一样优雅地赋值拷贝。
拷贝构造/运算符
拷贝构造函数:针对的是不存在的对象,直接在一行代码里声明对象后直接赋值;
拷贝赋值运算符:针对的是已存在的对象,后续语句再次赋值;
Java 都是引用,赋值几乎没有开销;但 C++ 一句简单赋值就可能引入拷贝开销,对于大对象要格外注意。
T(const T& t); //拷贝构造函数
T& operator=(const T& t); //拷贝复制运算符
T t0{}; //构造
T t1 = t0; //拷贝构造
T t2{};
t2 = t1; //拷贝赋值*
参数是引用类型:
- 防止循环调用;
参数是
const:- 支持传入
const(const_cast()可去掉const,但无法加上const);
- 支持传入
如何安全拷贝
对于单一数值类(或其指针)字段,可以直接拷贝。
对于复杂类型,很多因素(字节填充方式、RTTI 及虚表实现方式等)会影响对象内存布局(本质上涉及 C++ ABI 一致性,不同编译器实现差异);如果不管三七二十一直接拷贝,属于 UB。
可平凡拷贝(Trivially Copyable)
意味着可以安全地用
memcpy()、memmove()复制,而不会破坏语义;
函数调用过程中,可通过寄存器高效传参,否则只能走栈:构造析构涉及到内存分配释放、异常处理等,不能简单通过寄存器传递,无法保证安全性;
要求:
至少有一个拷贝或移动操作(构造/赋值)是平凡的(未被用户自定义);
所有拷贝/移动构造操作要么是平凡的,要么被删除(
delete);析构函数是平凡的(不能是虚析构函数,也不能是用户自定义的非平凡析构);
所有非静态成员和基类也必须是 trivially copyable。
可用于某些底层操作(如序列化、共享内存)。
可通过
std::is_trivially_copyable_v<T>()检查;可平凡拷贝,仅代表可安全地手动拷贝,不代表赋值会被自动优化为按字节拷贝,以下因素会影响:
存在字段填充:拷贝会带上填充位,影响对象唯一化表示(哈希/序列化),且某些ABI规定子类可以利用父类的填充空间;
成员有初始化器:会让默认构造非平凡,影响编译器对类型和唯一表示的判断;
平凡类型(Trivial)
平凡类型可以静态初始化,且生命周期始于存储分配完成时(无需调用构造函数)。
要求:
首先得可平凡拷贝;
默认构造函数也是平凡的;
不能有虚函数或虚基类(虚表内存布局及实现涉及二进制兼容);
基类和非静态成员都必须是平凡类型;
STL 中的容器类型都是非平凡类型;
可通过
std::is_trivial_v<T>()判断;
拷贝相关内置函数
memcpy():
小内存:直接内联为通用寄存器操作(
movl、movq等),避免函数调用开销;大内存:
依赖 SIMD 指令 SSE(movups)、AVX(vmovdqu)等;
使用非临时存储(Non-Temporal Store)指令,如
movntdq(SSE),vmovntdqa(AVX-512):绕过 CPU 缓存,直接写入内存;
避免污染缓存(大块数据很可能不会被立即重用);
不能处理内存重叠,属于未定义行为。
memmove():
- 能正确处理内存重叠。
std::copy():
支持 STL 迭代器;
迭代器有重叠也是未定义行为,建议改用
std::copy_n()。
std::rotate():
- 支持循环移动。
移动语义(C++11)
左值/右值
右值本质上是为移动语义专门引入的概念,使编译器能区分”可安全移动的左值”(xvalue)和普通左值。
expression
├── glvalue (泛左值,有身份,可引用);
│ ├── lvalue (常规左值);
│ └── xvalue (将亡值,如 std::move() 返回值);
└── rvalue (右值,可移动);
├── xvalue(将亡值,既可引用,还能安全移动);
└── prvalue (纯右值,常量/临时对象/普通函数返回值);
移动语义
C 语言参数只有拷贝语义;C++ 加入移动语义是为了避免赋值时隐式调用拷贝构造函数产生的不必要的开销,而右值引用完善了移动构造函数。
若外部传普通引用,在移动构造函数里:
若不将原指针置空,可能因为重复引用导致问题;
若将原指针置空,外部可能不知道,出现非法访问;
强制外部传右值引用,外部就会知道移动后不能再访问。
std::move()
本质只是类型转换,类似
static_cast<T&&>(),告诉编译器可以安全移动,触发移动构造;普通函数返回值、lambda 等本身就是右值,无需 move。
移动构造/运算符
移动构造函数:针对的是不存在的对象,直接在一行代码里声明对象后直接赋值;
移动赋值运算符:针对的是已存在的对象,后续语句再次赋值;对于移动,涉及到转移所有权,所以不能光构造,还要处理原对象;
Decoder::Decoder(Decoder &&other) noexcept : cjson(std::move(other.cjson)) {
}
Decoder &Decoder::operator=(Decoder &&other) noexcept {
if (this != &other) [[likely]] {
this->cjson = std::move(other.cjson);
other.cjson.reset();
}
return *this;
}
T t0{}; //构造
T t1 = std::move(t0); //移动构造
T t2{};
t2 = std::move(t1); //移动赋值
必须 check self;
原对象必须置空,可使用
std::exchange();对于 STL 成员,可直接调用
std::move();必须声明
noexcept,否则也不会被调用。
相关 concept
#define AssertMove(T) \
static_assert(std::is_move_constructible_v<T>, #T " MUST BE MOVE CONSTRUCTIBLE!"); \
static_assert(std::is_move_assignable_v<T>, #T " MUST BE MOVE ASSIGNABLE!"); \
static_assert(std::is_nothrow_move_constructible_v<T>, #T " MUST BE NOTHROW MOVE CONSTRUCTIBLE!"); \
static_assert(std::is_nothrow_move_assignable_v<T>, #T " MUST BE NOTHROW MOVE ASSIGNABLE!")
可重定位语义(C++26)
背景:STL 扩容场景,逐个元素调用拷贝/移动构造的运行时开销太高,可优化为:内存拷贝 + 原地析构。
本质 - 返璞归真:
消除 C++ 各种构造函数抽象的开销,通过更原始的方式模拟实现批量移动;
是对移动语义的特化优化(有限制条件),而移动是更通用的候选项。
没有对应的构造函数和赋值运算符,只需定义类型时加
trivially_relocatable_if_eligible标识,编译器会判断是否真的有资格:首先必须得可平凡复制,因为要
memcpy():- 拷贝/移动/析构都平凡(未定义/默认/为空);
对析构放宽要求:
- 可以不平凡,只要不释放资源,即允许
memcpy()+~T()模拟move。
- 可以不平凡,只要不释放资源,即允许
不能有虚函数;
不能有引用成员;
不能有不可重定位成员;
标准库没有限制一定要用
memcpy(),但事实各家标准库几乎都是这样实现;
拷贝 or 移动?
六大函数
零法则
- 尽量使用 RAII 和标准库容器,让编译器自动生成所有构造/析构函数;
编译器“潜规则”的坑
就算暂时未使用,也要显式声明所有函数 – 因为你无法预见外部对类型的使用方式,而编译器默认行为可能不符合预期。
自动生成的拷贝构造函数,要特别注意:裸指针是简单浅拷贝;
用户显式声明析构函数、拷贝构造函数/运算符:编译器不会生成默认的移动构造函数/运算符,这会显著影响性能;(指定为
default也算显式声明)用户显式声明移动构造函数/运算符:拷贝构造函数/运算符会被编译器声明为
delete,设计初衷是为了避免隐式拷贝,但可能不符合我们的预期;
Guaranteed Copy Elision(C++17)
当使用 prvalue 直接初始化对象时,直接在目标位置构造,不存在中间对象拷贝。
- 函数返回 prvalue;
T foo() { return T{}; }
T t = foo();
- 用 prvalue 初始化变量:
T t1 = T{};
T t2{T{}};
- prvalue 作为参数:
void foo(T t);
foo(T{});
RVO
编译器对于函数调用返回值的优化:直接在调用者的栈帧上构造对象,消除了保存返回值而执行的临时对象构造和拷贝。
返回引用的场景不适用;
如果用
-fno-elide-constructors关闭 RVO,编译器发现局部变量即将离开作用域变成将亡值,会尝试调用移动构造函数;影响 RVO 的场景(不适用或被抑制):
返回
static变量、类成员变量,或直接返回参数;函数内
return std::move(x);函数返回值用于赋值(会触发拷贝赋值运算符),而不是初始化构造;
返回局部引用/指针,这属于 UB(悬垂),跟 RVO 无关;
参数类型选择
数值;
对象值:
优先尝试最优方式(move for rvalue, copy for lvalue),否则退而求其次;
某些情况存在拷贝开销,但也有优势:
统一了左值右值,不用写两套重载;
函数拥有参数的完整副本,可安全存储或修改;
指针:
const限制是否可修改;需通过文档约定所有权,或改为智能指针:
unique_ptr作为参数,表示接管所有权,因为它只能移动;
左值引用:
const可控制是否能修改;如果传右值,则必须有
const修饰:无
const修饰:编译失败(右值无法绑定到非const左值);有
const修饰:绑定到左值,自动延长生命周期到函数作用域,不会触发拷贝/移动;
右值引用:
传右值:触发移动构造;
传左值:编译失败(无法绑定左值到右值引用,必须明确通过
std::move()放弃所有权);
万能引用(
T&&):用于模板函数传参场景;
基于引用折叠规则,可同时兼容左值右值;
内部可通过
std::forward()将模板参数转发给其他模板函数。
内存一致性
多核 CPU 缓存一致性架构模型
Store Buffer
CPU 和 Cache 之间的临时队列,暂存写操作(store)指令;
允许 CPU 快速“完成”写操作,而不必等待数据真正写入 Cache 或主存;
Invalidate Queue
接收其他 CPU 的“无效化请求”(invalidate request);
通知本 CPU 某个缓存行已过期,需要失效;
Interconnect
- 多个 CPU 核心之间的通信通道,负责传递缓存一致性消息。
C++ 内存序
sequential consistent ordering(顺序一致模型)
保证严格的全局 CPU 缓存一致性(任何改动立即同步到所有 CPU 核心),开销大;
对应代码层面的
memory_order_seq_cst;
acquire-release ordering
保证基本的 CPU 各核心之间局部缓存一致性;
对应代码层面的:
memory_order_acquire,常用于读操作(load);memory_order_acq_rel,用于既读又写;memory_order_release,常用于写操作(store);
影响指令重排:任何指令都不会重排到 acquire 之前、 release 之后;
relaxed ordering(宽松模型)
写只操作所在核心的本地缓存,不保证同步;
对应代码层面的
memory_order_relaxed;适合不需要严格多线程同步的场景:数据统计等;
其他模型
对应代码层面的
memory_order_consume;没有 acquire 严格,只要求有依赖关系的指令不能重排到前面;
本意是为了相比 acquire 提升性能,但真要实现很复杂,某些编译器甚至直接当 acquire 处理,可能后面会废弃。
volatile
不同于 Java,C/C++ 的
volatile并没有内存可见性语义。
早期 C/C++ 没有统一的跨平台内存模型,在此背景下诞生;
不严格保证内存可见性和原子性:仅阻止编译期访问相关优化(寄存器缓存、指令重排),不限制运行期 CPU/内存重排,不会插入内存屏障指令;
不是为多线程场景设计的,而是为内存映射等场景设计的。
// 禁止读优化:
volatile int flag = 0;
while (flag == 0) { } // 不能假设 flag 不变而优化成死循环,必须每次从内存读取
// 禁止写优化:
volatile int reg = 0;
reg = 1; // 必须实际写入内存(可能映射到硬件寄存器)
reg = 2; // 不能合并或删除第一次写
// 禁止编译期指令重排:
volatile int a, b;
a = 1;
b = 2; // 不能交换这两条写入顺序
std::atomic
性能最高:lock-free(直接使用 CPU 同步指令),不会卷入内核,没有上下文切换;
只适合简单非耗时任务;
直接当普通变量读写时,默认使用
memory_order_seq_cst;所以尽量使用 load/store。适用范围:
本来只能用于平凡类型;
C++20 提供了
atomic<shared_ptr>``、atomic<weak_ptr>,底层利用指针最低位(LSB)存储锁定标识。没有
unique_ptr版本,因为它强调独占所有权,不存在资源竞争;且移动语义很难跟原子操作兼容。
class spinlock {
private:
std::atomic<bool> flag{false};
public:
void lock() {
while (flag.exchange(true, std::memory_order_acquire));
}
void unlock() {
flag.store(false, std::memory_order_release);
}
}
False-Sharing
CPU 不会读取单个字节,而是读取一个 Cache-Line(通常 64 字节);其中可能包含多个变量。
多线程访问同一缓存行的多个变量,会产生缓存抖动,导致 False-Sharing。
#include <new>
#if defined(__cpp_lib_hardware_interference_size)
static constexpr size_t CACHE_LINE_SIZE = std::hardware_destructive_interference_size;
#else
static constexpr size_t CACHE_LINE_SIZE = 64;
#endif
struct alignas(CACHE_LINE_SIZE) T {
alignas(CACHE_LINE_SIZE) std::atomic x{false};
alignas(CACHE_LINE_SIZE) std::atomic y{false};
};
注意:
使用
hardware_constructive_interference_size不能避免 False-Sharing;只有
struct和class中的atomic成员需要处理,函数中的局部变量不需要处理;
thread_local
数据存储在 TLS 区(一种专属线程的静态存储区):
已初始化的写入
.tdata段;未初始化的写入
.tbss段;生命周期与线程完全一致;
通过专用寄存器(如
FS,GS)和偏移访问,极高效;数据大小有限制,太大会导致线程创建失败;
mimalloc 等第三方库利用
thread_local减少跨线程内存资源的数据竞争。
std::string write(const Json::Value& value) {
/*Json::FastWriter writer;
return writer.write(value);*/
static thread_local Json::StreamWriterBuilder builder;
return Json::writeString(builder, value);
}
未定义行为(UB)
性能:编译器假设 UB 不会发生,从而进行激进优化;(程序不应该依赖 UB)
硬件抽象:不假设运行在何种硬件,某些操作在特定架构才是 UB;
绝大多数 UB 都是内存相关
读取未初始化的内存:
结构体成员未初始化;
malloc()后读取既未置零也未写入的内存单元;
解引用空指针;
通过毫无关联的不同类型指针访问同一内存;
越界类:
有符号整数溢出;
C 字符串操作未以
\0结尾;memcpy()越界等;
use-after-free:
free()/delete()后继续使用;atd::move()后继续使用;
double-free(重复释放);
union写 a 读 b;函数返回局部引用/指针;
修改
const对象或字符串字面量;C 风格可变参数列表,
va_start和va_end不匹配;多线程数据竞争(内存可见性保证);
constexpr 与 UB
constexpr不允许出现 UB,可用于检测部分 UB。
constexpr int* p = nullptr;
constexpr int x = *p; //编译错误!空指针解引用 UB
constexpr int a = INT_MAX;
constexpr int b = a + 1; //编译错误!有符号整数溢出 UB
constexpr int arr[3] = {1, 2, 3};
constexpr int x = arr[5]; //编译错误!数组越界
UB编译器利用 UB “优化”
int funA(int *p) {
*p = 42; // 这里直接解引用赋值,编译器假定 p 不会是空指针(否则 UB)
if (p == NULL) return -1; // 编译器可能直接删除此分支!
return 0;
}
int funB(float *f, int *i) {
*f = 1.0f;
*i = 1;
return *f; //编译器假定 f 和 i 不会指向同一内存(否则 UB),优化为:直接返回编译期常量;
}
bool funC(int x) {
return x + 1 < x; //只有溢出的情况下(UB)才会为真,优化为:直接返回 false
}
可观测性与 as-if rule
可观测性
C++ 标准规定,必须保证可观测行为与抽象机的某种执行结果一致。
可观测行为:I/O、
volatile、系统调用;内部行为:内存布局、指针地址、临时对象数量、寄存器使用等;
异常也不属于“可观测行为”;
一旦有未定义行为,可观测性保证丧失;
as-if rule
只要最终的可观测行为正确,编译器可以做任何变换。
Copy-On-Write
QString 等第三方容器使用 Copy-on-Write(COW)来优化拷贝性能(仅在首次写入时深拷贝,其余情况共享数据)。
而 C++11 起,std::string 禁止 COW 实现,主要是为了保证:
拷贝(构造/赋值)之后,原字符串的指针(如
data())、引用和迭代器在未修改原对象的前提下始终保持有效。
这一要求是 C++ 抽象机语义的一部分,确保程序行为可预测,避免隐式未定义行为。
现实案例
int a[1000];
for (int i = 0; i < 1000; ++i) a[i] = i;
// 编译器可能:
// - 向量化(并行计算)
// - 消除数组(如果后续没用到)
// - 重排循环(如果无副作用)
for (int j = 0; j < 1000; ++j) {
a[j] = j;
std::cout << a[j] << "\n"; // 出现 I/O,必须保证可观测性(顺序输出)
}
STL 内存管理
堆 or 栈?
除了
std::array这种固定容量的,其他绝大多数容器都会动态分配内存;就算容器本身可能存储在栈上(局部变量),内部成员仍然在堆上;
扩容
C++11 开始,扩容会优先移动,无法移动才拷贝,都不行就编译错误;
对于大对象或者创建开销很大的对象,存储指针可降低扩容开销。
扩容会重新动态分配内存,所以元素内存地址要不要缓存而要动态获取!
reserve()
只改变
capacity(避免后续频繁扩容影响性能),但不改变size(未初始化数据);若要拿 STL 的裸指针去写数据,应使用构造函数传入容量保证内存初始化;
resize()
resize(n, x)=clear()/reserve()+assign(n, x);用于清空并初始化;resize(n)不建议用于删除结尾多余元素,并未真正释放空间;
删除元素
std::remove_if() (C++17)
仅仅往前移动元素覆盖要删除的元素,并返回新的迭代器结尾,并未真正释放元素,必须配结合
erase(),否则会内存泄漏;不支持 map/set;
v.erase(std::remove_if(v.begin(), v.end(), {}), v.end());
std::erase_if() (C++20)
结合了
remove_if()+erase(),真正移除元素;针对所有容器都有特化实现;
std::erase_if(v, [](auto &x){});
更安全的类型
unsigned char -> std::byte
C 没有专门的基础类型标识原始字节,所以“数字”、“字节”、“字符”三者语意混为一谈(甚至隐式转换);
C++17 引入的
std::byte专用于表示原始字节:默认只支持位运算,更符合原始字节数据的语义;
算数运算需要显式转换为
int才能进行,更安全;
enum -> enum class
enum本质上是int别名,可以和int自由转换,且会污染全局命名空间;enum class有自己的命名空间,不允许跟int隐式转换。
union -> std::variant
性能接近
union;支持非平凡类型;且支持 RAII,无需手动释放;
index()自动记录了当前活跃的类型,无需额外字段;类型安全:
通过
std::holds_alternative()判断类型;通过
std::get<T>()或std::visit()访问;错误访问会抛异常,而不是 UB;
std::variant<int, std::string> v = "C++";
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << "\n";
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << "\n";
}
}, v);
char* -> std::string
通过
char*构造:始终会拷贝内存,所以原有
char*必须及时free(),否则会内存泄漏;根据重载决议,指针和整型会隐式转换,所以传入数字能编译通过,但会 crash;
重载了跟
const char*的所有比较运算符,所以能直接比较;重载了
+运算符,频繁调用会产生临时对象;push_back()适合插入单字符,append()适合插入字符串;
小字符串优化
内部有一个小的
char数组,直接存储小字符串;对缓存友好;若
capacity大于数组大小,则使用了动态堆内存;
class string {
size_t capacity;
union {
struct {
char *ptr;
size_t size;
} heapbuf;
char stackbuf[sizeof(heapbuf)];
};
};
stringstream
复用 buffer,适合频繁插入各种类型;
如果确定长度,不建议使用,会有动态扩容和 format 开销;
C 静态数组 -> std::array
- 解决 C 数组没有值语义的问题(不能以传值的方式作为函数参数和返回值,也不能直接深拷贝):
array<int, 5> func(array<int, 5> arr) { //array 作为参数不会退化为指针
auto a = arr; //array 赋值可以复制整个数组(内部实现了深拷贝),而内置数组只会拷贝指针
return a; //array 作为返回值不会退化为指针
}
相比内置数组也没有性能损失;
属于 POD(pure old data),没有动态内存分配,包含
std::array的结构体可以直接:用
memset()清零;用
memcpy()拷贝;序列化/反序列化;
C 动态数组 -> std::vector
用于替换 C 的动态数组(本身也并不是标准,只是某些编译器的扩展);
内存连续,可快速随机访问;1.5 倍或 2 倍扩容;
删除元素效率低;
std::vector(size):初始化传入容量,同时更新capacity和size,并初始化数据;内存只增不减,
clear()并不能释放空间,可以使用swap()强制释放旧vector;C++11 提供了shrink_to_fit();push_back():适用于对象已存在的场景;
传右值会调用移动构造函数(避免拷贝和构造);
emplace_back():适用于对象不存在,需要构造的场景;
原地构造(通过完美转发,将参数原封不动传给构造函数),避免拷贝(仍然会构造新对象);
用它直接传递对象,并不能提升性能,因为对象已存在,仍会调用移动/拷贝构造函数;
异构容器
void* -> std::any
提供了一种类型安全、自动内存管理、支持任意类型的值语义容器,但引入一定运行时性能开销。
class any {
void* ptr_; // 指向实际对象
const std::type_info* type_; // 指向 typeid(T),可选
void (*destroy_)(void*); // 析构函数指针
void (*copy_)(const void*, void*); // 拷贝函数指针
};
类型限制:可拷贝、可析构;
本质是类型擦除,有 SOO(小对象优化);
不要通过
typeid()去判断类型(RTTI 有运行时开销):RTTI 本是为了
dynamic_cast()等场景追踪完整的继承树,用于std::any有点“杀鸡用牛刀”;std::any_cast<>()更高效:编译期生成唯一地址作为类型 ID,实现 O(1) 指针比较;
不依赖 RTTI,即使
-fno-rtti关闭 RTTI 也能正常工作;
tuple vs struct
当多个值逻辑相关、生命周期一致、且不值得(或不能)定义新类型时,
tuple是优雅之选。
性能对比:
构造与返回开销:
struct属于聚合初始化,直接在调用方栈帧构造,100% 触发 RVO;tuple需要make_tuple()构造临时对象,不容易 RVO;
成员访问开销:
struct直接根据内存布局偏移完全内联访问成员,性能更高;tuple的get<>()有模板展开开销;
小对象/平凡类型,性能几乎无差异;
tuple特有的:数据打包:
std::make_tuple(): 值语义,通常会拷贝或移动(完美转发);std::tie():引用语义,只接收已有数据的左值引用,不持有;
结合
std::apply()用于延迟/动态调用;std::pair<T, U>本质是std::tuple<T, U>的特化;
轻量级视图容器
std::string_view
视图类型,内部仅包含(指针和长度)两个成员,非常轻量:
作为函数参数,建议直接按值传递,无需
const引用:支持传入所有字符串类型,包括
const char*和std::string;自带长度信息,比
const char*安全;
性能接近
const char*:- 传入
const char*时,不会像const std::string&一样隐式构造临时对象;
- 传入
功能接近
std::string:提供了
std::string几乎所有只读接口;可平凡复制;
注意:
不持有数据,需要外部确保生命周期;
无法调用
c_str()等写内存接口,但可通过data()读取指针;若需长期持有外部传入的内存(赋值给内部),还是应该用
std::string;
std::span (C++20)
比
std::string_view更通用的视图类型,只要求内存连续;C++23 开始,可平凡复制;
可接受:
C 数组;
T*+len;std::string、std::array、std::vector;迭代器范围;
其他 STL容器
理论上,频繁增删节点时链表效率更高;但实际上,链表指针跳转引起的 cache miss 对性能的影响可能更大。
据 C++ 之父之前测试,除非节点数据量大(如超过 1MB)且频繁在中间增删,否则std::vector仍是最优。
折衷的方案:数组作为链表节点、在预分配的大块内存中创建链表等。
链表
- 没有 capacity 的概念,无法通过
reserve()预留空间;
list
双向链表;
适合频繁增删元素,不适合随机访问;
forward_list
- 单向链表;
队列
deque
双向队列;
内存分段连续:
融合了
vector的数组 和list的链表;一个中控数组管理多个分段缓冲区地址信息;
扩容比
vector更平滑:扩容时仅需分配新的中控数组,降低扩容开销;
同样没有 capacity 的概念,无法通过
reserve()预留空间;
任何插入/删除都会导致迭代器失效;
核心成员:
cur:指向当前访问的元素;map_ptr:指向中控数组中当前缓冲区的指针;first/last:指向当前缓冲区的起始/结束地址;
适合频繁首尾增删节点(被
stack/queue采用),O(1) 耗时;中间增删仍然 O(N)。
queue
实际上是个适配器,而不是真正的容器;
底层默认使用
deque,也可指定list或vector:
std::queue<int, std::vector> q;
基于红黑树的有序容器
稳定的时间复杂度 O(logN);
自动排序,依赖比较运算符;
主要是
map、set;
multiset/multimap
- 允许键值重复;
flat_map(C++23)
存储空间连续(类似
vector<pair<K,V>>),缓存命中率高;使用二分查找 O(logN);查找/遍历效率高,插入删除效率低(O(N));
基于哈希的无序容器
不稳定的时间复杂度 O(1) ~ O(N);
无序;
空间成本高;
遍历效率低;
拉链法解决冲突,查询效率依赖 hash 算法;
可自定义 hash 函数;
unordered_map
at():如果不存在会抛异常;operator[]:不存在不会抛异常,会返回默认值;
可以修改,赋值会插入新值;
避免对同一 key 多次调用(会重复计算哈希),应调用
find()拿到iterator再执行后续操作。
emplace():原地构造(避免拷贝),接受右值;
emplace(std::move(k), std::move(v)),无需临时变量pair;
insert(std::pair(std::move(k), std::move(v))),虽接受右值,但需要临时pair;contains()仅适合单纯判断是否存在;若查询后需立即访问,建议用find()(会返回指针);
unordered_set
- 元素唯一,不允许重复;
unordered_multiset/unordered_multimap
- 允许键值重复;
场景相关的内存问题
处理 C 风格字符串
C 风格字符串相关操作一般都要求
\0结尾,否则属于未定义行为;
strlen():通过\0判断结尾;strcpy():逐字符比较直到\0;strcpm():复制直到遇到\0;strdup():strlen()+malloc()+strcpy();
处理 C 风格可变参数
C 风格可变参数实际上是一种运行时参数,编译器无法在编译期检查参数是否匹配,因此必须小心使用。
va_start和va_end要匹配,否则属于 UB;如果要多次遍历或传给其他函数,应该用
va_copy拷贝一份va_list,而不能重复va_start,否则也是不匹配 UB;
va_list args;
va_start(args, msg);
//拷贝一份,用于获取长度
va_list args_copy;
va_copy(args_copy, args);
const auto len = vsnprintf(nullptr, 0, msg, args_copy);
va_end(args_copy);
if (len > 0) {
std::vector<char> buffer(len + 1);
vsnprintf(buffer.data(), buffer.size(), msg, args);
//...
}
va_end(args);
其实对于现代 C++,更安全的的方式是改用编译期可变模板参数:
如果支持 C++20 的
std::format,一行代码即可优雅地处理可变参数格式化;如果不支持,可考虑结合 C++17 的
std::make_tuple和折叠表达式:
#if !defined(USE_STD_FORMAT)
template <typename... Args>
std::string dynxxLogFormatT(std::string_view format, Args&&... args) {
std::ostringstream oss;
auto formatWithArgs = [&oss, &format](auto... xArgs) {
std::string tmpFormat{format};
constexpr auto flag = "{}";
((oss << tmpFormat.substr(0, tmpFormat.find(flag)) << xArgs, tmpFormat.erase(0, tmpFormat.find(flag) + 2)), ...);
oss << tmpFormat;
};
std::apply(formatWithArgs, std::make_tuple(args...));
return oss.str();
}
#endif
template<typename... Args>
void dynxxLogPrintF(DynXXLogLevelX level, std::string_view format, Args&&... args) {
auto fContent =
#if !defined(USE_STD_FORMAT)
dynxxLogFormatT(format, std::forward<Args>(args)...)
#else
std::vformat(std::string{format}, std::make_format_args(args...))
#endif
;
dynxxLogPrint(level, fContent);
}
函数式编程
按值捕获:
只读(
const),最安全(避免悬垂引用);若需修改(包括移动),须加上
mutable关键字。
引用捕获:
不能捕获右值引用;
有悬垂引用风险;
指针捕获:
裸指针要按值捕获;
shared_ptr可用于 lambda 等异步场景避免内存提前释放;
static成员会被隐式捕获;避免直接捕获
this,尽量按需捕获成员;std::function底层是类型擦除,涉及虚函数开销,除非用于存储回调等场景,否则应考虑模板函数。
异常处理:unwinding
异常后,程序会从
throw点开始向上回溯调用栈,直到匹配到catch块,这期间栈上构造的所有对象,都会自动析构,顺序与构造顺序相反。
尽量多利用 RAII 管理资源,这样即使遇到异常一般也能正常释放;
析构函数不要抛异常:否则会打断 unwinding,直接调用
std::terminate();移动构造/运算符应声明
noexcept,否则不会被调用。
线程初始化
std::thread 构造函数:
参数默认会被
decay()转为值类型,因为异步场景易出现悬垂引用;若确实要传引用,需通过
std::ref包装;
template<typename T>
class reference_wrapper {
T* ptr;
public:
reference_wrapper(T& ref) : ptr(&ref) {}
operator T&() const { return *ptr; } //隐式转换
T& get() const { return *ptr; }
};
template<typename T>
reference_wrapper<T> ref(T& t) {
return reference_wrapper<T>(t);
}
平台相关内存管理
JNI 内存
JNI 的世界,GC 不一定管用。
引用
本地引用:
只有返回
jobject的函数才会产生本地引用,MethodID/FieldID均不是;循环或回调等频繁调用场景,本地引用需显式释放;
也可通过
PushLocalFrame()/PopLocalFrame()自动管理;
全局引用:
- 用于延长生命周期,需要在合适的时机手动释放。
static bool ensureLocalCapacity(Thread* self, int capacity) {
int numEntries = self->jniLocalRefTable.capacity();
return ((kJniLocalRefMax - numEntries) >= capacity);
}
bool dvmPushLocalFrame(Thread* self, const Method* method) {
//...
#ifdef USE_INDIRECT_REF
saveBlock->xtra.localRefCookie = self->jniLocalRefTable.segmentState.all;
#else
saveBlock->xtra.localRefCookie = self->jniLocalRefTable.nextEntry;
#endif
//...
return true;
}
static jint PushLocalFrame(JNIEnv* env, jint capacity) {
//...
if (!ensureLocalCapacity(ts.self(), capacity) ||
!dvmPushLocalFrame(ts.self(), dvmGetCurrentJNIMethod())) {
//...
}
return JNI_OK;
}
static jobject PopLocalFrame(JNIEnv* env, jobject jresult) {
//...
if (!dvmPopLocalFrame(ts.self())) {
//...
}
return addLocalReference(ts.self(), result);
}
bool dvmPopLocalFrame(Thread* self) {
//...
dvmPopJniLocals(self, saveBlock);
//...
return true;
}
INLINE void dvmPopJniLocals(Thread* self, StackSaveArea* saveArea) {
self->jniLocalRefTable.segmentState.all = saveArea->xtra.localRefCookie;
}
buffer 类
GetStringUTFChars()、GetIntArrayElements()、GetByteArrayElements()会返回 C 指针,需要通过ReleaseStringUTFChars()、ReleaseXXXArrayElements()显式释放;GetObjectArrayElement()返回的jobject(本地引用),无需手动释放 native 内存。
static jbyte* GetByteArrayElements(JNIEnv* env, jbyteArray array, jboolean* is_copy) {
//...
return GetPrimitiveArray<jbyteArray, jbyte*, ByteArray>(soa, array, is_copy);
}
static jchar* GetCharArrayElements(JNIEnv* env, jcharArray array, jboolean* is_copy) {
//...
return GetPrimitiveArray<jcharArray, jchar*, CharArray>(soa, array, is_copy);
}
static void* GetPrimitiveArrayCritical(JNIEnv* env, jarray java_array, jboolean* is_copy) {
//...
return array->GetRawData(array->GetClass()->GetComponentSize());
}
static jobject GetObjectArrayElement(JNIEnv* env, jobjectArray java_array, jsize index) {
//...
return soa.AddLocalReference<jobject>(array->Get(index));
}
OC 内存
OC/C++ 混编很爽,但内存管理时,他们是两个独立世界:
“上帝的归上帝,凯撒的归凯撒。”
自动释放池
OC 方法一般会隐式创建 AutoReleasePool(block 除外),但 C++ 函数不会:内部如果创建 OC 对象,需要包在 @autoreleasepool{} 代码块。
弱引用
__weak的自动置nil依赖 OC 运行时的dealloc调用:Runtime 会扫描所有
__weak引用,将其置为nil;前提是这些
__weak变量必须位于 Runtime 可追踪的内存中(如 OC 对象实例变量、全局/栈上变量);
C++ 对象的析构与 OC 对象的
dealloc是两个独立生命周期系统:- C++ 成员变量(即使是
__weak id)通常不在 OC Runtime 的弱引用表监控范围内;
- C++ 成员变量(即使是
所以,如果 C++ 对象直接持有 OC __weak 成员,会导致严重内存问题:
weakSelf不会被自动置nil,成为悬垂指针;后续消息发送
EXC_BAD_ACCESS直接 crash。
建议的方案:
.mm 内部匿名
namespace定义NSObject子类持有__weak id;C++ 构造函数利用
id创建 OC 对象,析构函数显式释放 OC 对象(MRC 调用release,ARC 置为nil)。
内存分析工具
静态分析
clang-tidy
LLVM 提供的静态代码扫描工具。
安装方式
VSCode 插件;
安卓和鸿蒙 NDK 均有内置;
iOS/macOS 可通过
brew install llvm安装;
参考配置
规则按 abi、performance、bugprone、cppcoreguidelines、modernize 等分组。
可通过根目录创建 .clang-tidy 文件自定义配置,通过 -FooGroup-BarConfig 禁用某条子规则。
Checks:
-*,
performance-*,
abi-*,
cert-*,
concurrency-*,
android-*,
objc-*,
bugprone-*,
-bugprone-easily-swappable-parameters,
cppcoreguidelines-*,
-cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-prefer-member-initializer,
-cppcoreguidelines-avoid-non-const-global-variables,
-cppcoreguidelines-pro-type-vararg,
-cppcoreguidelines-pro-type-reinterpret-cast,
-cppcoreguidelines-pro-bounds-pointer-arithmetic,
-cppcoreguidelines-pro-bounds-constant-array-index,
-cppcoreguidelines-pro-bounds-array-to-pointer-decay,
-cppcoreguidelines-non-private-member-variables-in-classes,
modernize-*,
-modernize-use-trailing-return-type,
llvm-*,
-llvm-qualified-auto,
-llvm-namespace-comment,
-llvm-include-order,
readability-*,
-readability-qualified-auto,
-readability-identifier-length,
-readability-math-missing-parentheses,
-readability-convert-member-functions-to-static,
google-*,
-google-build-using-namespace,
-google-readability-namespace-comments,
misc-*,
-misc-non-private-member-variables-in-classes,
-misc-use-internal-linkage,
-misc-use-anonymous-namespace,
FormatStyle: llvm
手动忽略
如果某些地方需要手动忽略,可在注释中使用 NOLINT 相关声明:
class Foo {
// Suppress all the diagnostics for the line
Foo(int param); // NOLINT
// Consider explaining the motivation to suppress the warning
Foo(char param); // NOLINT: Allow implicit conversion from `char`, because <some valid reason>
// Silence only the specified checks for the line
Foo(double param); // NOLINT(google-explicit-constructor, google-runtime-int)
// Silence all checks from the `google` module
Foo(bool param); // NOLINT(google*)
// Silence all checks ending with `-avoid-c-arrays`
int array[10]; // NOLINT(*-avoid-c-arrays)
// Silence only the specified diagnostics for the next line
// NOLINTNEXTLINE(google-explicit-constructor, google-runtime-int)
Foo(bool param);
// Silence all checks from the `google` module for the next line
// NOLINTNEXTLINE(google*)
Foo(bool param);
// Silence all checks ending with `-avoid-c-arrays` for the next line
// NOLINTNEXTLINE(*-avoid-c-arrays)
int array[10];
// Silence only the specified checks for all lines between the BEGIN and END
// NOLINTBEGIN(google-explicit-constructor, google-runtime-int)
Foo(short param);
Foo(long param);
// NOLINTEND(google-explicit-constructor, google-runtime-int)
// Silence all checks from the `google` module for all lines between the BEGIN and END
// NOLINTBEGIN(google*)
Foo(bool param);
// NOLINTEND(google*)
// Silence all checks ending with `-avoid-c-arrays` for all lines between the BEGIN and END
// NOLINTBEGIN(*-avoid-c-arrays)
int array[10];
// NOLINTEND(*-avoid-c-arrays)
};
动态分析
Sanitizers
最初由 Google 开源的 Sanitizers 套件,现已成为 LLVM 项目的一部分,各家编译器也做了深度支持。
可用于运行时动态检测内存问题、资源竞争、UB 等,可直接在 CMakeLists.txt 配置。
不过要注意:
各平台编译器支持情况有差异;
有一定开销,建议仅在 debug 构建打开。
set(SANITIZERS "")
if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
if(ANDROID AND "${CMAKE_ANDROID_ARCH_ABI}" STREQUAL "arm64-v8a")
list(APPEND SANITIZERS "hwaddress")
else()
list(APPEND SANITIZERS "address")
endif()
if(NOT WIN32)
list(APPEND SANITIZERS "undefined")
endif()
if(NOT WIN32 AND NOT EMSCRIPTEN AND NOT ANDROID AND NOT OHOS AND NOT APPLE)
list(APPEND SANITIZERS "leak")
endif()
# TSan is mutually exclusive with ASan and LSan.
# Enable it only if you strictly need to check for race conditions.
if(NOT WIN32 AND NOT EMSCRIPTEN AND NOT ANDROID AND NOT OHOS)
# list(APPEND SANITIZERS "thread")
endif()
if(SANITIZERS)
list(JOIN SANITIZERS "," SANITIZERS_STR)
if(MSVC)
set(SANITIZER_FLAGS "/fsanitize=${SANITIZERS_STR}")
else()
set(SANITIZER_FLAGS "-fsanitize=${SANITIZERS_STR}")
endif()
set_target_properties(${PROJECT_NAME} PROPERTIES
COMPILE_FLAGS "${SANITIZER_FLAGS} -fno-omit-frame-pointer -g"
LINK_FLAGS "${SANITIZER_FLAGS}"
)
endif()
endif()
基于软件的 AdSan
为每个字节分配一个“影子字节”,记录该地址是否可访问(如是否已分配、是否在有效范围内);
编译时插入额外检查代码,每次内存访问前,检查影子内存状态,若非法则立即中止并报告错误。
Android 的额外配置
依赖 NDK 目录的动态链接库(libclang_rt.asan-$abi-android.so),需拷贝到 jniLibs/$abi 目录;
需要接管整个进程内存,并且在所有 so 加载之前,需要在 resources/lib/$abi 目录 通过 wrap.sh 脚本手动加载上面的 so:
#!/system/bin/sh
LD_PRELOAD=libclang_rt.asan-aarch64-android.so exec "$@"
shell 脚本需确保拥有可执行权限,并且注意 Windows 编辑器下可能出现不兼容的换行符(会影响脚本执行);
AdSan 在 Android 上依赖帧指针,所以必须加上
-fno-omit-frame-pointer链接参数,确保错误报告可读。
Android 基于硬件的 AdSan
利用硬件特性(主要是 ARM64 的 Top Byte Ignore, TBI):
在 64 位指针的高 8 位(即“tag”)存储一个随机值;
分配内存时,同时为内存块分配一个 tag,并存储在影子内存中;
每次内存访问时,比较指针 tag 与目标地址影子内存中的 tag,不匹配则报错;
tag 针对的是 16 字节对齐的内存块,并且不需要全局唯一,只需要在当前所有活跃的内存块保持唯一,因此冲突概率并不高。
仍使用影子内存,但粒度更粗(通常为 16 字节对齐),因此内存开销更低。
仅支持具备 TBI 能力的平台:主要是 ARM64;
不依赖动态链接库,也无需 hook 启动流程。
XCode 启用 Sanitizers
XCode 深度集成 Sanitizer 相关工具链,只需 Build Settings 勾选对应的 Sanitizer,Instruments 可直接查看报告。
鸿蒙端 Sanitizers 支持
鸿蒙及 DevEco Studio 同样深度集成 Sanitizers,并且不需要任何额外配置(只需要 CMakeLists.txt 添加配置并且确保 debug 构建)。