前言
本文从“在C/C++开发时选用哪种内存处理方式较好”的角度,聊聊内存的使用。主要还是站在需要开发后台功能的QT开发者的角度来写的总结笔记。
内存分布
我们开发的应用程序运行时,为了确保确保系统稳定性,通常应用程序是在用户态下运行,只能访问操作系统为其分配的用户空间内存。
临时变量、局部变量等临时值,通常存储在用户态的栈空间或寄存器中;
全局变量、静态常量、静态数组等生命周期为整个程序运行期的,通常存储在用户态的静态存储区(数据段或BSS段);
动态数组(如new、malloc、calloc、realloc等申请的内存)、 STL容器(vector、list、map) 等,通常存储在用户态的堆空间中;
通过malloc或new申请动态内存时,尝试从用户态的堆内存池中分配内存,若用户态堆中有足够空闲内存块,则直接返回给应用程序;若用户态的堆内存不足时,C库会通过系统调用(如brk或mmap)向内核申请扩展堆空间,应用程序不需要关注此操作。
通过内存映射文件技术(mmap),申请的内核动态内存,内核会为用户态应用进程分配虚拟地址空间(这个虚拟机地址空间是在用户态地址空间中分配),并建立与物理内存或文件的映射关系。
以下是单个应用进程3类分布区对比(声明:以下表格来源于AI辅助创作)
| 特性 | 栈(Stack) | 静态存储区(Static Area) | 堆(Heap) |
|---|---|---|---|
| 典型应用场景 | 临时变量、局部变量 | 全局变量、静态常量、静态数组 | 动态数据、大对象 |
| 分配方式 | 编译器自动分配/释放 (调整栈指针) | 编译期固定地址分配 | 运行时动态分配(malloc/new) |
| 访问速度 | 最快(仅次于CPU寄存器) | 次之(直接寻址) | 最慢(间接寻址) |
| 生命周期 | 函数调用周期 | 程序运行周期 | 手动控制或GC管理 |
| 内存连续性 | 连续存储 | 连续存储 | 非连续(链表结构) |
| 大小范围 | Linux: 2-10MB Windows: 1-8MB | 单变量/数组不超过几百MB | 理论是所有系统剩余虚拟内存 (申请越多,碎片越严重) |
| 管理开销 | 无(自动管理) | 无(编译期确定) | 高(需维护空闲链表/内存池) |
| 线程安全 | 线程独占(无需同步) | 可能需同步(全局共享) | 需同步(多线程共享) |
| 碎片问题 | 无 | 无 | 易产生碎片 |
| 分配失败后果 | 栈溢出(程序崩溃) | 编译期错误 | 返回NULL(需错误处理) |
开发运用
栈的应用
栈空间是最小的,也是执行最快的,适合存放小型、生命周期短且大小固定的数据,因为有大小限制,如果申请大数据容易导致程序崩溃,例如int arr[1000000](约4MB),可能超出栈默认大小,引发栈溢出。
以下通过读取文件内容的场景来对比
//正确方式:不管文件有多大,每次读取1KB到栈中 std::ifstream file("large_file.bin", std::ios::binary); char buff[1024]; // 1KB栈缓冲区 while (file.read(buff, sizeof(buff))) { // 处理当前块(buff中数据) } //错误方式:10MB栈缓冲区,导致程序崩溃 std::ifstream file("large_file.bin", std::ios::binary); char buff[1024*1024*10]; // 10MB栈缓冲区 while (file.read(buff, sizeof(buff))) { // 处理当前块(buff中数据) }如果硬要申请很多的数据缓存,可以把数组char buff[1024*1024*10];搬到外面作为静态数组用,或者改成vector(动态内存)方式来用,下面给出改成动态内存方式的样例
const size_t buffer_size = 1024 * 1024 * 20; // 20MB缓冲区 std::vector<char> buff(buffer_size); // 堆分配缓冲区 std::ifstream file("large_file.bin", std::ios::binary | std::ios::ate); while (file.read(buff.data(), sizeof(buff))) { // 处理当前块(buff中数据) }总结:临时变量、局部变量不能申请太大的数组缓存,主要是关注大数组,或者是包含大数组的结构体即可。
静态存储区的应用
静态存储区与栈很接近,运行效率稍微比栈低,但空间比栈大,而且作用域是整个全局,如果频繁要用到某个常量/变量/数组,那么就适合设置为全局变量、静态常量、静态数组。
全局变量、静态常量太常用了,这里举个静态数组的场景:处理指定目录下存在待入库到mysql的文件
// 数据结构体 struct stCollectMsg { int id; char name[64]; double value; char timestamp[20]; }; const int BATCH_SIZE = 100; stCollectMsg sqldata[BATCH_SIZE]; // 静态数组 // 文件读取函数 void ReadFile(const char* filename) { std::ifstream file(filename, std::ios::binary); int current_count = 0; while (!file.eof()) { //读取数据 file.read((char*)&sqldata[current_count], sizeof(stCollectMsg)); if (file.gcount() == sizeof(stCollectMsg)) { //数量时,把数据插入数据库 if (++current_count == BATCH_SIZE) { InsertData(conn, sqldata, current_count); current_count = 0; } } } // 处理剩余数据 if (current_count > 0) { InsertData(conn, sqldata, current_count); } } // 数据入库函数 void InsertData(MYSQL* conn, stCollectMsg* data, int count) { std::string query = "INSERT INTO collect_data (id, name, value, timestamp) VALUES "; for (int i = 0; i < count; ++i) { query += "(" + std::to_string(data[i].id) + ", '" + data[i].name + "', " + std::to_string(data[i].value) + ", '" + data[i].timestamp + "')"; if (i != count - 1) query += ","; } if (mysql_query(conn, query.c_str())) { std::cerr << "入库失败: " << mysql_error(conn) << std::endl; } }组装多条数据一次入库,比一条一条入库效率快很多,类似的场景很多。
堆的应用
静态数组能做的,申请堆内存的动态数组也能做,区别是堆上申请的内存是不连续的,导致整体性能没有动态数组和静态数组效率快。
但是堆更灵活,也更大,适合大对象数据,以及规模大的数据量处理。比如视频类数据,都是以G为单位,最好放在动态内存中。
虚拟地址空间
使用mmap进行内存映射时,用户空间会创建虚拟地址区域,同时与物理内存建立映射关系,应用进程以访问虚拟地址的方式来访问真实的物理内存。
以下通过与动态内存对比,来加深了解(声明:以下表格来源于AI辅助创作)
| 对比维度 | mmap内存映射 | 动态内存分配(malloc/free) |
|---|---|---|
| 工作原理 | 将文件/设备映射到进程虚拟地址空间,建立直接访问通道 | 通过系统调用从堆(heap)分配内存块,返回可操作指针 |
| 内存来源 | 文件数据或匿名映射区(不关联文件) | 堆内存空间 |
| 系统开销 | 建立映射开销较大(需修改页表), 适合长期使用的内存区域 | 分配/释放操作轻量,适合频繁变动的内存需求 |
| 管理机制 | 由操作系统内核管理映射关系, 自动处理页错误 | 需程序员手动管理分配/释放,依赖内存管理器实现(如ptmalloc、jemalloc) |
| 性能特点 | • 大文件访问减少拷贝开销 • 缺页中断影响随机访问性能 | • 小内存分配效率高 • 频繁分配/释放可能产生碎片 |
| 内存限制 | 受虚拟地址空间限制(32位系统约3GB用户空间) | 受物理内存+swap空间限制 |
| 线程安全 | 映射区域自动共享,需额外同步机制 | 分配器本身线程安全,但使用需加锁 |
| 典型问题 | • 内存映射对齐要求(通常4KB) • msync同步策略选择 | • 内存泄漏风险 • use-after-free漏洞 • 碎片化导致分配失败 |
| 优势场景 | • 大文件随机访问(数据库索引) • 进程间共享内存 • 零拷贝数据传输 | • 灵活分配任意大小内存 • 实时内存需求处理 • 短生命周期对象 |
| 适用场景 | • 数据库引擎 • 大型媒体文件处理 • 进程间通信 | • 动态数据结构 • 临时缓冲区 • 运行时确定大小的对象 |
总结:mmap在内存映射时开销比较大,处理小数量的内存/数据性能较低,其优势场景是适合大文件读写以及多个进程共享一段内存。
比如,一段高清监控视频,可以结合共享内存+内存映射,提供查看进程、AI分析进程、质量分析进程等多个进程同时访问(这种设计方式比每个进程自己加载一段高清监控视频到自己的动态区域后再进行分析的设计方式节省内存,以及节省IO操作的开销)。
以下给出内存映射与常规IO操作对比表(声明:以下表格来源于AI辅助创作)
对比维度 | mmap操作 | read/write操作 |
|---|---|---|
工作原理 | 将文件直接映射到进程地址空间 | 通过内核缓冲区进行数据中转 |
优势场景 | • 随机访问大文件(数据库/B+树) | • 顺序读写(日志文件) |
性能表现 | • 大文件随机访问快3-5倍 | • 小文件操作快20-30% |
内存影响 | 可能引发大量缺页中断(需预加载策略) | 内核缓冲区可控(可调节buffer大小) |
数据安全 | 依赖msync同步(默认异步写入) | 同步写入保证数据持久化 |
适用文件大小 | >1MB文件效益明显 | <1MB文件效率更优 |
多进程协作 | 天然支持(MAP_SHARED) | 需额外IPC机制 |
复杂度 | 需处理页对齐问题(4KB倍数) | 编程模型简单直接 |
典型应用 | • 数据库引擎 | • 日志收集系统 |
系统限制 | 受限于虚拟内存空间(32位系统最大2-3GB) | 仅受物理存储限制 |