
🔥 个人主页: flos chen
🌟 边学习,边记录,一起学习进步!


文章目录
-
- 前言
- [1. 智能指针](#1. 智能指针)
-
- [1.1 std::unique_ptr(C++11)](#1.1 std::unique_ptr(C++11))
- [1.2 std::shared_ptr 与 std::weak_ptr(C++11)](#1.2 std::shared_ptr 与 std::weak_ptr(C++11))
- [1.3 std::unique_ptr 与多态(C++14 改进)](#1.3 std::unique_ptr 与多态(C++14 改进))
- [2. std::optional](#2. std::optional)
-
- [2.1 问题:用指针表示"可能为空"](#2.1 问题:用指针表示"可能为空")
- [2.2 解决:值语义的"可能为空"](#2.2 解决:值语义的"可能为空")
- [2.3 内存布局对比](#2.3 内存布局对比)
- [3. std::variant](#3. std::variant)
-
- [3.1 替代虚函数多态](#3.1 替代虚函数多态)
- [3.2 值语义多态](#3.2 值语义多态)
- [3.3 内存对比](#3.3 内存对比)
- [4. 小缓冲区优化](#4. 小缓冲区优化)
-
- [4.1 SSO(Small String Optimization)](#4.1 SSO(Small String Optimization))
- [4.2 SBO(Small Buffer Optimization)在容器中的应用](#4.2 SBO(Small Buffer Optimization)在容器中的应用)
- [4.3 自定义小缓冲区容器](#4.3 自定义小缓冲区容器)
- [5. C++17 多态分配器(PMR)](#5. C++17 多态分配器(PMR))
-
- [5.1 问题](#5.1 问题)
- [5.2 PMR 解决方案](#5.2 PMR 解决方案)
- [5.3 分配器性能对比](#5.3 分配器性能对比)
- [6. 内存对齐与 alignas](#6. 内存对齐与 alignas)
-
- [6.1 为什么对齐重要](#6.1 为什么对齐重要)
- [6.2 alignas(C++11)](#6.2 alignas(C++11))
- [6.3 对齐分配(C++17)](#6.3 对齐分配(C++17))
- [6.4 std::hardware_destructive_interference_size(C++17)](#6.4 std::hardware_destructive_interference_size(C++17))
- [7. std::span 与零拷贝视图](#7. std::span 与零拷贝视图)
-
- [7.1 避免容器拷贝](#7.1 避免容器拷贝)
- [7.2 内存收益](#7.2 内存收益)
- [8. C++20 协程帧分配优化](#8. C++20 协程帧分配优化)
-
- [8.1 问题](#8.1 问题)
- [8.2 优化:HALO(Heap Allocation eLision Optimization)](#8.2 优化:HALO(Heap Allocation eLision Optimization))
- [8.3 自定义分配器](#8.3 自定义分配器)
- [9. C++23 std::mdspan](#9. C++23 std::mdspan)
-
- [9.1 多维数组的零拷贝视图](#9.1 多维数组的零拷贝视图)
- [9.2 内存优势](#9.2 内存优势)
- [10. 编译器层面的内存优化](#10. 编译器层面的内存优化)
-
- [10.1 拷贝消除(C++17 保证)](#10.1 拷贝消除(C++17 保证))
- [10.2 \[no_unique_address](C++20)](#10.2 [[no_unique_address]](C++20))
- [10.3 尾调用优化与栈帧复用](#10.3 尾调用优化与栈帧复用)
- [11. 总结与最佳实践](#11. 总结与最佳实践)
- 参考资料
前言
内存是 C++ 程序员永远绕不开的话题。从 C++11 到 C++23,标准委员会持续引入了一系列旨在减少内存分配次数、降低内存占用、提升缓存友好性的新特性。本文将从底层原理出发,系统梳理这些内存优化利器,并给出实际项目中的最佳实践。
适用读者:有 C++ 基础,关注内存效率、性能调优的中高级开发者。
1. 智能指针
1.1 std::unique_ptr(C++11)
unique_ptr 是零开销的独占所有权指针,大小与裸指针相同(8 字节),析构时自动释放内存。
cpp
#include <memory>
struct Node {
int value;
std::unique_ptr<Node> next; // 链表节点,自动递归释放
Node(int v) : value(v) {}
};
int main() {
// 工厂函数返回 unique_ptr,调用者获得所有权
auto head = std::make_unique<Node>(42);
head->next = std::make_unique<Node>(43);
// 离开作用域自动释放,无需手动 delete
// 内存泄漏风险降为零
}
内存开销分析:
| 指针类型 | 大小(64位) | 额外开销 |
|---|---|---|
T* |
8 bytes | 无 |
std::unique_ptr<T> |
8 bytes | 无(空删除器时) |
std::unique_ptr<T, Deleter> |
8 + sizeof(Deleter) | 取决于删除器 |
std::shared_ptr<T> |
16 bytes | 控制块(~32-48 bytes) |
1.2 std::shared_ptr 与 std::weak_ptr(C++11)
cpp
#include <memory>
struct Observer;
struct Subject {
std::vector<std::weak_ptr<Observer>> observers; // 弱引用,不增加引用计数
void notify() {
for (auto it = observers.begin(); it != observers.end(); ) {
if (auto obs = it->lock()) { // 尝试提升为 shared_ptr
// 通知观察者
++it;
} else {
it = observers.erase(it); // 已失效,清理
}
}
}
};
内存优化要点:
weak_ptr不增加引用计数,避免循环引用导致的内存泄漏make_shared将对象和控制块合并为一次分配(减少内存碎片)
cpp
// ❌ 两次分配:一次对象,一次控制块
std::shared_ptr<Widget> p1(new Widget());
// ✅ 一次分配:对象和控制块在同一块内存
auto p2 = std::make_shared<Widget>();
1.3 std::unique_ptr 与多态(C++14 改进)
cpp
#include <memory>
#include <vector>
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};
struct Circle : Shape {
double radius;
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
struct Rectangle : Shape {
double w, h;
Rectangle(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
}
int main() {
// C++14: make_unique 简化创建
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
double total = 0;
for (const auto& s : shapes) {
total += s->area();
}
}
2. std::optional
2.1 问题:用指针表示"可能为空"
cpp
// ❌ 旧方式:堆分配 + 指针语义
struct Config {
std::string* description; // 可能为 nullptr
Config() : description(nullptr) {}
~Config() { delete description; }
};
2.2 解决:值语义的"可能为空"
cpp
#include <optional>
#include <string>
struct Config {
std::optional<std::string> description; // 栈上存储,无堆分配
void set_desc(std::string_view sv) {
description = std::string(sv); // 有值
}
void clear_desc() {
description.reset(); // 无值,释放内部 string 的堆内存
}
};
2.3 内存布局对比
// std::optional<std::string> 的内存布局(典型实现):
// [bool has_value | padding | std::string 对象(通常32字节)]
// 总计约 40 字节,全部在栈上
// 对比 std::string* 方案:
// 栈上 8 字节指针 + 堆上 32 字节 string 对象 + 可能的字符数据
// 至少 2 次分配,缓存不友好
性能收益:
- 消除一次堆分配(
new/delete) - 数据局部性更好(值在栈上,缓存友好)
- 无空指针解引用风险
3. std::variant
3.1 替代虚函数多态
传统多态需要堆分配 + 虚函数表指针:
cpp
// ❌ 传统多态:每个对象至少 8 字节 vptr + 堆分配
struct Base { virtual void draw() = 0; virtual ~Base() = default; };
struct Circle : Base { void draw() override {} };
struct Square : Base { void draw() override {} };
std::vector<std::unique_ptr<Base>> shapes; // 每个元素一次堆分配
3.2 值语义多态
cpp
#include <variant>
#include <vector>
struct Circle { double radius; void draw() const { /* ... */ } };
struct Square { double side; void draw() const { /* ... */ } };
struct Triangle { double base, height; void draw() const { /* ... */ } };
using Shape = std::variant<Circle, Square, Triangle>;
int main() {
// 全部栈上存储,无堆分配,无 vptr
std::vector<Shape> shapes;
shapes.emplace_back(Circle{5.0});
shapes.emplace_back(Square{3.0});
shapes.emplace_back(Triangle{4.0, 6.0});
for (const auto& shape : shapes) {
std::visit([](const auto& s) { s.draw(); }, shape);
}
}
3.3 内存对比
| 方案 | 每个元素开销 | 1000 个元素总内存 | 缓存行为 |
|---|---|---|---|
unique_ptr<Base> |
8B 指针 + 堆对象 + 8B vptr | ~48KB(分散) | 差(随机访问) |
std::variant |
sizeof(最大类型) + 判别字节 | ~24KB(连续) | 好(顺序访问) |
关键优势 :
variant存储在连续内存中,遍历时缓存命中率远高于指针多态。
4. 小缓冲区优化
4.1 SSO(Small String Optimization)
std::string 在字符串长度小于阈值时,直接在对象内部存储字符数据,避免堆分配。
cpp
#include <string>
#include <iostream>
int main() {
// 典型 SSO 阈值:15 字节(GCC/Clang)或 22 字节(MSVC)
std::string short_str = "hello"; // SSO:无堆分配
std::string long_str = "this is a very long string that exceeds SSO"; // 堆分配
// 验证:查看 string 对象大小
std::cout << "sizeof(std::string) = " << sizeof(std::string) << "\n";
// 典型输出:32(包含内部缓冲区 + 指针 + 大小 + 容量)
}
4.2 SBO(Small Buffer Optimization)在容器中的应用
cpp
#include <vector>
// 自定义小缓冲区分配器(C++17 PMR 配合)
#include <memory_resource>
// 栈上预分配 1KB 缓冲区
char buffer[1024];
std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};
// 使用栈上内存的 vector
std::pmr::vector<int> vec{&pool};
for (int i = 0; i < 100; ++i) {
vec.push_back(i); // 前 256 个 int 在栈上,无堆分配
}
4.3 自定义小缓冲区容器
cpp
#include <array>
#include <cstddef>
// 简化版小缓冲区 vector
template <typename T, size_t N>
class SmallVector {
std::array<T, N> inline_buffer_; // 栈上缓冲区
T* data_ = inline_buffer_.data();
size_t size_ = 0;
size_t capacity_ = N;
public:
void push_back(const T& val) {
if (size_ < capacity_) {
new (data_ + size_) T(val); // 栈上 placement new
} else {
grow(); // 超出小缓冲区才堆分配
}
++size_;
}
~SmallVector() {
for (size_t i = 0; i < size_; ++i)
data_[i].~T();
if (data_ != inline_buffer_.data())
::operator delete(data_);
}
private:
void grow() {
size_t new_cap = capacity_ * 2;
T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T)));
for (size_t i = 0; i < size_; ++i) {
new (new_data + i) T(std::move(data_[i]));
data_[i].~T();
}
if (data_ != inline_buffer_.data())
::operator delete(data_);
data_ = new_data;
capacity_ = new_cap;
}
};
适用场景:元素数量可预估且通常较少的容器(如函数参数列表、小型事件订阅者列表)。
5. C++17 多态分配器(PMR)
5.1 问题
标准容器默认使用 std::allocator,每次分配都调用全局 new/delete,在高频分配场景下成为瓶颈。
5.2 PMR 解决方案
cpp
#include <memory_resource>
#include <vector>
#include <string>
int main() {
// 1. 单调缓冲区分配器:只进不出,适合短生命周期批量分配
char buf[4096];
std::pmr::monotonic_buffer_resource mono(buf, sizeof(buf));
std::pmr::vector<std::pmr::string> names{&mono};
names.push_back("Alice");
names.push_back("Bob");
names.push_back("Charlie");
// 所有分配都在 buf 上,无系统调用
// 2. 池分配器:固定大小块分配,适合大量相同大小的对象
std::pmr::unsynchronized_pool_resource pool;
std::pmr::vector<int> data{&pool};
for (int i = 0; i < 10000; ++i) {
data.push_back(i);
}
// 3. 带上游的池分配器
std::pmr::synchronized_pool_resource sync_pool{&mono};
// 线程安全的池分配
}
5.3 分配器性能对比
| 分配器 | 100万次分配耗时 | 内存碎片 | 线程安全 |
|---|---|---|---|
std::allocator(全局 new) |
~500ms | 中 | 是 |
monotonic_buffer_resource |
~5ms | 无 | 否 |
unsynchronized_pool_resource |
~30ms | 低 | 否 |
synchronized_pool_resource |
~80ms | 低 | 是 |
数据为典型参考值,实际取决于平台和数据规模。
6. 内存对齐与 alignas
6.1 为什么对齐重要
- CPU 缓存行(通常 64 字节):未对齐的数据可能跨缓存行,导致两次内存访问
- SIMD 指令:SSE/AVX 要求 16/32 字节对齐
- False Sharing:多线程场景下,不同线程访问同一缓存行的不同变量会导致性能退化
6.2 alignas(C++11)
cpp
#include <cstddef>
#include <new>
// 对齐到缓存行,避免 false sharing
struct alignas(64) Counter {
std::atomic<int> value{0};
// 填充到 64 字节,确保不同 Counter 不在同一缓存行
char padding[64 - sizeof(std::atomic<int>)];
};
int main() {
Counter counters[4]; // 每个 Counter 占 64 字节,互不干扰
// 验证对齐
static_assert(alignof(Counter) == 64);
static_assert(sizeof(Counter) == 64);
}
6.3 对齐分配(C++17)
cpp
#include <new>
#include <cstdlib>
// C++17: 对齐的 new/delete
struct alignas(64) AlignedData {
float matrix[16]; // 4x4 矩阵
};
int main() {
// 自动使用对齐分配
auto* data = new AlignedData[100];
// 手动对齐分配
void* ptr = ::operator new(1024, std::align_val_t{64});
// 使用...
::operator delete(ptr, std::align_val_t{64});
delete[] data;
}
6.4 std::hardware_destructive_interference_size(C++17)
cpp
#include <new>
// 可移植的缓存行大小(避免硬编码 64)
struct alignas(std::hardware_destructive_interference_size) PaddedCounter {
std::atomic<int> count{0};
};
7. std::span 与零拷贝视图
7.1 避免容器拷贝
cpp
#include <span>
#include <vector>
#include <algorithm>
// ❌ 旧方式:拷贝整个 vector
void process_copy(std::vector<int> data) { // 值传递,触发拷贝
std::sort(data.begin(), data.end());
}
// ❌ 旧方式:指针 + 大小,不安全
void process_raw(int* data, size_t size) { /* ... */ }
// ✅ 新方式:零拷贝视图
void process_view(std::span<int> data) {
std::sort(data.begin(), data.end());
}
int main() {
std::vector<int> vec(1'000'000, 42);
process_view(vec); // 零拷贝
process_view(std::span(vec).subspan(100, 500)); // 子范围,零拷贝
}
7.2 内存收益
- 零堆分配 :
span本身只有 16 字节(指针 + 大小),在栈上传递 - 零数据拷贝:直接引用原始数据
- 减少内存峰值:不产生临时容器
8. C++20 协程帧分配优化
8.1 问题
每个协程在首次挂起时需要分配一个协程帧(存储局部变量、挂起点等),默认使用堆分配。
8.2 优化:HALO(Heap Allocation eLision Optimization)
编译器在满足条件时可以完全消除协程帧的堆分配:
cpp
#include <coroutine>
#include <optional>
struct Task {
struct promise_type {
std::optional<int> result;
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(int v) { result = v; }
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
Task(std::coroutine_handle<promise_type> h) : handle(h) {}
~Task() { if (handle) handle.destroy(); }
int get() { return *handle.promise().result; }
};
// 如果编译器能证明协程帧的生命周期不超过调用者,
// 会将帧分配在调用者的栈上(HALO)
Task compute() {
co_return 42;
}
int main() {
auto task = compute();
// 如果 HALO 生效,这里没有堆分配
std::cout << task.get() << "\n";
}
8.3 自定义分配器
cpp
struct Task {
struct promise_type {
// 自定义协程帧分配
static void* operator new(std::size_t size) {
// 使用内存池、栈分配等
return my_pool.allocate(size);
}
static void operator delete(void* ptr, std::size_t size) {
my_pool.deallocate(ptr, size);
}
// ...
};
};
9. C++23 std::mdspan
9.1 多维数组的零拷贝视图
cpp
#include <mdspan> // C++23,部分编译器在 <experimental/mdspan>
#include <vector>
int main() {
// 一维存储
std::vector<double> data(100 * 200);
// 二维视图:零拷贝
std::mdspan<double, std::extents<std::dynamic_extent, std::dynamic_extent>>
matrix(data.data(), 100, 200);
// 访问元素
matrix[3, 5] = 3.14;
// 子视图:零拷贝切片
auto sub = std::submdspan(matrix,
std::pair(10, 20), // 行 10-19
std::pair(50, 100)); // 列 50-99
}
9.2 内存优势
- 替代
vector<vector<T>>(每行一次堆分配 → 一次分配) - 替代手动指针算术(安全且零开销)
- 支持非连续布局(stride),适配已有内存布局
10. 编译器层面的内存优化
10.1 拷贝消除(C++17 保证)
cpp
struct Big {
int data[1024];
Big() { /* 构造 */ }
Big(const Big&) { /* 拷贝:4KB 复制 */ }
};
Big make_big() { return Big{}; }
int main() {
Big b = make_big(); // C++17: 保证无拷贝,直接在 b 的位置构造
}
10.2 \[no_unique_address](C++20)
cpp
struct Empty {}; // sizeof(Empty) == 1
struct WithoutOpt {
Empty e; // 占 1 字节 + 对齐填充
int x; // 总大小可能是 8 字节
};
struct WithOpt {
[[no_unique_address]] Empty e; // 不占空间
int x; // 总大小 4 字节
};
static_assert(sizeof(WithoutOpt) >= 8);
static_assert(sizeof(WithOpt) == 4);
实际用途:空分配器、空删除器、空 tag 类型不再浪费空间。
10.3 尾调用优化与栈帧复用
虽然不完全是"新特性",但现代编译器配合 if constexpr、constexpr 等特性,能更积极地进行栈帧复用,减少栈内存使用。
11. 总结与最佳实践
特性速查表
| 特性 | 标准 | 优化目标 | 收益等级 |
|---|---|---|---|
unique_ptr / make_unique |
C++11/14 | 消除内存泄漏 | ⭐⭐⭐⭐⭐ |
make_shared |
C++11 | 减少分配次数 | ⭐⭐⭐⭐ |
std::optional |
C++17 | 消除不必要的堆分配 | ⭐⭐⭐⭐ |
std::variant |
C++17 | 消除 vptr + 堆分配 | ⭐⭐⭐⭐⭐ |
| PMR 分配器 | C++17 | 减少系统调用 | ⭐⭐⭐⭐⭐ |
alignas / 对齐分配 |
C++11/17 | 缓存友好 | ⭐⭐⭐⭐ |
std::span |
C++20 | 零拷贝视图 | ⭐⭐⭐⭐ |
| 协程帧优化(HALO) | C++20 | 消除协程堆分配 | ⭐⭐⭐ |
[[no_unique_address]] |
C++20 | 消除空对象占用 | ⭐⭐⭐ |
std::mdspan |
C++23 | 多维零拷贝 | ⭐⭐⭐⭐ |
实践建议
- 默认使用
unique_ptr:除非需要共享所有权,否则不要用shared_ptr。 - 用
optional替代"可能为空的指针":减少一次堆分配,提升缓存局部性。 - 用
variant替代继承多态 :在类型集合已知时,variant的内存效率和缓存表现远优于虚函数。 - 高频分配场景使用 PMR:游戏循环、网络包处理、解析器等场景收益显著。
- 注意对齐 :多线程共享数据用
alignas(64)避免 false sharing。 - 避免
vector<vector<T>>:使用一维vector+mdspan(或手动索引)替代。 - 信任编译器 :C++17 的拷贝消除保证已经足够强,不要手动
std::move返回值。
参考资料
- C++17 Standard - Memory Resources
- C++20 Standard - Coroutines
- P0009R18: mdspan
- CppCon 2021: Memory Resources
- C++ Tips of the Week - Google
如果本文对你有帮助,欢迎点赞收藏。如有补充或勘误,请在评论区指出,共同进步。