基于现代 C++ 的跨平台开发 - 内存篇

从 12 年接触 Android JNI 开发,到 17 年接触 iOS ObjC/C++ 混合开发,再到 24 年真正从零搭建完整的跨所有主流平台的 C++ 项目,再加上今年底进入某老牌软件大厂见识跨越 20 多年的庞大 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() 安全,尽量避免;

  • 整数和指针间的类型转换;

  • 可以加上 constvolatile,但不能去掉。

  • 不能用于将 nullptr 转换为其他类型,只能用 static_cast() 转;

const_cast()

  • 仅用于指针或引用;

  • 仅用于去掉 const;(加上不需要 cast)

const_cast()

  • 如果需要多次 cast,一般先用其他 cast,最后 const_cast();

  • 运行时转换,不安全(可能导致未定义行为);

std::decay_t()

  • 将数组转为指针(退化,符合字面意义);

  • 移除引用;

  • 移除基本类型的 const/volatile

  • 对于指针,只能移除顶层 const,即 const T const* -> const T *

std::add_const()

  • 编译时行为,比 const_cast() 安全高效;

  • 常用于模板元编程,确保类型是 const

重载决议 - 隐式类型转换优先级

精确匹配

  • 类型完全相同;

  • 加上 const/volatile

  • 数组 -> 指针;

  • std::function -> 指针;

类型提升

  • bool/char/short -> int

  • float -> double

标准转换

  • int -> long/float

  • double -> float

  • Derived* -> Base*

  • 指针/数字 -> bool

用户定义的转换

  • 重载构造函数,如 stringconst 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:
    virtualtype_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;
}

静态多态 - 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() {};
};

内存模型

  • 栈是向下生长的(高地址在上);

  • 函数内局部变量;

  • 效率高:存在寄存器,且有 push/pop 指令;

  • 容量小:每个线程都有固定大小的栈空间;

  • 函数调用前后,由系统自动分配和释放;

堆(自由存储区)

  • 堆是向上生长的(低地址在上);

  • 需手动释放;

  • 几乎无空间限制;

  • 容易产生碎片;

全局/静态区

static

  • 作用于全局变量/函数:

    • 改变链接属性:只能在模块内使用,外部无法通过 extern 访问;

    • 既能实现封装性,也能避免符号冲突;

    • C 语言中,静态变量在代码执行前就会初始化,不能用变量赋值;

    • C++ 更推荐使用匿名 namespace 实现隐藏内部符号;

  • 作用于函数内局部变量:

    • 改变存储属性:保存在静态区的(.data.bss 段),不会随着函数执行完销毁;

    • C++ 中:

      • 静态局部变量在首次调用时初始化;

      • C++11 开始,编译器保证线程安全,可用于实现单例;

  • 作用于 C++ 类:

    • 静态成员变量:存在静态区;

    • 静态成员函数:不依赖 this 指针,通过类名直接访问,仅能访问静态成员;

常量区

const

  • 强调运行期不可修改;

  • 从右向左读( * 读成 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 更严格,强制编译期可求值,否则编译错误。

变量不初始化的风险

  • 对于栈上的对象,可能出现垃圾值:

    • 因为栈内存是被反复利用(覆盖)的,操作系统考虑到性能并不会频繁释放内存;
  • 对于堆上的对象,操作系统会清空内存,造成已经帮我们初始化的假象,仍然会有陷阱。

内存管理

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

RAII

构造函数申请资源,析构函数释放资源。
栈上对象离开作用域会自动调析构释放,堆上需要显式释放。

  • 尽量创建栈对象(避免 new/delete 管理堆内存),离开作用域自动释放;

  • char*、数组等可改为 std::stringstd::vectorstd::array 等 STL 封装类型(内部有 SOO 小对象优化,按需自动管理堆内存);

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

OOP 内存管理

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

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

  • 子类构造函数体执行;

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

析构
  • 与构造顺序正好相反;

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

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

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

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

拷贝语义

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

    • 支持传入 constconst_cast() 可去掉 const,但无法加上 const);

如何安全拷贝

对于单一数值类(或其指针)字段,可以直接拷贝。
对于复杂类型,很多因素(字节填充方式、RTTI 及虚表实现方式等)会影响对象内存布局(本质上涉及 C++ ABI 一致性,不同编译器实现差异);如果不管三七二十一直接拷贝,属于 UB。

内存布局

