Skip to main content

Chapter 4: Memory Management

STL 容器的内存管理要解决一个具体问题:容器需要先取得一段能容纳若干元素的原始存储,再在其中按需构造对象,并在对象失效时按正确顺序析构和释放存储。std::vector<int> v; v.reserve(8); v.emplace_back(1); v.clear(); 这一组操作能把本章主线压缩到一个场景:reserve 改变存储容量,emplace_back 改变对象生命周期,clear 结束对象生命周期,容器析构时释放底层存储。

读完本章后,读者应能区分三个层级:分配接口负责取得和归还字节级存储;构造与析构负责建立和结束对象生命周期;容器策略负责把容量、元素数量、异常安全和性能成本组织成稳定状态。这个区分会直接影响后续阅读 vectordequelist、allocator 和 raw memory algorithm 的方式。

本章采用一个最小动态数组作为贯穿材料。它维护三个指针:begin_ 指向已构造元素起点,end_ 指向已构造元素终点,cap_ 指向已分配存储终点。这个模型与常见 std::vector 实现形状接近,但代码只用于教学,不代表某个标准库实现的真实源码。

#include <cstddef>
#include <memory>
#include <new>
#include <utility>

class IntBuffer {
public:
void reserve(std::size_t new_cap) {
if (new_cap <= capacity()) {
return;
}

int* new_begin = static_cast<int*>(::operator new(new_cap * sizeof(int)));
int* new_end = new_begin;

try {
for (int* p = begin_; p != end_; ++p, ++new_end) {
std::construct_at(new_end, std::move(*p));
}
} catch (...) {
for (int* p = new_begin; p != new_end; ++p) {
std::destroy_at(p);
}
::operator delete(new_begin);
throw;
}

for (int* p = begin_; p != end_; ++p) {
std::destroy_at(p);
}
::operator delete(begin_);

begin_ = new_begin;
end_ = new_end;
cap_ = new_begin + new_cap;
}

void push_back(int value) {
if (end_ == cap_) {
reserve(capacity() == 0 ? 1 : capacity() * 2);
}
std::construct_at(end_, value);
++end_;
}

void clear() noexcept {
for (int* p = begin_; p != end_; ++p) {
std::destroy_at(p);
}
end_ = begin_;
}

~IntBuffer() {
clear();
::operator delete(begin_);
}

private:
std::size_t capacity() const noexcept {
if (begin_ == nullptr) {
return 0;
}
return static_cast<std::size_t>(cap_ - begin_);
}

int* begin_ = nullptr;
int* end_ = nullptr;
int* cap_ = nullptr;
};

这段代码中最关键的分工是:::operator new 只返回一段满足对齐要求的原始存储;std::construct_at 才把某个 int 对象构造在指定地址;std::destroy_at 结束对象生命周期;::operator delete 释放原始存储。后续每一节都回到这条路径,解释 STL 容器为何需要把“容量”和“元素数量”拆成两个状态。

4.1 分配接口:malloc / free 与 operator new / delete

内存分配接口先回答“哪里来一段可用存储”。在 C 和 C++ 中,malloc / freeoperator new / operator delete 都能取得和归还动态存储,但它们处在不同的语言层级。malloc 的返回值是 void*,调用方需要自己决定如何解释这段字节;operator new 是 C++ 分配函数,服务于 new expression 和标准库容器等对象管理路径。

malloc 只承诺返回一段可用于对象表示的存储。它不调用构造函数,不知道目标类型,也不负责对象生命周期。对于普通字节缓冲、C 接口兼容或自定义底层分配器,malloc 仍然可用;一旦目标对象带有构造、析构、不变量或异常路径,调用方必须额外安排对象构造和析构。这个额外责任常常就是错误来源。

operator new 是 C++ 的分配函数。它的基本形式接收字节数并返回 void*,失败时通常抛出 std::bad_allocoperator delete 接收先前由匹配分配函数取得的指针并释放存储。它们仍然主要处理原始存储;对象构造和析构由 new expression、delete expression、placement new、std::construct_atstd::destroy_at 或 allocator 路径负责。

下面的例子把两个层级拆开。第一段只取得存储,第二段才建立对象生命周期。

struct Widget {
Widget(int value) : value(value) {}
~Widget() {}
int value;
};

