【C/C++ C++ 新特性中的那些内存优化的特性】

🔥 个人主页: flos chen

❄️ 个人专栏: 《系统分析师》 《C/C++》

《Qt》 《Linux》 《SQL》

《深度学习》

🌟 边学习,边记录,一起学习进步!

文章目录

    • 前言
    • [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 多维零拷贝 ⭐⭐⭐⭐

实践建议

  1. 默认使用 unique_ptr :除非需要共享所有权,否则不要用 shared_ptr。
  2. 用 optional 替代"可能为空的指针":减少一次堆分配,提升缓存局部性。
  3. 用 variant 替代继承多态 :在类型集合已知时,variant 的内存效率和缓存表现远优于虚函数。
  4. 高频分配场景使用 PMR:游戏循环、网络包处理、解析器等场景收益显著。
  5. 注意对齐 :多线程共享数据用 alignas(64) 避免 false sharing。
  6. 避免 vector<vector<T>> :使用一维 vector + mdspan(或手动索引)替代。
  7. 信任编译器 :C++17 的拷贝消除保证已经足够强,不要手动 std::move 返回值。

参考资料


如果本文对你有帮助,欢迎点赞收藏。如有补充或勘误,请在评论区指出,共同进步。

相关推荐
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 查询推荐动态
java·开发语言·交友
jaysee-sjc1 小时前
【苍穹外卖】Day10:定时任务与 WebSocket 实时提醒实战
java·开发语言·javascript·数据库·vue.js·websocket·网络协议
ysu_03141 小时前
【C++ 进阶之路】(二)从内联函数到类与对象:深挖内存对齐、this 指针与三大默认成员函数
开发语言·数据结构·c++·笔记·面向对象·this
独隅1 小时前
Claude 高效应用实战指南
android·java·开发语言·claude·claude code
布莱克6051 小时前
C++ 中 set 与 map 的区别详解
开发语言·c++·算法·哈希
我叫洋洋1 小时前
Allegro 画布“垃圾显示“清理指南:把板子调成干净工作台(实战总结)
c语言·c++·单片机·嵌入式硬件·eda·嘉立创
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 圈子互动
java·开发语言·交友
jimy12 小时前
构造函数表达式的值类别是:prvalue
开发语言·c++
小嘉丶学长2 小时前
Day06 · 数据类型与类型检测
开发语言·python·学习·1024程序员节