查看内存布局
clang++ -Xclang -fdump-record-layouts -stdlib=libc++ -std=c++11 -fsyntax-only xxx.cpp
内存布局规则
  • 如果不含有虚函数,非静态变量起始地址和类对象地址一样;

  • 静态变量和非 const 全局变量一样(只是作用域不同):

  • 若初始化为非默认值,存储在 .data (已初始化数据)段;

  • 若未赋值,或赋予默认值,存储在 .bss (未初始化数据)段,节省磁盘空间,加载时自动清零;

  • 成员函数无论是否静态,都存在二进制文件的代码段;

  • 父类的成员变量排在前面,多继承时按继承顺序排,子类成员变量在最后;

  • 菱形继承时,子类会包含两份公共父类的,除非虚继承;

影响 class 内存大小的因素
  • finaloverride 不影响;

  • 非静态成员变量;

  • 对齐方式:包含填充位;

  • 继承关系:存储基类非静态成员;

  • 虚函数:新增 sizeof(void*) 存储虚表;

  • 空类(无成员、无虚函数):

    • 空基类:会被优化为 0 空间;

    • 普通空类:为保证地址唯一,空间为 1 字节;

内存对齐

编译器为 struct 的每个成员按其自然边界分配空间,各成员按被声明顺序在内存中顺序存储:

  • 第一个成员的地址和整个结构的地址相同;

  • 最后结构体的内存大小必须是结构体中最大成员内存大小的整数倍;

  • 便于 CPU 快速访问,节省存储空间;

struct Data { 
    char c; //char 1 字节
    double d;//double 8 字节
    int i; //int 4 字节
} //为保证字节对齐,首个字段要填充
pack 设置内存对齐

如果内存对齐设置不合理,轻则浪费空间,严重的出现非对齐访问(属于 UB,某些情况直接崩溃)。
不同编译器对 pack 的实现细节可能有差异,不适合跨平台场景;
它破坏了类型系统的自然对齐契约。

#pragma pack(push) // 保存对齐状态
#pragma pack(4) // 设为 4 字节对齐
struct Data {
    char c;
    double d; //double 需要 8 字节!
    int i;
};
#pragma pack(pop) // 恢复默认对齐状态
_Static_assert(alignof(Data) == 8, "ABI alignment mismatch");
优化字段排序

推荐通过调整字段顺序,优化内存布局:

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;填充在中间,破坏缓存局部性!

可平凡拷贝(Trivially Copyable)

意味着可以安全地用 memcpy()memmove() 复制,而不会破坏语义;
函数调用过程中,可通过寄存器高效传参,否则只能走栈:构造析构涉及到内存分配释放、异常处理等,不能简单通过寄存器传递,无法保证安全性;

  • 要求:

    • 至少有一个拷贝或移动操作(构造/赋值)是平凡的(未被用户自定义);

    • 所有拷贝/移动构造操作要么是平凡的,要么被删除(delete);

    • 析构函数是平凡的(不能是虚析构函数,也不能是用户自定义的非平凡析构);

    • 所有非静态成员和基类也必须是 trivially copyable。

  • 可用于某些底层操作(如序列化、共享内存)。

  • 可通过 std::is_trivially_copyable_v<T>() 检查;

  • 可平凡拷贝,仅代表可安全地手动拷贝,不代表赋值会被自动优化为按字节拷贝,以下因素会影响:

    • 存在字段填充:拷贝会带上填充位,影响对象唯一化表示(哈希/序列化),且某些ABI规定子类可以利用父类的填充空间;

    • 成员有初始化器:会让默认构造非平凡,影响编译器对类型和唯一表示的判断;

其他相关类型划分

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>() 检测;

平凡类型(Trivial)

平凡类型可以静态初始化,且生命周期始于存储分配完成时(无需调用构造函数)。

  • 要求:

    • 首先得可平凡拷贝;

    • 默认构造函数也是平凡的;

    • 不能有虚函数或虚基类(虚表内存布局及实现涉及二进制兼容);

    • 基类和非静态成员都必须是平凡类型;

  • STL 中的容器类型都是非平凡类型;

  • 可通过 std::is_trivial_v<T>() 判断;

std::is_layout_compatible()
  • 两个类型布局兼容(但不代表 sizeof() 一样,可能有填充位);

  • 相互之间可通过 std::memcpy() 拷贝 ;

std::has_unique_object_representations()
  • 每个字段在内存中拥有唯一表示:即严格限制内存布局不能有填充位;

  • 常用于序列化/反序列化;

  • 仍然不代表赋值会被自动优化为按字节拷贝。

拷贝相关内置函数