void manual_object_path() {
void* raw = ::operator new(sizeof(Widget));
Widget* ptr = nullptr;

try {
ptr = std::construct_at(static_cast<Widget*>(raw), 42);
} catch (...) {
::operator delete(raw);
throw;
}

std::destroy_at(ptr);
::operator delete(raw);
}

这段代码说明一个可迁移判断:看见 operator new,先把它理解为“取得存储”;看见 std::construct_at 或 placement new,才进入“对象存在”的阶段;看见 std::destroy_at,对象生命周期结束;看见 operator delete,底层存储归还。把这四个动作混成一个动作,会让后续阅读容器扩容、异常安全和 allocator 时失去状态边界。

mallocoperator new 的工程差异可以按同一组维度判断。输入维度上,二者都接收字节数;输出维度上,二者都返回地址。失败处理上,malloc 返回空指针,常规 throwing operator new 抛异常。对象语义上,二者都不自动构造业务对象;new expression 才把 operator new 与构造合并成一个语言表达式。释放匹配上,malloc 对应 freeoperator new 对应 operator delete,混用释放接口会破坏分配器的内部账本。

STL 容器偏向通过 allocator 进入分配路径,而 allocator 的默认实现通常会落到 C++ 分配函数。容器关注的核心并非“调用哪一个堆接口”这一层,而是“容器能否把存储取得、元素构造、失败清理和最终释放分成可控制阶段”。这正是 vector::reserve 能先扩容量,再逐个构造元素,最后提交新状态的基础。

4.2 new / delete expression 的组合动作

new expression 是语言表达式,它把分配函数调用和对象初始化组合成一个动作。表达式 new Widget(42) 的可观察结果是返回一个指向已构造 Widget 的指针;展开看,它先选择并调用合适的 operator new 分配存储,再在这段存储上初始化对象。构造函数成功后,对象生命周期开始。

delete expression 是对应的组合动作。表达式 delete p 先对 p 指向的对象调用析构函数,再选择并调用匹配的 operator delete 释放存储。数组形式 new[]delete[] 形成另一组匹配路径,常见实现会在数组分配附近保存元素数量或额外元数据,用于 delete[] 时按元素逆序析构。

下面的代码展示组合表达式与手动拆解路径之间的关系。

void expression_path() {
Widget* p = new Widget(42);
delete p;
}

void roughly_equivalent_manual_path() {
void* raw = ::operator new(sizeof(Widget));
Widget* p = nullptr;

try {
p = std::construct_at(static_cast<Widget*>(raw), 42);
} catch (...) {
::operator delete(raw);
throw;
}

std::destroy_at(p);
::operator delete(raw);
}

这两个函数在教学层面表达同一条主路径:分配、构造、析构、释放。真实语言规则还会处理类内分配函数、对齐感知分配、数组额外开销、构造失败时的匹配释放函数选择等细节。对于源码阅读,先抓住组合动作的四个阶段,再把实现差异挂到对应阶段上。

构造失败是 new expression 最值得关注的边界。分配成功后,如果构造函数抛出异常,对象生命周期没有完成建立,语言会尝试调用匹配的 deallocation function 归还已取得存储。这个规则让 new Widget(...) 在异常路径上保持存储清理。但当调用方手动使用 operator newstd::construct_at 时,清理责任回到调用方手中,前面示例中的 catch 块就承担这个职责。

数组表达式增加了元素数量维度。new Widget[n] 需要构造多个元素;某个元素构造失败时,已经构造完成的前面元素需要逆序析构,随后释放整段存储。delete[] 也需要知道元素个数,才能逐个析构。常见实现使用数组 cookie 等策略记录必要信息,但标准层面对很多存储细节保留实现空间。工程上应把 newdeletenew[]delete[] 成对使用,接口形态本身就是释放路径所需信息的一部分。

STL 容器通常不直接用 new T[n] 管理元素数组,因为 new T[n] 会一次性默认构造 n 个对象。vector 需要的是“容量为 n 的原始存储,但当前只有 size() 个对象已经构造”。如果用 new T[n] 表示容量,空余位置也会变成真实对象,容器就无法低成本区分已用元素和预留容量。容器因此采用分离路径:先分配原始存储,再按元素数量逐个构造。

