进程内存一直涨就是泄漏吗?从所有权到工具定位 C++ 内存问题

时间:2026/04/16

线上服务的 RSS 从 300 MiB 缓慢涨到 2 GiB,第一反应往往是“内存泄漏”。但仅凭进程内存曲线,至少有几种可能:对象已经丢失却没有释放;缓存仍然可达但没有上限;队列持续积压;容器保留 capacity;分配器缓存了已释放内存;碎片让空闲块难以复用。

这些现象都可能耗尽机器,却需要完全不同的修复方式。给每条路径补一个 delete 不但未必解决问题,还可能制造重复释放。可靠排查应该从所有权和对象数量入手,再用动态检测、堆快照和压力复现证明是哪一种增长。

本文建立一条完整路径:先区分泄漏与内存驻留,再用 RAII 避免常见泄漏,通过可观测生命周期验证所有权,最后选择 Sanitizer、Valgrind 或平台工具定位分配栈。


1. 什么才是严格意义上的内存泄漏?

最典型的泄漏是:动态分配仍未释放,但程序已经丢失所有能用于正确释放它的路径。

1
2
3
void leak() {
int* value = new int(42);
} // 指针丢失,分配仍存在

这种内存不可达,后续代码既无法使用,也无法 delete。长时间服务反复走这条路径,未释放字节会随调用次数增长。

工程排查还要区分几类相似现象:

现象 对象是否仍可达 典型原因 主要手段
真正泄漏 否或只剩无效内部引用 所有权丢失、循环拥有 Leak 检测、分配栈
逻辑性保留 无界缓存、积压队列、永不清理会话 对象计数、业务指标、堆快照
容器容量保留 vector 清空元素但保留 capacity capacity 指标、容器策略
分配器驻留/碎片 分配可能已释放 分配器缓存、块尺寸不匹配 allocator 统计、heap profiler
内存破坏 不适用 越界、use-after-free、重复释放 ASan/UBSan、调试库

RSS 不下降不证明 delete 没执行。释放通常先把块归还用户态分配器,分配器未必立刻把页面归还操作系统。反过来,进程退出时“没有 leak 报告”也不能证明长期运行时缓存和队列有界。

2. 为什么所有权设计比事后补释放更重要?

裸资源要求每条正常、提前返回和异常路径都手工清理:

1
2
3
4
5
6
7
Widget* create(bool valid) {
Widget* widget = new Widget;
if (!valid) {
return nullptr; // widget 泄漏
}
return widget; // 调用方是否负责 delete 仍不清楚
}

RAII 把释放动作绑定到对象析构:

1
2
3
4
5
6
7
std::unique_ptr<Widget> create(bool valid) {
auto widget = std::make_unique<Widget>();
if (!valid) {
return nullptr; // 局部 unique_ptr 自动释放
}
return widget; // 明确转移独占所有权
}

默认选择顺序通常是:

  1. 能作为值或容器成员保存,就不单独动态分配;
  2. 需要动态生命周期且只有一个所有者,使用 unique_ptr
  3. 确实有多个参与者共同延长生命周期,才使用 shared_ptr
  4. 观察关系使用引用、裸指针或 weak_ptr,不负责释放;
  5. C API 资源立即包装成带正确删除器的 RAII 类型。

这不是禁止裸指针。裸指针很适合短期观察,问题是把“拥有并负责释放”的含义藏在一个普通地址中。

3. new/deletemalloc/free 为什么不能混用?

new T(args...) 通常包含两步:取得适合 T 的存储,再在其中构造 Tdelete 先析构对象,再归还对应存储。数组形式还要处理多个元素:

获取方式 正确释放方式
new T delete
new T[n] delete[]
malloc/calloc/realloc free
make_unique<T> unique_ptr 自动释放
make_unique<T[]>(n) unique_ptr<T[]> 自动释放

malloc 只返回原始字节,不调用 C++ 构造函数;free 也不会调用析构函数。混用分配/释放家族属于未定义行为,不能因为某个平台底层实现碰巧相似就依赖。

realloc 还有一个经典错误:

1
buffer = static_cast<char*>(std::realloc(buffer, new_size));

失败时 realloc 返回空,原块仍有效,但原地址已经被覆盖而泄漏。应先保存临时返回值,成功后再提交:

1
2
3
4
5
6
void* replacement = std::realloc(buffer, new_size);
if (replacement != nullptr) {
buffer = static_cast<char*>(replacement);
} else {
// buffer 仍指向原分配,由当前所有者处理
}

C++ 代码接收 malloc 资源时可以立即封装:

1
2
3
4
5
using MallocBuffer = std::unique_ptr<std::byte, decltype(&std::free)>;

MallocBuffer allocate(std::size_t bytes) {
return {static_cast<std::byte*>(std::malloc(bytes)), &std::free};
}

调用方仍要检查分配失败,但所有成功路径和异常路径都共享同一个 free 责任。

4. 为什么智能指针仍然可能泄漏?

shared_ptr 使用引用计数,无法自动回收强引用环:

1
2
3
Parent ──shared_ptr──> Child
↑ │
└────shared_ptr────────┘

外部引用全部消失后,两边仍各持有一次强引用,计数不会归零。修复不是机械地“随便换一边为 weak”,而是先确定主从生命周期。例如父对象拥有子对象,子对象只观察父对象:

1
2
3
4
5
6
7
8
9
struct Parent;

struct Child {
std::weak_ptr<Parent> parent;
};

struct Parent {
std::shared_ptr<Child> child;
};

图结构、回调捕获和事件订阅也容易形成隐蔽循环:对象拥有回调,回调 lambda 又捕获对象的 shared_ptr。需要时捕获 weak_ptr,执行前 lock(),并定义订阅解除协议。

unique_ptr 很难形成共享环,但调用 release() 后若没有立刻把裸指针交给新所有者,同样会泄漏。智能指针安全来自所有权语义被正确使用,不来自类型名字。

5. 一个可运行的生命周期验证示例

工具定位之前,可以给关键领域对象增加测试期存活计数,验证一次操作结束后对象是否归零。下面的 C++20 示例建立父子关系:父强拥有子,子弱观察父。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <atomic>
#include <iostream>
#include <memory>

struct Tracked {
Tracked() { ++alive; }
virtual ~Tracked() { --alive; }

static inline std::atomic<int> alive{0};
};

struct Parent;

struct Child final : Tracked {
std::weak_ptr<Parent> parent;
};

struct Parent final : Tracked {
std::shared_ptr<Child> child;
};

int main() {
std::cout << "before = " << Tracked::alive.load() << '\n';

{
auto parent = std::make_shared<Parent>();
parent->child = std::make_shared<Child>();
parent->child->parent = parent;

std::cout << "inside = " << Tracked::alive.load() << '\n';
}

std::cout << "after = " << Tracked::alive.load() << '\n';
}

编译运行:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -pedantic \
ownership_check.cpp -o ownership_check
./ownership_check

预期输出:

1
2
3
before = 0
inside = 2
after = 0

如果把 Child::parent 改成 shared_ptr<Parent>after 会保持 2,直观暴露循环拥有。这个计数只能证明被跟踪类型的构造与析构数量是否平衡,不能检测越界、第三方分配和所有泄漏;它适合作为单元测试中的领域不变量,而不是替代内存工具。

6. 排查内存增长应该从哪一步开始?

建议按证据逐步缩小范围:

  1. 建立稳定复现:固定请求序列、并发度和运行时间,确认增长可重复;
  2. 区分阶段:空闲、预热、稳定负载、停止流量后分别观察;
  3. 记录业务数量:连接、任务、缓存条目、队列深度、会话和领域对象数;
  4. 做堆快照差分:比较两个稳定时刻增长最多的类型和分配栈;
  5. 最小化路径:关闭无关模块,缩到能稳定增长的测试;
  6. 运行检测工具:针对 leak、越界或 use-after-free 使用相应工具;
  7. 修复所有权/上限:不要只在退出处清空全局容器掩盖长期增长;
  8. 重复同一负载验证:确认对象数、堆占用和错误报告同时改善。

只看 RSS 很难区分对象仍可达还是分配器保留页面。业务计数与 heap profile 结合,才能判断“内存里究竟多了什么”。

7. Sanitizer 适合发现什么?

Clang/GCC 的 AddressSanitizer(ASan)适合测试中发现:

  • heap/stack/global 越界;
  • use-after-free;
  • 部分 use-after-return/use-after-scope;
  • double free 等分配错误。

常见测试构建命令:

1
2
3
4
clang++ -std=c++20 -O1 -g -Wall -Wextra \
-fsanitize=address,undefined -fno-omit-frame-pointer \
main.cpp -o app
./app

LeakSanitizer(LSan)可在支持的平台/工具链上报告退出时仍未释放且不可达的分配:

1
2
3
clang++ -std=c++20 -O1 -g -fsanitize=address \
-fno-omit-frame-pointer main.cpp -o app
ASAN_OPTIONS=detect_leaks=1 ./app

Leak 检测的可用性和默认行为随操作系统、编译器和运行时变化,尤其应结合当前 Apple 平台或 CI 工具链验证。若运行时不支持,不要把“没有报告”理解成没有泄漏。

Sanitizer 只能检查真正执行到的路径。测试应覆盖正常、提前返回、异常、超时、取消、重试和关闭流程。诊断构建通常更慢、占用更多内存,不应未经评估直接代替生产二进制。

8. Valgrind、Instruments 和堆分析工具怎样分工?

Valgrind Memcheck 常用于 Linux 历史代码,可检测泄漏、非法访问和未初始化值:

1
2
3
4
5
6
valgrind --tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
--error-exitcode=1 \
./app

它无需编译器插桩,但保留 -g 调试信息能得到源码栈;运行可能比原程序慢很多,不适合大规模生产负载。Valgrind 对现代 macOS/Apple Silicon 的适用性有限,通常优先使用 Xcode Instruments 的 Leaks/Allocations、平台采样工具,或在项目支持的 Linux 环境中运行 Valgrind。