memcpy():
  • 效率高,不仅会优化为 SIMD 指令,甚至可能会开启 DMA;

  • 不能处理内存重叠,属于未定义行为。

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 限制是否可修改;

    • 需通过文档约定所有权,或改为智能指针;

  • 左值引用:

    • const 可控制是否能修改;

    • 如果传右值,则必须有 const 修饰:

      • const 修饰:编译失败(右值无法绑定到非 const 左值);

      • const 修饰:绑定到左值,自动延长生命周期到函数作用域,不会触发拷贝/移动;

  • 右值引用:

    • 传右值:触发移动构造;

    • 传左值:编译失败(无法绑定左值到右值引用,必须明确通过 std::move() 放弃所有权);

  • 万能引用(T&&):

    • 用于模板函数传参场景;

    • 基于引用折叠规则,可同时兼容左值右值;

    • 内部可通过 std::forward() 将模板参数转发给其他模板函数。

智能指针

仅限于管理堆内存,某些局部内存指针(如 JNIEnv*)不能用智能指针;
内存所有权要清晰,智能指针不是 GC/ARC。

auto_ptr

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

unique_ptr

  • 独占所有权,只能移动不能拷贝;

  • 真正的零成本开销,适合绝大多数场景替换裸指针:

    • 基于模板的编译期 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 不属于类型的一部分;

  • 占用两个指针空间:指针对象、控制块(引用计数、弱引用数,deleterallocator);

  • 控制块有开销,C++ 类型尽量用 make_shared() 避免两次内存分配(同时创建对象和控制块);

  • 真正原子化的只有引用计数更新,成员访问需要自己处理线程安全(C++20 引入了 std::atomic<std::shared_ptr<T>>);

  • 如果要拿到 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)
    // 无需手动清理
}

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

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):初始化传入容量,同时更新 capacitysize,并初始化数据;

  • 内存只增不减,clear() 并不能释放空间,可以使用 swap() 强制释放旧 vector;C++11 提供了 shrink_to_fit();

  • push_back()

    • 适用于对象已存在的场景;

    • 传右值会调用移动构造函数(避免拷贝和构造);

  • emplace_back()

    • 适用于对象不存在,需要构造的场景;

    • 原地构造(通过完美转发,将参数原封不动传给构造函数),避免拷贝(仍然会构造新对象);

    • 用它直接传递对象,并不能提升性能,因为对象已存在,仍会调用移动/拷贝构造函数;

轻量级视图类型

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

  • std::string_view 更通用的视图类型,只要求内存连续;

  • 可平凡复制;

  • 可接受:

    • C 数组;

    • T* + len

    • std::stringstd::arraystd::vector;

    • 迭代器范围;

其他 STL容器

链表

  • 没有 capacity 的概念,无法通过 reserve() 预留空间;
list
  • 双向链表;

  • 适合频繁增删元素,不适合随机访问;

forward_list
  • 单向链表;

队列

deque
  • 双向队列;

  • 特殊的存储结构:

    • 融合了 vector 的数组 和 list 的链表;

    • 一个中控数组管理多个分段缓冲区地址信息;

  • 关于扩容:

    • 扩容时仅需分配新的中控数组,降低扩容开销;

    • 同样没有 capacity 的概念,无法通过 reserve() 预留空间;

  • 任何插入/删除都会导致迭代器失效;

  • 核心成员:

    • cur:指向当前访问的元素;

    • map_ptr:指向中控数组中当前缓冲区的指针;

    • first/last:指向当前缓冲区的起始/结束地址;

  • 适合频繁双向增删节点(被 stack/queue 采用),O(1) 耗时;

queue
  • 实际上是个适配器,而不是真正的容器;

  • 底层默认使用 deque,也可指定 listvector

std::queue<int, std::vector> q;

基于红黑树的有序容器

  • 稳定的时间复杂度 O(logN);

  • 自动排序,依赖比较运算符;

  • 主要是 mapset

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
  • 允许键值重复;

其他

tuple vs struct
  • 构造与返回开销:

    • struct 属于聚合初始化,直接在调用方栈帧构造,100% 触发 RVO;

    • tuple 需要 make_tuple() 构造临时对象,不容易 RVO;

  • 成员访问开销:

    • struct 直接根据内存布局偏移完全内联访问成员,性能更高;

    • tupleget<>() 有模板展开开销;

  • tuple 能结合可变参数模版批量处理数据;

  • 小对象/平凡类型,性能几乎无差异;

std::any
  • 本质是类型擦除,有 SOO(小对象优化);

  • 不要通过 typeid() 去判断类型(RTTI 有运行时开销):

    • RTTI 本是为了 dynamic_cast() 等场景追踪完整的继承树,用于 std::any 有点”杀鸡用牛刀“;