下面的状态图总结了组合表达式和容器路径的差异。图中“原始存储”表示地址可用但目标对象尚未开始生命周期,“有效对象”表示构造完成且析构责任已经存在。

图中的关键路径是 RawStorage → LiveObject → RawStorage。STL 容器的大量源码都围绕这两个状态转换展开:扩容时取得新 RawStorage,迁移时逐个进入 LiveObject,回滚时销毁已经构造的新对象,提交后销毁旧对象并释放旧存储。

4.3 placement new、raw memory 与 uninitialized memory

placement new 解决“在指定地址构造对象”的问题。它的常见形式是 new (address) T(args...),含义是在调用方提供的存储地址上构造 T 对象。这个表达式不会向堆申请新存储;标准库提供的指针形式 placement operator new(std::size_t, void*) 会返回传入地址。对象构造仍然真实发生,因此析构责任也随之产生。

raw memory 指已经取得但尚未承载目标对象生命周期的存储。uninitialized memory 在 STL 语境中通常指一段未构造目标元素的连续存储,例如 vector 扩容后 end_cap_ 之间的区域。它可以容纳对象表示,但当前还没有对象;对这段区域执行普通赋值会把“给已有对象赋新值”和“创建新对象”混淆。

下面的例子使用 std::byte 提供一段自动存储期的缓冲区,然后在其中构造和销毁 Widget

#include <cstddef>
#include <memory>
#include <new>

void construct_in_local_buffer() {
alignas(Widget) std::byte storage[sizeof(Widget)];

Widget* p = std::construct_at(
reinterpret_cast<Widget*>(storage),
7
);

std::destroy_at(p);
}

alignas(Widget) 是这段代码的必要条件。对象地址必须满足 Widget 的对齐要求,构造函数才能在该地址上建立有效对象。reinterpret_cast 只把地址解释成 Widget*,它本身不创建对象;std::construct_at 才开始对象生命周期。std::destroy_at 结束生命周期后,缓冲区重新回到普通字节存储状态。

C++20 提供 std::construct_at,让“在地址上构造对象”这个动作显式化。早期代码常使用 placement new:::new (static_cast<void*>(p)) T(args...)。二者在教学主线上承担同一职责:把一段已对齐的 raw memory 转成 live object。标准库容器和 allocator 相关代码中还会出现 std::allocator_traits<A>::constructstd::uninitialized_movestd::uninitialized_copystd::destroy 等工具,它们处理的是批量构造和批量销毁。

批量构造必须考虑部分成功状态。假设 vector 扩容时要把 [begin_, end_) 中的元素移动到新存储,前 3 个元素构造成功,第 4 个元素构造抛异常。此时新存储前 3 个位置已经是有效对象,需要析构;第 4 个和后续位置仍然只是 raw memory;旧存储中的原对象仍要保持可清理状态。容器源码中的 guard、临时指针和提交点,通常就是为了记录“已经构造到哪里”。

template <class T>
T* uninitialized_move_range(T* first, T* last, T* dest) {
T* current = dest;
try {
for (; first != last; ++first, ++current) {
std::construct_at(current, std::move(*first));
}
return current;
} catch (...) {
for (T* p = dest; p != current; ++p) {
std::destroy_at(p);
}
throw;
}
}

这段简化代码的核心变量是 current。它不是普通循环游标那么简单,它记录了新存储中已经进入对象生命周期的边界。异常发生时,清理循环只能销毁 [dest, current);销毁 [current, dest + n) 会对未构造位置执行析构,属于错误生命周期操作。

阅读 STL 源码时,raw memory 路径的判断顺序可以固定为四步:先找分配得到的存储范围,再找已经构造的对象范围,再找异常路径如何清理部分构造对象,最后找提交点如何更新容器的 beginendcapacity 状态。这个顺序比直接追每个辅助模板名更稳定,因为不同实现会使用不同命名包装同一组生命周期边界。

4.4 对齐、cache locality 与内存碎片

内存布局会改变访问成本。对齐决定一个对象能放在哪些地址上,cache locality 决定连续访问时处理器能否复用相邻数据,内存碎片决定分配器能否把空闲空间组成未来请求所需的块。STL 容器的性能差异常常来自这些布局因素,而时间复杂度只能描述操作次数的增长趋势。