Valgrind 常见分类:

分类 含义
definitely lost 已找不到指向分配起点的有效指针,优先修复
indirectly lost 因拥有它的上层分配泄漏而连带丢失
possibly lost 只能找到疑似内部指针,需要人工确认
still reachable 退出时仍可达,不一定是严格泄漏
suppressed 被 suppression 规则隐藏

“still reachable” 不能一概忽略。退出时的进程级缓存可能无害,但长期服务中同一类型持续增长仍是容量问题。

对于 RSS 增长而 leak 工具无报告的情况,heap profiler、分配采样和前后快照通常更有价值,因为它们能展示仍可达对象和热点分配栈。具体命令依赖操作系统、allocator 和构建环境,应以项目当前工具链为准。

9. 容器清空后为什么内存还不下降?

vector::clear() 析构元素并把 size() 变为 0,但通常保留 capacity 供后续复用:

1
2
3
values.clear();
std::cout << values.size(); // 0
std::cout << values.capacity(); // 可能仍很大

这不是泄漏。如果容器很快会再次增长,保留容量能减少分配。如果峰值数据一次性出现、容器之后长期空闲,可以通过销毁并重建容器明确释放其存储:

1
std::vector<Item>{}.swap(values);

shrink_to_fit() 只是非强制请求,不能保证 capacity 降到 size。即使容器把内存归还 allocator,RSS 也可能不立即下降。

对象池、arena 和缓存同样需要容量策略。它们的目的就是保留资源以复用,因此“永不释放”可能符合实现,却不一定符合服务负载。应定义最大条目数、最大字节数、TTL、淘汰方式和压力下的降级策略。

10. 越界为什么会伪装成泄漏或分配器问题?

越界写可能破坏容器、引用计数或堆分配器元数据,随后表现为无法释放、重复释放或在完全无关的 malloc 中崩溃:

1
2
std::vector<int> values{1, 2, 3};
values[3] = 4; // 越界,未定义行为

operator[] 不检查边界。外部输入决定索引时可使用 at(),测试构建配合 ASan 和标准库 hardening/debug 模式;具体 libc++/libstdc++ 开关随版本变化,应遵循项目工具链配置。

发现 heap corruption 后,应先定位最早的非法写,而不是在最终崩溃点修改 allocator。内存问题经常在破坏发生很久后才显现。

11. 工程中最容易踩哪些坑?

误区一:RSS 不降就是 delete 没执行

allocator 缓存、容器 capacity 和碎片都可能保留页面。需要对象数量、堆快照和分配栈证据。

误区二:退出时释放所有全局容器就算修复

进程退出本就由操作系统回收地址空间。长期服务真正关心运行过程中是否有界,退出清理不能修复无界缓存。

误区三:换成 shared_ptr 就不会泄漏

共享引用环和回调捕获仍会让计数无法归零。默认优先单一所有权,并画出强/弱关系。

误区四:工具报告的每一项都来自自己代码

运行时和第三方库可能有已知常驻分配。先保留符号、查看完整调用栈并验证 suppression 来源,不要盲目忽略或修改库代码。

误区五:一次短测试没有报告就证明安全

泄漏可能只在错误路径、取消、长连接和并发竞态中出现。复现负载必须覆盖真实生命周期。

误区六:只修分配点,不检查所有权图

在某条路径补释放可能与另一所有者冲突。先确定谁拥有、何时结束,再用 RAII 让释放只发生一次。

12. 什么时候需要专门的内存管理策略?

普通业务代码优先标准容器和智能指针。只有 profiler 证明通用分配成为瓶颈,或对象确实拥有共同生命周期时,才考虑对象池、arena、std::pmr 和自定义 allocator。

这些策略会改变“释放”的观察方式:单调 arena 的单块 deallocate 通常不回收空间,直到整个资源释放;对象池归还对象后仍保留存储。工具可能把仍由资源拥有的块显示为 reachable,它们需要通过资源总量和生命周期判断,而不是按普通逐对象泄漏处理。

13. 总结

开头 RSS 从 300 MiB 涨到 2 GiB,只说明进程保留的物理内存增加,无法单独证明对象已经泄漏。正确调查必须回答“多出来的是什么、是否仍可达、为什么还活着”。

  1. 真正泄漏、无界保留、容器容量、碎片和内存破坏需要不同证据与修复;
  2. 默认值语义和 unique_ptr,共享关系画清强弱边,是预防泄漏的首要手段;
  3. new/deletenew[]/delete[]malloc/free 必须正确配对,C 资源应立即 RAII 化;
  4. Sanitizer 适合测试路径中的非法访问与支持平台上的 leak,Valgrind/平台 profiler 用于更深定位;
  5. 长期服务要同时监控业务对象数、队列、缓存和 heap profile,不能只看退出报告。

遇到内存曲线上涨时,先给连接、任务、缓存和关键对象加数量指标,再比较两个稳定时刻的堆快照。只要能说出“哪类对象由谁持有、为什么没有结束”,修复就会从猜测变成所有权设计。