class any {
    void* ptr_;                     // 指向实际对象
    const std::type_info* type_;    // 指向 typeid(T) —— 可选
    void (*destroy_)(void*);        // 析构函数指针
    void (*copy_)(const void*, void*); // 拷贝函数指针
};
  • std::any_cast<>() 更高效:

    • 编译期生成唯一地址作为类型 ID,实现 O(1) 指针比较;

    • 不依赖 RTTI,即使 -fno-rtti 关闭 RTTI 也能正常工作;

stringstream
  • 复用 buffer,适合频繁插入各种类型;

  • 如果确定长度,不建议使用,会有动态扩容和 format 开销;

内存一致性

多核 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/内存重排,不会插入内存屏障指令;

  • 不是为多线程场景设计的,而是为内存映射等场景设计的。

std::atomic

  • 性能最高:lock-free(直接使用 CPU 同步指令),不会卷入内核,没有上下文切换;

  • 只适合简单非耗时任务;

  • 直接当普通变量读写时,默认使用 memory_order_seq_cst ;所以尽量使用 load/store。

  • 适用范围:

    • 本来只能用于平凡类型;

    • C++20 提供了 atomic<shared_ptr>``、atomic<weak_ptr>,底层利用指针最低位(LSB)存储锁定标识。

    • 没有 unique_ptr 版本,因为它强调独占所有权,不存在资源竞争;且移动语义很难跟原子操作兼容。

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;

  • 只有 structclass 中的 atomic 成员需要处理,函数中的局部变量不需要处理;

thread_local

  • 数据存储在 TLS 区(一种专属线程的静态存储区):

  • 已初始化的写入 .tdata 段;

  • 未初始化的写入 .tbss 段;

  • 生命周期与线程完全一致;

  • 通过专用寄存器(如 FS, GS)和偏移访问,极高效;

  • 数据大小有限制,太大会导致线程创建失败;

  • mimalloc 等第三方库利用 thread_local 减少跨线程内存资源的数据竞争。

未定义行为(UB)

性能:编译器假设 UB 不会发生,从而进行激进优化;(程序不应该依赖 UB)
硬件抽象:不假设运行在何种硬件,某些操作在特定架构才是 UB;

绝大多数 UB 都是内存相关

  • 读取未初始化的内存:

    • 结构体成员未初始化;

    • malloc() 后读取既未置零也未写入的内存单元;

  • 解引用空指针;

  • 通过毫无关联的不同类型指针访问同一内存;

  • 越界类:

    • 有符号整数溢出;

    • C 字符串操作未以 \0 结尾;

    • memcpy() 越界等;

  • use-after-free:

    • free()/delete() 后继续使用;

    • atd::move() 后继续使用;

  • double-free(重复释放);

  • union 写 a 读 b;

  • 函数返回局部引用/指针;

  • 修改 const 对象或字符串字面量;

  • C 风格可变参数列表,va_startva_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
}

场景相关的内存问题

处理 C 风格可变参数

  • va_startva_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 风格字符串相关操作一般都要求 \0 结尾,否则属于未定义行为;

  • strlen():通过 \0 判断结尾;

  • strcpy():逐字符比较直到 \0

  • strcpm():复制直到遇到 \0

  • strdup()strlen() + malloc() + strcpy()

函数式编程

  • 按值捕获:

    • 只读(const),最安全(避免悬垂引用);

    • 若需修改(包括移动),须加上 mutable 关键字。

  • 引用捕获:

    • 不能捕获右值引用;

    • 有悬垂引用风险;

  • 指针捕获:

    • 裸指针要按值捕获;

    • shared_ptr 可用于 lambda 等异步场景避免内存提前释放;

  • static 成员会被隐式捕获;

  • 避免直接捕获 this,尽量按需捕获成员;

  • std::function 底层是类型擦除,涉及 RTTI 开销,除非用于存储回调等场景,否则应考虑模板函数。

异常处理: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++ 对象直接持有 OC __weak 成员,会导致严重内存问题:

  • weakSelf 不会被自动置 nil,成为悬垂指针;

  • 后续消息发送 EXC_BAD_ACCESS 直接 crash。

建议的方案:

  • .mm 内部匿名 namespace 定义 NSObject 子类持有 __weak id

  • C++ 构造函数利用 id 创建 OC 对象,析构函数显式释放 OC 对象(MRC 调用 release,ARC 置为 nil)。

内存分析工具

静态分析

clang-tidy

动态分析

AddressSanitizer

UndefinedBehaviorSanitizer