对齐是对象地址的合法性约束。alignof(T) 给出类型 T 的对齐要求,动态分配函数需要返回满足目标对象要求的地址。C++17 引入对齐感知的 operator new / operator delete 选择路径,用于处理超过默认 new 对齐能力的过对齐类型。对于容器,allocator 的 allocate 必须返回足以放置 T 的存储;容器随后才能用构造工具在该存储上创建元素。

struct alignas(64) CacheLineSlot {
int value;
};

static_assert(alignof(CacheLineSlot) == 64);

这个类型要求对象地址按 64 字节对齐。普通程序很少手写过对齐对象,但标准库实现必须处理这类类型,因为 std::vector<CacheLineSlot> 应该能正确分配和构造元素。源码中与 align_val_t、allocator、platform allocation 相关的分支,通常服务于这种对齐边界。

cache locality 描述访问相邻内存时的局部性收益。vector 的元素连续存放,线性遍历时相邻元素大概率位于相邻 cache line,硬件预取也更容易发挥作用。list 的节点分散分配,逻辑上的下一个元素可能位于完全不同的内存块,遍历时更容易受到 cache miss 和指针追踪影响。两个容器的某些操作同为线性复杂度,实际运行成本仍可能差很多。

下面的访问模式展示了连续存储对遍历的影响。代码本身不用于基准测试,只用于说明访问路径。

int sum_contiguous(const int* first, const int* last) {
int total = 0;
for (const int* p = first; p != last; ++p) {
total += *p;
}
return total;
}

这个循环每次访问下一个地址。对于连续容器,地址递增与逻辑顺序一致;对于节点容器,逻辑递增需要先读取节点中的 next 指针,再跳到另一个节点。源码阅读时看见 vector 的三指针状态和 list 的节点指针状态,应马上联想到两类不同成本:前者把重分配和移动成本集中在扩容或插入点,后者把分配和指针追踪成本分摊到每个节点操作中。

内存碎片分为外部碎片和内部碎片。外部碎片指空闲空间总量足够,但分散成许多小块,无法满足某个大块请求;内部碎片指分配器为了对齐、元数据或 size class 向上取整,导致已分配块中有一部分空间无法承载用户对象。节点容器大量执行小块分配,可能增加分配器元数据和碎片压力;连续容器请求较大块,扩容时可能产生旧块释放和新块申请的峰值内存。

STL 选型中的内存判断需要同时看四个问题:对象是否需要连续存储,操作是否频繁在中间插入删除,元素移动成本是否可接受,访问模式是否以遍历和随机访问为主。vector 在遍历和随机访问上通常有布局优势,但扩容会迁移元素并使相关指针、引用和 iterator 失效。list 在节点重连上有稳定性优势,但每个节点独立分配会提高空间开销并削弱局部性。

4.5 内存池与 allocator 策略入口

内存池解决“许多相似大小对象频繁分配释放”的成本问题。它预先向底层分配器申请较大块,再把这块切成固定大小或少数几类大小的 slot。分配对象时从空闲链表取一个 slot,释放对象时把 slot 放回池中。这样可以减少底层分配函数调用次数,并让同类对象在地址上更集中。

内存池改变的是存储来源和复用策略,不接管对象生命周期本身。对象仍然需要在取得的 slot 上构造,在对象失效时析构,然后 slot 才能回到池。对于 STL 容器,这个分工十分关键:allocator 的 allocate / deallocate 处理原始存储,容器或 allocator_traits 负责调用构造和销毁接口。

下面是一个极简固定大小池的示意代码。它只展示 slot 复用思路,省略线程安全、异常安全、跨块回收、对齐泛化和析构管理。

#include <cstddef>
#include <new>
#include <vector>

class FixedIntPool {
public:
int* allocate_one() {
if (free_ == nullptr) {
grow();
}
Node* node = free_;
free_ = free_->next;
return reinterpret_cast<int*>(node);
}

void deallocate_one(int* p) noexcept {
Node* node = reinterpret_cast<Node*>(p);
node->next = free_;
free_ = node;
}

~FixedIntPool() {
for (void* block : blocks_) {
::operator delete(block);
}
}

private:
union Node {
Node* next;
alignas(int) unsigned char storage[sizeof(int)];
};

void grow() {
constexpr std::size_t block_count = 64;
Node* block = static_cast<Node*>(::operator new(sizeof(Node) * block_count));
blocks_.push_back(block);
for (std::size_t i = 0; i < block_count; ++i) {
block[i].next = free_;
free_ = &block[i];
}
}

Node* free_ = nullptr;
std::vector<void*> blocks_;
};

这段代码中的 slot 被放入空闲链表时存放 next 指针;被分配给 int 对象时,同一段字节用于承载对象。这个复用关系要求调用方严格区分当前状态:空闲状态下可以读写 next,对象状态下只能按 int 访问。更一般的池化 allocator 会把这种状态切换封装起来,并通过 allocator 接口提供给容器。

allocator 是 STL 容器的内存策略入口。容器模板参数中的 allocator 让容器把“需要多少个 T 的存储”描述给策略对象,而策略对象决定从普通堆、池、arena、共享内存或 polymorphic memory resource 中取得存储。容器自身仍然维护元素数量、构造边界、异常回滚和 iterator 失效规则。

C++17 的 std::pmr 提供了运行期可替换 memory resource 的标准入口。std::pmr::vector<T> 的容器逻辑仍是 vector 逻辑,只是分配请求被转发到指定资源。单调缓冲资源适合批量构造、整体释放的场景;池资源适合大量同大小或近似大小对象反复分配释放的场景。资源选择应根据对象生命周期形状决定,而非根据“池化一定更快”这样的单句结论决定。

内存池有明确代价。池需要管理空闲链表和大块生命周期,可能保留已经释放给池但尚未归还系统的内存;固定大小池会产生内部碎片;跨线程使用需要同步或线程本地策略;对象大小差异较大时,池化分组会变复杂。工程判断顺序应先看分配频率和对象大小分布,再看释放模式是否成批或反复复用,接着看内存峰值和线程模型,最后决定是否把 allocator 策略引入容器。

4.6 STL 容器的内存管理路径

STL 容器的内存管理路径可以压缩成一个状态机:分配原始存储,构造若干元素,维护已构造范围,修改时移动或销毁元素,失败时回滚,提交后释放旧存储。vector 代表连续存储容器,list 代表节点存储容器,deque 代表分段存储容器。它们的接口不同,但底层都必须守住存储和对象生命周期的边界。

vector 扩容为例,容器拥有三个状态量:beginendcapbeginend 是有效对象范围;endcap 是未构造存储;cap - begin 是容量。reserve(n)n 超过当前容量时申请新存储,把旧元素移动或拷贝到新存储,成功后提交新三指针,随后销毁旧对象并释放旧存储。异常发生时,新存储中已经构造的对象要清理,旧三指针保持原状态。

template <class T>
struct VectorState {
T* begin;
T* end;
T* cap;
};

// [begin, end) : live objects
// [end, cap) : raw storage reserved for future objects

这三行注释就是阅读 vector 内存源码的核心地图。任何插入、删除、扩容、清空操作都可以转换成对两个区间的修改:有效对象区间如何变化,预留存储区间如何变化。源码里的 allocator、traits、move iterator、guard 和提交函数,最终都服务于这两个区间的一致性。

push_back 有两条路径。容量足够时,它在 end 指向的位置构造一个新元素,然后推进 end。容量不足时,它先走扩容路径,得到更大的 raw storage,再把新元素构造到新尾部。这解释了为什么 push_back 的摊还复杂度可以是常数,但某一次调用可能触发线性数量的元素移动和一次新分配。

erase 处理的是已构造对象区间内部的空洞。对于连续存储,删除中间元素后,后续元素需要向前移动赋值或移动构造,最后一个元素析构,end 回退。对于节点容器,erase 通常是断开节点链接,析构节点内元素并释放节点存储。二者都结束一个对象生命周期,但连续容器额外维护元素顺序和紧凑布局,节点容器额外维护指针链接不变量。

容器的异常安全来自提交点设计。扩容时,旧状态在新状态完全构造成功前保持可用;新状态构造过程中用临时指针记录已构造范围;全部成功后才更新容器成员。这个顺序让失败路径有明确清理目标,也让成功路径有明确所有权转移时刻。

图中的提交点是 E。在 E 之前,容器对外仍以旧状态为准;在 E 之后,新存储成为容器状态,旧存储进入清理阶段。阅读真实 STL 实现时,提交点可能被包装在私有函数、局部 guard 或异常处理对象中,但判断依据仍是成员指针或控制结构何时被更新。

把本章的内容转成 STL 源码阅读顺序,可以得到一条稳定检查线:先区分存储范围和对象范围,再定位构造与销毁动作,接着检查分配与释放是否匹配,然后分析异常路径中的部分构造清理,最后把内存布局映射到性能和 iterator 失效。这个顺序能覆盖 vector 的连续扩容、list 的节点分配、deque 的分段块管理,也能自然连接到后续 allocator 章节。

最小自检任务

阅读下面的简化代码,判断它的生命周期路径是否正确。要求说明:哪一段代码取得原始存储,哪一段开始对象生命周期,异常发生时需要清理哪些对象,最后如何释放存储。

struct Item {
Item(int value) : value(value) {}
Item(const Item& other) : value(other.value) {}
~Item() {}
int value;
};

void build_items(const Item* source, std::size_t count) {
Item* buffer = static_cast<Item*>(::operator new(sizeof(Item) * count));
Item* current = buffer;

try {
for (std::size_t i = 0; i < count; ++i, ++current) {
std::construct_at(current, source[i]);
}
} catch (...) {
for (Item* p = buffer; p != current; ++p) {
std::destroy_at(p);
}
::operator delete(buffer);
throw;
}

for (Item* p = buffer; p != current; ++p) {
std::destroy_at(p);
}
::operator delete(buffer);
}

答案要点

::operator new(sizeof(Item) * count) 取得一段能容纳 countItem 的原始存储,此时 [buffer, buffer + count) 中尚未存在 Item 对象。循环中的 std::construct_at(current, source[i])current 指向的位置构造对象,current 始终记录已经构造范围的尾后位置。

如果第 i 次构造抛异常,[buffer, current) 是已经构造成功的有效对象范围,需要逐个 std::destroy_at[current, buffer + count) 仍然只是原始存储,不能析构。清理已构造对象后,代码用 ::operator delete(buffer) 释放整段原始存储,并重新抛出异常。

全部构造成功后,[buffer, current) 全部是有效对象。函数末尾先销毁所有对象,再释放原始存储。这个顺序体现了容器内存路径的基本规则:释放存储前结束对象生命周期,构造失败时只清理已经进入生命周期的对象,分配函数和释放函数保持匹配。

本章知识点总结

  • 分配层级mallocoperator new 和 allocator 的 allocate 主要取得原始存储,构造动作另有独立入口。
  • 对象起点:对象生命周期从构造成功开始,地址转换和字节存储本身不会建立目标对象。
  • 表达式组合new expression 组合分配与构造,delete expression 组合析构与释放。
  • 失败清理:构造函数抛异常时,已经取得的存储或已经构造的部分对象需要按阶段清理。
  • 数组匹配new[]delete[] 属于数组路径,释放阶段需要数组元素数量信息。
  • placement new:placement new 和 std::construct_at 都能在指定地址上创建对象,前提是存储大小和对齐满足目标类型要求。
  • 未构造区间:容器容量中的空余位置是 raw memory,普通赋值只能作用于已经存在的对象。
  • 构造边界:批量构造时必须记录已经成功构造到哪里,异常路径据此销毁部分对象。
  • 对齐约束:对象地址必须满足 alignof(T),过对齐类型会触发更具体的分配与释放路径。
  • 局部性成本:连续容器通常拥有更好的遍历局部性,节点容器通常付出更多分配和指针追踪成本。
  • 碎片来源:大量小块分配、对齐填充和分配器 size class 都可能增加实际内存开销。
  • 池化策略:内存池减少底层分配次数,但仍需由容器或调用方管理对象构造与析构。
  • allocator 入口:allocator 把容器的存储来源策略化,容器仍负责元素状态、异常回滚和复杂度承诺。
  • 提交点:容器扩容先构造新状态,成功后再提交成员指针,失败时回滚新存储并保留旧状态。
  • 源码顺序:阅读容器内存源码时,先找存储范围,再找对象范围,再看构造销毁、异常清理、释放匹配和性能后果。