探秘 C++ 内存管理:从虚拟内存到内存池的深度解析与实战应用

目录

  • [1 内存的理论知识](#1 内存的理论知识)
    • [1.1 内存的定义](#1.1 内存的定义)
    • [1.2 虚拟内存和物理内存](#1.2 虚拟内存和物理内存)
    • [1.3 关键概念](#1.3 关键概念)
  • [2 常用命令](#2 常用命令)
    • [2.1 windows系统](#2.1 windows系统)
    • [2.2 linux](#2.2 linux)
  • [3 cpp/c程序中的内存](#3 cpp/c程序中的内存)
    • [3.1 内存布局](#3.1 内存布局)
    • [3.2 操作内存](#3.2 操作内存)
      • [3.2.1 C语言](#3.2.1 C语言)
      • [3.2.2 c++](#3.2.2 c++)
    • [3.3 内存对齐](#3.3 内存对齐)
    • [3.4 内存泄漏](#3.4 内存泄漏)
    • [3.5 智能指针](#3.5 智能指针)
      • [3.5.1 shared_ptr共享的智能指针](#3.5.1 shared_ptr共享的智能指针)
      • [3.5.2 unique_ptr独占的智能指针](#3.5.2 unique_ptr独占的智能指针)
      • [3.5.3 weak_ptr弱引用的智能指针](#3.5.3 weak_ptr弱引用的智能指针)
  • [4 内存池](#4 内存池)
    • [4.1 内存池基本概念](#4.1 内存池基本概念)
    • [4.2 实现原理](#4.2 实现原理)
    • [4.3 代码实现](#4.3 代码实现)
    • [4.4 应用场景](#4.4 应用场景)

1 内存的理论知识

1.1 内存的定义

在计算机组成原理中,内存(Memory)是计算机系统中的一种重要存储设备,用于存储正在运行的程序和数据,是计算机进行数据处理和运算的临时存储区域.

  • 存储结构与功能 :内存由许多存储单元组成,每个存储单元都有唯一的地址,就像一个巨大的数组。这些存储单元可以存储二进制数据,通常以字节(Byte)为单位进行编址。计算机的中央处理器(CPU)可以通过地址总线来指定要访问的存储单元地址,通过数据总线来读取或写入数据,从而实现对内存中数据的快速访问和操作,以支持计算机程序的运行。
  • 地址总线:地址总线的宽度决定了 CPU 可以寻址的内存容量。例如,32 位的地址总线可以表示 2 32 2^{32} 232个不同的地址,即可以寻址 4GB 的内存空间
  • 数据总线:数据总线用于在 CPU、内存以及其他设备之间传输数据,其宽度决定了每次能够传输的数据位数。
  • 与其他部件的关系 :内存是连接 CPU 和外部存储设备(如硬盘、光盘等)的桥梁。当计算机启动时,操作系统和其他必要的程序会从外部存储设备加载到内存中,CPU 从内存中读取指令和数据进行处理,并将处理结果写回内存。同时,内存也与输入输出设备(如显示器、打印机等)进行数据交互,协调计算机系统各部件之间的工作,确保整个系统的高效运行。

一个程序运行举例

1.可执行文件加载到内存:

操作系统将编译好的 .exe(或 ELF 等格式)文件中的代码和数据加载到内存。

2 . 内存释放与程序结束然后。

具体下面阐述

1.2 虚拟内存和物理内存


前言:
内存(物理内存 / RAM) -(硬件设备)

是什么:电脑里的硬件(比如内存条),临时存储正在运行的程序和数据,断电后数据消失。

作用:程序必须先 "搬到" 内存里才能被 CPU 运行(比如从硬盘加载到内存),相当于 CPU 的 "临时工作台"。
内核空间 -(软件划分的区域)

是什么:操作系统在内存中划分的一块 "禁区",属于软件层面的区域,只有系统核心(内核)能访问。

作用:专门运行操作系统的核心功能,比如管理硬件(如硬盘、网卡)、分配内存、保护系统安全等。

关键特点:普通程序(比如你写的 C++ 程序)不能直接进入这个区域,需要通过系统调用(比如 "申请内存""读写文件")让内核帮忙做事。

内存像 "大仓库",里面放着你正在用的所有东西(你的程序、微信、系统文件等);

内核空间像 "仓库管理员的办公室",只有管理员(操作系统)能进去处理货物调度、安全检查等核心工作,普通用户(你的程序)只能在仓库公共区域活动,需要帮忙时喊管理员(系统调用)。


操作系统有虚拟内存与物理内存的概念。在很久以前,还没有虚拟内存概念的时候,程序寻址用的都是物理地址。程序能寻址的范围是有限的,这取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32也就是4G。并且这是固定的,如果没有虚拟内存,且每次开启一个进程都给4G的物理内存,就可能会出现很多问题:

  • 内存资源浪费与不足:物理内存是有限的,当有多个进程要执行时,都分配 4G 内存,很快会将内存分配完。内存较小的系统中,这种情况会更迅速出现,导致没有得到分配资源的进程只能等待,降低了系统的整体运行效率。
  • 进程数据安全问题:由于指令直接访问物理内存,一个进程就可以修改其他进程的数据,甚至可能修改内核地址空间的数据,这会破坏系统的稳定性和安全性,导致系统崩溃或数据丢失等问题。
  • 程序运行地址错误:内存是随机分配的,进程得到的内存地址可能与程序预期的地址不一致,导致程序运行出现错误。因为程序在编译和链接时通常是基于特定的地址空间进行假设和设置的,实际运行地址的混乱会使程序难以正确执行

一个进程运行时都会得到4G的虚拟内存。这个虚拟内存你可以认为,每个进程都认为自己拥有4G的空间 ,这只是每个进程认为的,但是实际上,在虚拟内存对应的物理内存上,可能只对应的一点点的物理内存,实际用了多少内存,就会对应多少物理内存。

如何映射

基于 MMU 和页表的常规映射

  • MMU 的作用:MMU 是一种硬件组件,它负责将虚拟地址转换为物理地址。当 CPU 访问虚拟地址时,MMU 会根据页表来查找对应的物理地址。页表是一种数据结构,存储在内存中,操作系统会对其进行管理。
  • 分页机制:虚拟内存和物理内存都被划分为固定大小的页(Page) ,例如常见的页大小为 4KB。虚拟地址空间中的页可以映射到物理内存中的页框(Page Frame),也可以暂时不映射,当程序访问未映射的页时,会触发缺页中断,操作系统会从磁盘(如交换空间或文件)中加载相应的页到物理内存中,并更新页表
  • 按需加载:操作系统采用按需加载的策略,只有当程序真正访问某个虚拟页时,才会将其加载到物理内存中。因此,虚拟内存空间通常远大于物理内存空间,虚拟内存中只有一部分会对应到物理内存上,这就实现了虚拟内存到物理内存的稀疏映射。

内存映射文件(MMP)的参与

  • 原理:内存映射文件允许程序将文件的一部分或全部映射到虚拟地址空间,这样程序可以像访问内存一样直接访问文件内容,而无需使用传统的文件 I/O 操作(如 read 和 write)。在使用内存映射文件时,操作系统会将文件的一部分映射到虚拟内存中,而这部分虚拟内存可能会根据需要映射到物理内存上。
  • 场景举例:当一个大型文件被映射到虚拟内存时,操作系统可能只会将文件的一小部分(对应于程序当前访问的区域)加载到物理内存中。当程序访问其他区域时,会触发缺页中断,操作系统会将相应的文件内容从磁盘加载到物理内存中。

程序运行

当每个进程创建的时候,内核会为进程分配4G的虚拟内存,当进程还没有开始运行时,这只是一个内存布局。实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好 (叫做存储器映射)

这个时候数据和代码还是在磁盘上的。当运行到对应的程序时,进程去寻找页表,发现页表中地址没有存放在物理内存上,而是在磁盘上,于是发生缺页异常,于是将磁盘上的数据拷贝到物理内存中。

1.3 关键概念

SWAP

**Swap 空间是磁盘上的一块区域,用于在物理内存不足时,**将内存中暂时不使用的页面数据交换到磁盘上,以腾出物理内存给其他更需要的进程使用。**当被交换出去的页面数据后续又需要使用时,再从 Swap 空间交换回物理内存

**Buffers **

内存中的 buffer(缓冲区)是一块临时存储数据的内存区域,用于在不同设备、组件或程序之间暂存数据,解决数据处理速度不匹配的问题。

核心作用

  • 速度适配:
    比如硬盘读写速度比 CPU 慢很多,当程序读取文件时,数据不会一个字节一个字节地直接传给 CPU,而是先批量读到缓冲区(内存里的一块区域),CPU 再从缓冲区快速读取,减少等待时间。
    类似快递中转站:货车(慢速设备)先把货物堆到中转站(缓冲区),再由工人(CPU)分批快速搬走。
    数据缓冲:
  • 当程序需要发送数据(比如网络传输),会先把数据写入缓冲区,再由硬件(如网卡)慢慢发送,避免程序一直等待发送完成。

总之,buffer 是数据在 "高速处理区"(内存 / CPU)和 "低速设备"(硬盘、网卡等)之间的 "临时中转站",目的是让数据流动更顺畅。

cache

cache(高速缓存)是计算机中为了加速数据访问而设计的 "高速缓冲区
核心作用

  • 速度适配:CPU运行速度极快(纳秒级),而内存(RAM)速度慢得多(百纳秒级),就像"学霸"和"普通学生"的做题速度差异。
  • cache相当于CPU旁边的"小抄本":提前把CPU即将用到的数据从内存(甚至硬盘)复制到cache里,CPU直接从cache读取,避免等待慢速设备。

内存是 "书架"(数据存放在这里,但拿书需要起身走到书架)。 cache 是

"书桌抽屉"(常用的书提前放在抽屉里,伸手就能拿到,不用每次起身)。 CPU

就是你,坐在书桌前做题,抽屉越大、分类越合理(缓存策略越好),做题速度越快

和buffer的区别

特性 cache buffer
目的 加速数据访问(让快设备更快拿到数据) 暂存数据(解决设备间速度不匹配)
位置 硬件层面(CPU内置,或软件模拟) 内存中的普通区域(软件定义)
数据流向 单向(主要是"读加速",数据从慢设备到快设备) 双向(可读可写,比如暂存写入/读取的数据)
例子 CPU缓存、浏览器缓存(软件cache) 文件读写缓冲区、网络接收缓冲区

2 常用命令

2.1 windows系统

ctrl+shift+esc 点开性能

2.2 linux

使用free-h

total used free shared buff/cache available

Mem: 7.7Gi 115Mi 7.5Gi 0.0Ki 89Mi 7.4Gi

Swap: 2.0Gi 0B 2.0Gi
total:表示对应内存区域(物理内存 Mem 或交换空间 Swap )的总容量。

used:已使用的内存容量。

free:当前空闲的内存容量。

shared:多个进程共享的内存容量。

buff/cache:用于缓冲区(buffer)和缓存(cache)的内存容量,buffer 主要用于暂存磁盘读写数据,cache 用于缓存文件数据等以加速后续访问。

available:系统认为可用于启动新进程等的内存容量,它综合考虑了可回收的 buffer/cache 等因素。

Mem(物理内存)

总容量 7.7Gi,已使用 115Mi,空闲 7.5Gi,共享 0.0Ki,缓冲区和缓存共占用 89Mi,系统认为可供新进程使用的有 7.4Gi 。 说明当前系统物理内存使用量较少,空闲内存充足。

Swap(交换空间)

总容量 2.0Gi,已使用 0B,空闲 2.0Gi 。表示系统目前没有使用交换空间,物理内存能够满足当前运行程序的需求。

3 cpp/c程序中的内存

3.1 内存布局

cpp 复制代码
#include <iostream>
#include <vector>

// 全局变量(存储在数据段)
int globalVar = 10;
int uninitializedGlobal;  // 未初始化的全局变量,存储在 BSS 段

// 静态变量(静态存储区,属于数据段)
static int staticVar = 20;

int main() {
    // 局部变量(存储在栈区)
    int localVar = 30;
    
    // 动态分配内存(存储在堆区)
    int* heapVar = new int(40);
    
    // 数组(局部数组,存储在栈区)
    int stackArray[3] = {1, 2, 3};
    
    // vector 内部数据存储在堆区(vector 对象本身在栈区,管理堆内存)
    std::vector<int> heapVector;
    heapVector.push_back(50);
    
    // 调用函数,观察栈帧变化
    int result = localVar + *heapVar;
    
    // 释放堆内存
    delete heapVar;
    
    return 0;
}

c和cpp

  • 代码段(Text Segment):存储程序的机器指令(如 main 函数、全局函数的二进制代码),只读。
  • 数据段(Data Segment):存储已初始化的全局变量(如 globalVar=10、staticVar=20),可读可写。
  • BSS 段(Block Started by Symbol):存储未初始化的全局变量(如 uninitializedGlobal)和未初始化的静态变量,自动初始化为 0。
  • 栈区(Stack):用于存储局部变量(如 localVar、stackArray)、函数参数、返回地址等,由编译器自动管理,遵循 "后进先出" 原则。
  • 堆区(Heap):用于动态内存分配(如 new/delete),由程序员手动管理,内存分配灵活但开销较大。

程序加载后内存布局:

内存高地址

├───────────────┤

│ 堆区(空闲) │

├───────────────┤

│ 栈区(待填充)│

├───────────────┤

│ BSS 段 │ (uninitializedGlobal=0)

├───────────────┤

│ 数据段 │ (globalVar=10, staticVar=20)

├───────────────┤

│ 代码段 │ (程序指令)

└───────────────┘

内存低地址
main 函数执行中

内存高地址

├───────────────┤

│ 堆区(40, 50)│ ← new 分配和 vector 数据

├───────────────┤

│ 栈区 │ ← localVar=30, stackArray={1,2,3}, heapVector对象

├───────────────┤

│ BSS 段 │ (不变)

├───────────────┤

│ 数据段 │ (不变)

├───────────────┤

│ 代码段 │ (不变)

└───────────────┘

内存低地址

内存释放与程序结束

  • 手动释放堆内存:
    执行 delete heapVar; 时,堆区存储 40 的内存被释放,归还给操作系统,但 heapVector 的内存会在其析构时(离开作用域)自动释放(vector 会管理内部堆内存)。
  • 自动释放栈内存:
    main 函数执行完毕后,其栈帧被销毁,所有局部变量(localVar、stackArray、heapVector 对象)的内存被
  • 操作系统自动回收。
    全局 / 静态变量释放:
    程序结束时,数据段和 BSS 段的内存由操作系统统一回收,无需手动处理。

总结 :类和函数变量 非new/malloc/calloc的变量

由栈管理,new/malloc/calloc的变量由堆管理

补充:进程(程序) 线程(函数)协程 的区别

简单来说,进程是程序运行的"独立容器" ,而线程是进程内的"执行单元" 。协程是比线程更轻量的 "用户态执行单元",本质是用户空间内的函数级协作式调度
关系

进程(资源容器)

↓ 包含多个 线程(执行单元,内核调度)

↓ 包含多个 协程(用户态轻量任务,程序自调度)

特性 进程 线程 协程
调度单位 操作系统(内核级调度) 操作系统(内核级调度) 程序自身(用户态调度)
资源分配 独立内存空间、文件句柄等 共享进程资源,独立栈空间 共享线程资源(栈可复用或独立)
上下文切换 开销最大(需切换内核态资源) 开销中等(切换寄存器、栈指针等) 开销最小(仅切换函数调用栈状态)
并发性 多进程并发(需 IPC 通信) 多线程并发(需同步机制如锁) 单线程内协程并发(协作式调度)
创建成本 高(需分配独立内存、初始化资源) 中(共享进程资源,仅创建栈空间) 极低(仅创建函数栈或复用栈)
并行能力 可利用多核(每个进程占一个CPU) 可利用多核(每个线程占一个CPU) 单线程内串行(需配合多线程/进程才能利用多核)
典型场景 强隔离任务(如不同程序独立运行) 程序内多任务(如同时处理网络和UI) 高并发 IO(如数万网络连接处理)

3.2 操作内存

3.2.1 C语言

malloc 函数 (需要强转)
void *malloc(size_t size);

举例使用

int *ptr = (int *)malloc(5 * sizeof(int));

calloc 函数

功能:calloc 函数用于动态分配指定数量和大小的内存块,并将分配的内存初始化为 0。
void *calloc(size_t nmemb, size_t size);

cpp 复制代码
 // 分配能存储 5 个 int 类型元素的内存空间,并初始化为 0
    int *ptr = (int *)calloc(5, sizeof(int));

memset 函数 (取决于变量)
功能 :memset 函数用于将一块内存区域的每个字节都设置为指定的值。
void *memset(void *s, int c, size_t n);

s:指向要设置的内存区域的指针。

c:要设置的值,通常是一个字符(以整数形式表示)。

n:要设置的字节数。

cpp 复制代码
 char str[10];
    // 初始化
    memset(str, 0, sizeof(str));

memcpy 函数 (取决于源地址)

功能:memcpy 函数用于将一块内存区域的内容复制到另一块内存区域。
void *memcpy(void *dest, const void *src, size_t n);

参数:

dest:指向目标内存区域的指针。

src:指向源内存区域的指针。

n:要复制的字节数。

cpp 复制代码
int source[5] = {1, 2, 3, 4, 5};
    int destination[5];
    // 将 source 数组的内容复制到 destination 数组
    memcpy(destination, source, 5 * sizeof(int));

3.2.2 c++

new 运算符的返回值是一个指针

为单个 int 类型对象分配内存

若你想为单个 int 类型的对象分配内存并初始化为 0,

cpp 复制代码
#include <iostream>

int main() {
    // 使用 new 为单个 int 类型对象分配内存并初始化为 0
    int* b = new int(0);
    std::cout << "b 指向的值为: " << *b << std::endl;
    // 释放内存
    delete b;
    return 0;
}
  • 为单个对象分配内存使用 new 为单个对象分配内存时,会调用对象的默认构造函数(如果有的话)来初始化对象。
cpp 复制代码
#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass 构造函数被调用" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass 析构函数被调用" << std::endl;
    }
    void printMessage() {
        std::cout << "Hello from MyClass!" << std::endl;
    }
};

int main() {
    // 使用 new 为单个对象分配内存
    MyClass* obj = new MyClass;
    obj->printMessage();

    // 使用 delete 释放内存
    delete obj;

    return 0;
}
  • 为数组分配内存
    使用 new[] 为数组分配内存时,返回的是一个指向数组首元素的指针,其类型和数组元素的类型一致。示例如下:
cpp 复制代码
#include <iostream>

int main() {
    // 使用 new[] 为 int 数组分配内存
    int* arrPtr = new int[5];

    // 初始化数组元素
    for (int i = 0; i < 5; ++i) {
        arrPtr[i] = i;
    }

    // 输出数组元素
    for (int i = 0; i < 5; ++i) {
        std::cout << arrPtr[i] << " ";
    }
    std::cout << std::endl;

    // 释放数组内存
    delete[] arrPtr;

    return 0;
}

补充 讲一下malloc和new的区别

对比项 malloc new
本质 C语言标准库函数 C++中的运算符
语法和类型安全 需指定分配的字节数,返回void*类型指针,要手动类型转换 只需指定对象类型,自动计算内存大小,返回对应类型指针,无需手动转换
内存初始化 只分配内存,不进行初始化,内存中是随机值 分配内存后,对自定义类型调用构造函数初始化;内置类型可显式初始化
异常处理 内存分配失败时返回NULL指针,需手动检查返回值判断是否成功 内存分配失败时默认抛出std::bad_alloc异常,可用try - catch块捕获处理
内存释放 使用free函数释放,仅标记内存可用,不做额外清理 使用delete(数组用delete[])释放,先调用析构函数清理对象再释放内存
重载 不能被重载 可以被重载,能自定义内存分配行为

3.3 内存对齐

内存对齐指的是将数据存储在特定的内存地址上,这些地址通常是数据类型大小的整数倍。也就是说,每种数据类型在内存中存储时,起始地址要满足一定的规则。

结构体的对齐规则

  • 成员对齐:结构体中的每个成员都要按照其自身的对齐值进行对齐,即每个成员的起始地址必须是其对齐值的整数倍。如果前一个成员的存储结束地址不满足下一个成员的对齐要求,编译器会在它们之间插入填充字节。
  • 结构体整体对齐:结构体的总大小必须是其最大成员对齐值的整数倍。如果结构体的实际大小不满足这个要求,编译器会在结构体的末尾插入填充字节。
cpp 复制代码
#include <stdio.h>

// 定义一个结构体
struct Example {
    char c;    // 1 字节
    int i;     // 4 字节
    short s;   // 2 字节
};

int main() {
    struct Example ex;
    printf("结构体 Example 的大小: %zu 字节\n", sizeof(ex));
    return 0;
}

在这个例子中,struct Example 结构体包含一个 char 类型成员 c、一个 int 类型成员 i 和一个 short 类型成员 s。按照内存对齐规则,c 占 1 字节,由于 i 的对齐值是 4 字节,所以在 c 后面会插入 3 个填充字节,使得 i 的起始地址是 4 的倍数。i 占 4 字节,s 的对齐值是 2 字节,i 结束后地址刚好是 2 的倍数,所以 s 紧接着存储,占 2 字节。此时结构体的实际大小是 1 + 3 + 4 + 2 = 10 字节,但由于结构体整体对齐要求,其总大小必须是最大成员(int 类型,对齐值为 4)的整数倍,所以在 s 后面会再插入 2 个填充字节,最终结构体的大小为 12 字节。 最佳建议:从大到小依次排布

#pragma pack(n)

正常情况下,编译器会依据数据类型的大小与硬件平台的要求对结构体成员进行内存对齐,以此来提升内存访问效率。不过,这样有时会造成内存空间的浪费。#pragma pack 指令能够让你自行设定对齐字节数,从而减少填充字节,节省内存,但可能会降低内存访问效率。

cpp 复制代码
#include <stdio.h>

// 未使用 #pragma pack,采用默认对齐方式
struct DefaultAlign {
    char c;  // 1 字节
    int i;   // 4 字节
    short s; // 2 字节
};

// 使用 #pragma pack(1),按 1 字节对齐
#pragma pack(1)
struct OneByteAlign {
    char c;  // 1 字节
    int i;   // 4 字节
    short s; // 2 字节
};
#pragma pack()

int main() {
    printf("默认对齐方式下结构体大小: %zu 字节\n", sizeof(struct DefaultAlign));
    printf("1 字节对齐方式下结构体大小: %zu 字节\n", sizeof(struct OneByteAlign));
    return 0;
}

补充不同类型的类型大小

数据类型 典型内存大小(32位系统,字节) 典型内存大小(64位系统,字节) 取值范围
bool 1 1 truefalse
char 1 1 -128 到 127(有符号)或 0 到 255(无符号)
unsigned char 1 1 0 到 255
short 2 2 -32,768 到 32,767
unsigned short 2 2 0 到 65,535
int 4 4 -2,147,483,648 到 2,147,483,647
unsigned int 4 4 0 到 4,294,967,295
long 4 8 有符号时范围不同,无符号时范围是有符号的两倍
unsigned long 4 8 无符号时范围是对应有符号类型的两倍
long long 8 8 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
unsigned long long 8 8 0 到 18,446,744,073,709,551,615
float 4 4 单精度浮点数,大约 6 - 7 位有效数字
double 8 8 双精度浮点数,大约 15 - 16 位有效数字
long double 8(部分系统) 12或16(部分系统) 扩展精度浮点数,精度高于 double,不同系统实现不同
指针类型 4 8 用于存储内存地址,指向不同类型时意义不同

3.4 内存泄漏

内存泄漏指的是程序在运行过程中,由于某些原因导致已经分配的内存无法被释放,从而造成系统可用内存不断减少的现象

  • 动态内存分配未释放:在使用如 C 语言中的 malloc、calloc、realloc 或 C++ 中的 new 进行动态内存分配后,如果没有使用对应的 free 或 delete 操作来释放内存,就会造成内存泄漏。
  • 对象生命周期管理不当:在面向对象编程中,如果对象之间存在复杂的引用关系,当对象不再使用时,其引用计数没有正确归零,导致对象无法被销毁,从而造成内存泄漏。
  • 资源未正确释放:除了内存之外,像文件句柄、网络连接、数据库连接等资源在使用完毕后若未正确释放,也会造成类似内存泄漏的资源泄漏问题。

带来的危害

  • 系统性能下降:随着内存泄漏的不断积累,系统可用内存会逐渐减少,这会导致系统频繁进行磁盘交换(将内存中的数据交换到磁盘上的虚拟内存),从而使系统运行速度变慢。
  • 程序崩溃:当可用内存耗尽时,程序可能会因为无法分配到所需的内存而崩溃。
  • 资源耗尽:除了影响程序本身,内存泄漏还可能导致整个系统资源耗尽,影响其他程序的正常运行。

3.5 智能指针

3.5.1 shared_ptr共享的智能指针

std::shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。再最后一个shared_ptr析

构的时候,内存才会被释放。

给一个使用范例(本人觉得 网上那些有点复杂 不适于初学者 不如直接范例)

cpp 复制代码
#include <iostream>
#include <memory>

// 定义一个简单的类
class MyClass {
public:
    MyClass(int value) : data(value) {
        std::cout << "MyClass 构造函数被调用,值为: " << data << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass 析构函数被调用,值为: " << data << std::endl;
    }
    void printData() {
        std::cout << "存储的数据是: " << data << std::endl;
    }
private:
    int data;
};

int main() {
    // 使用 std::make_shared 创建 std::shared_ptr
    std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>(42);
    //std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>(); 无参情况
   //auto ptr2 = std::make_shared<MyClass>();//自动推导

    // 输出引用计数
    std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl;

    // 创建另一个 std::shared_ptr 指向同一个对象
    std::shared_ptr<MyClass> ptr2 = ptr1;

    // 再次输出引用计数
    std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl;
    std::cout << "ptr2 的引用计数: " << ptr2.use_count() << std::endl;

    // 通过 ptr2 调用对象的成员函数
    ptr2->printData();

    // 重置 ptr2,使其不再指向该对象
    ptr2.reset();

    // 输出引用计数
    std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl;

    // 当 ptr1 离开作用域时,对象会被自动销毁
    return 0;
}  
  • 定义类 MyClass:包含构造函数、析构函数和一个成员函数 printData,用于展示对象的创建、销毁过程以及访问对象的数据。
  • 创建 std::shared_ptr:运用 std::make_shared 来创建 std::shared_ptr 类型的 ptr1,并把一个 MyClass 对象初始化为 42。
    引用计数:借助 use_count 方法可以获取当前指向对象的 std::shared_ptr 数量。
  • 共享对象:将 ptr1 赋值给 ptr2,这样两个智能指针就会指向同一个对象,引用计数也会相应增加。
  • 重置智能指针:调用 reset 方法能让 ptr2 不再指向该对象,引用计数会随之减少。
  • 自动内存管理:当 ptr1 离开其作用域时,引用计数变为 0,对象会被自动销毁,从而调用析构函数。

本质上 share_ptr是一个类 ,利用了模板 来构造 或者使用auto 自动推动类型呢

3.5.2 unique_ptr独占的智能指针

unique_ptr是一个独占型的智能指针,它不允许其他的智能指针共享其内部的指针,不允许通过赋值将

一个unique_ptr赋值给另一个unique_ptr

复制代码
unique_ptr<T> my_ptr(new T); // 正确
unique_ptr<T> my_other_ptr = std::move(my_ptr); // 正确

3.5.3 weak_ptr弱引用的智能指针

share_ptr虽然已经很好用了,但是有一点share_ptr智能指针还是有内存泄露的情况,当两个对象相互

使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄漏.(像死锁一样)

cpp 复制代码
#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() {
        std::cout << "A 被销毁" << std::endl;
    }
};

class B {
public:
    // 使用 std::weak_ptr 避免循环引用
    std::weak_ptr<A> a_ptr;
    ~B() {
        std::cout << "B 被销毁" << std::endl;
    }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    // 互相引用
    a->b_ptr = b;
    b->a_ptr = a;

    // 检查 weak_ptr 是否过期
    if (!b->a_ptr.expired()) {
        // 锁定 weak_ptr 获取 shared_ptr
        std::shared_ptr<A> a_shared = b->a_ptr.lock();
        if (a_shared) {
            std::cout << "通过 weak_ptr 成功访问 A 对象" << std::endl;
        }
    }

    return 0;
}    
  • 类的定义:定义了两个类 A 和 B,A 类中有一个 std::shared_ptr类型的成员 b_ptr,B 类中有一个 std::weak_ptr 类型的成员 a_ptr。(设计模式推崇的一种做法)
  • 创建智能指针:在 main 函数中,使用 std::make_shared 创建了 std::shared_ptr 类型的 a 和 std::shared_ptr类型的 b。
  • 互相引用:让 a 指向 b,b 指向 a,形成了一个引用关系。由于 B 类中使用的是 std::weak_ptr 指向 A,所以不会增加 A 对象的引用计数。
  • 检查 std::weak_ptr 是否过期:使用 expired() 方法检查 b->a_ptr 是否过期,如果没有过期,则使用 lock() 方法将 std::weak_ptr 转换为 std::shared_ptr,以便安全地访问 A 对象。
  • 对象销毁:当 main 函数结束时,a 和 b 离开作用域,它们的引用计数减为 0,A 和 B 对象会被正确销毁,避免了循环引用导致的内存泄漏问题。

4 内存池

4.1 内存池基本概念

为什么需要内存池

  • 提升分配效率:常规内存分配(如 malloc/free)涉及系统调用,频繁操作开销大。内存池预先向系统申请大块内存,程序后续分配直接从池内获取,减少系统调用次数,加快分配速度。
  • 减少内存碎片:频繁分配 / 释放小块内存会导致堆内存碎片化,降低内存利用率。内存池集中管理内存,合理分配回收,缓解碎片问题。
  • 特定场景需求:如高频次创建 / 销毁小对象(如游戏中大量临时角色数据),内存池可避免重复分配释放,优化性能。

4.2 实现原理

内存池的核心原理是通过预先分配一大块内存,并对其进行有效的管理,以减少频繁向操作系统申请和释放内存带来的开销,同时降低内存碎片问题。它采用分类管理的策略,将内存请求分为小块内存和大块内存,针对不同类型的内存请求采用不同的处理方式。

  • 小块内存(小于等于 4KB)
    • 预分配与节点管理
      • 内存池初始化时,会预先向操作系统申请一大块连续的内存,将其划分为多个固定大小(如 4KB)的内存块,每个内存块由一个 mp_node_s 结构体来管理。这些结构体可以看作是内存块的 "管家",记录着内存块的使用信息。
      • 每个 mp_node_s 结构体包含 last 指针和 end 指针。last 指针指向当前内存块中尚未分配的起始位置,end 指针指向该内存块的末尾,通过这两个指针可以方便地计算出当前内存块剩余的可用空间。
    • 内存分配
      当有小块内存分配请求时,内存池会从当前的 mp_node_s 节点开始遍历,检查每个节点的剩余空间是否足够。在检查过程中,会使用 mp_align_ptr 函数对内存地址进行对齐操作,以确保分配的内存地址符合特定的对齐要求,提高内存访问效率。
      如果某个节点的剩余空间足够,就会从该节点分配内存,并更新 last 指针,使其指向下一个可用的位置。如果所有节点的剩余空间都不足,内存池会调用 mp_alloc_block 函数来申请一个新的内存块,并将其加入到节点链表中。
      - 内存回收
      对于小块内存的回收,内存池并不会立即将其归还给操作系统,而是将其标记为可用,留在节点中供后续分配使用。这样可以避免频繁的系统调用,提高内存使用效率。
  • 大块内存(大于 4KB)
    - 按需分配
    对于大块内存的请求,内存池不会预先缓存,而是在收到请求时直接调用操作系统的 malloc 函数来分配内存。这样做的好处是可以避免大块内存长期占用内存池的资源,同时也能保证大块内存的分配灵活性。
    分配的大块内存会由 mp_large_s 结构体来管理,该结构体包含一个 alloc 指针,指向实际分配的内存地址,以及一个 next 指针,用于将多个大块内存节点连接成一个链表。
  • 内存释放
    当大块内存不再使用时,内存池会直接调用 free 函数将其归还给操作系统,以释放系统资源。

4.3 代码实现

cpp 复制代码
#include <iostream>
#include <memory>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <fcntl.h>

// 常量定义
constexpr size_t MP_ALIGNMENT = 32;
constexpr size_t MP_PAGE_SIZE = 4096;
constexpr size_t MP_MAX_ALLOC_FROM_POOL = MP_PAGE_SIZE - 1;

// 对齐函数模板
template <typename T>
T mp_align(T n, size_t alignment) {
    return (n + (alignment - 1)) & ~(alignment - 1);
}

template <typename T>
T* mp_align_ptr(T* p, size_t alignment) {
    return reinterpret_cast<T*>((reinterpret_cast<size_t>(p) + (alignment - 1)) & ~(alignment - 1));
}

// 大内存块结构体
struct mp_large_s {
    std::unique_ptr<mp_large_s> next;
    void* alloc;
};

// 节点结构体
struct mp_node_s {
    unsigned char* last;
    unsigned char* end;
    std::unique_ptr<mp_node_s> next;
    size_t failed;
};

// 内存池抽象基类
class MemoryPool {
public:
    virtual ~MemoryPool() = default;
    virtual void reset() = 0;
    virtual void* alloc(size_t size) = 0;
    virtual void* nalloc(size_t size) = 0;
    virtual void* calloc(size_t size) = 0;
    virtual void free(void* p) = 0;
};

// 具体内存池实现类
class DefaultMemoryPool : public MemoryPool {
private:
    size_t max;
    std::unique_ptr<mp_node_s> current;
    std::unique_ptr<mp_large_s> large;
    std::unique_ptr<mp_node_s[]> head;

    void* alloc_block(size_t size);
    void* alloc_large(size_t size);

public:
    DefaultMemoryPool(size_t size);
    ~DefaultMemoryPool() override;
    void reset() override;
    void* alloc(size_t size) override;
    void* nalloc(size_t size) override;
    void* calloc(size_t size) override;
    void free(void* p) override;
};

// DefaultMemoryPool 构造函数
DefaultMemoryPool::DefaultMemoryPool(size_t size) {
    void* rawPool;
    if (posix_memalign(&rawPool, MP_ALIGNMENT, size + sizeof(DefaultMemoryPool) + sizeof(mp_node_s)) != 0) {
        throw std::bad_alloc();
    }
    max = (size < MP_MAX_ALLOC_FROM_POOL) ? size : MP_MAX_ALLOC_FROM_POOL;
    head = std::make_unique<mp_node_s[]>(1);
    current = std::unique_ptr<mp_node_s>(reinterpret_cast<mp_node_s*>(rawPool));
    large = nullptr;
    current->last = reinterpret_cast<unsigned char*>(current.get()) + sizeof(DefaultMemoryPool) + sizeof(mp_node_s);
    current->end = current->last + size;
    current->failed = 0;
}

// DefaultMemoryPool 析构函数
DefaultMemoryPool::~DefaultMemoryPool() {
    while (large) {
        if (large->alloc) {
            std::free(large->alloc);
        }
        large = std::move(large->next);
    }
    while (current) {
        current = std::move(current->next);
    }
}

// 重置内存池
void DefaultMemoryPool::reset() {
    while (large) {
        if (large->alloc) {
            std::free(large->alloc);
        }
        large = std::move(large->next);
    }
    large = nullptr;
    for (auto* h = current.get(); h; h = h->next.get()) {
        h->last = reinterpret_cast<unsigned char*>(h) + sizeof(mp_node_s);
    }
}

// 分配小块内存
void* DefaultMemoryPool::alloc_block(size_t size) {
    unsigned char* m;
    size_t psize = static_cast<size_t>(current->end - reinterpret_cast<unsigned char*>(current.get()));
    if (posix_memalign(reinterpret_cast<void**>(&m), MP_ALIGNMENT, psize) != 0) {
        return nullptr;
    }
    auto new_node = std::make_unique<mp_node_s>();
    new_node->end = m + psize;
    new_node->next = nullptr;
    new_node->failed = 0;
    m += sizeof(mp_node_s);
    m = mp_align_ptr(m, MP_ALIGNMENT);
    new_node->last = m + size;
    auto p = current.get();
    while (p->next) {
        if (p->failed++ > 4) {
            current = std::move(p->next);
        }
        p = p->next.get();
    }
    p->next = std::move(new_node);
    return m;
}

// 分配大块内存
void* DefaultMemoryPool::alloc_large(size_t size) {
    void* p = std::malloc(size);
    if (!p) {
        return nullptr;
    }
    size_t n = 0;
    for (auto* largePtr = large.get(); largePtr; largePtr = largePtr->next.get()) {
        if (!largePtr->alloc) {
            largePtr->alloc = p;
            return p;
        }
        if (n++ > 3) {
            break;
        }
    }
    auto newLarge = std::make_unique<mp_large_s>();
    newLarge->alloc = p;
    newLarge->next = std::move(large);
    large = std::move(newLarge);
    return p;
}

// 分配内存
void* DefaultMemoryPool::alloc(size_t size) {
    if (size <= max) {
        auto p = current.get();
        do {
            auto m = mp_align_ptr(p->last, MP_ALIGNMENT);
            if (static_cast<size_t>(p->end - m) >= size) {
                p->last = m + size;
                return m;
            }
            p = p->next.get();
        } while (p);
        return alloc_block(size);
    }
    return alloc_large(size);
}

// 非对齐分配内存
void* DefaultMemoryPool::nalloc(size_t size) {
    if (size <= max) {
        auto p = current.get();
        do {
            auto m = p->last;
            if (static_cast<size_t>(p->end - m) >= size) {
                p->last = m + size;
                return m;
            }
            p = p->next.get();
        } while (p);
        return alloc_block(size);
    }
    return alloc_large(size);
}

// 分配并清零内存
void* DefaultMemoryPool::calloc(size_t size) {
    void* p = alloc(size);
    if (p) {
        std::memset(p, 0, size);
    }
    return p;
}

// 释放内存
void DefaultMemoryPool::free(void* p) {
    for (auto* largePtr = large.get(); largePtr; largePtr = largePtr->next.get()) {
        if (p == largePtr->alloc) {
            std::free(largePtr->alloc);
            largePtr->alloc = nullptr;
            return;
        }
    }
}

// 内存池工厂类
class MemoryPoolFactory {
public:
    static std::unique_ptr<MemoryPool> createMemoryPool(size_t size) {
        return std::make_unique<DefaultMemoryPool>(size);
    }
};

int main() {
    size_t size = 1 << 12;
    try {
        auto pool = MemoryPoolFactory::createMemoryPool(size);
        for (int i = 0; i < 10; ++i) {
            pool->alloc(512);
        }
        std::cout << "mp_align(123, 32): " << mp_align(24, 32) << ", mp_align(17, 32): " << mp_align(17, 32) << std::endl;
        for (int i = 0; i < 5; ++i) {
            char* pp = static_cast<char*>(pool->calloc(32));
            if (pp) {
                for (int j = 0; j < 32; ++j) {
                    if (pp[j]) {
                        std::cout << "calloc wrong" << std::endl;
                    } else {
                        std::cout << "calloc success" << std::endl;
                    }
                }
            }
        }
        for (int i = 0; i < 5; ++i) {
            void* l = pool->alloc(8192);
            pool->free(l);
        }
        pool->reset();
        for (int i = 0; i < 58; ++i) {
            pool->alloc(256);
        }
    } catch (const std::bad_alloc& e) {
        std::cerr << "Memory allocation failed: " << e.what() << std::endl;
        return 1;
    }
    return 0;
}   

4.4 应用场景

读写缓冲区
定义与原理

读写缓冲区是一种临时存储区域,主要用于在数据的读取和写入过程中缓存数据。它可以平衡数据的读写速度差异,避免频繁的磁盘或网络 I/O 操作,从而提升系统的整体性能。
结合场景分析

假设在一个文件读写的场景中,若没有读写缓冲区,每次读取或写入一个字节都要进行一次磁盘 I/O 操作,这会带来很大的开销。而使用读写缓冲区,程序可以先将数据从磁盘读取到缓冲区,之后再从缓冲区读取数据;写入数据时,先将数据写入缓冲区,等缓冲区满了再一次性写入磁盘。
优势

提升 I/O 效率:减少 I/O 操作的次数,降低 I/O 开销。

平衡速度差异:在高速设备和低速设备之间起到缓冲作用。
二者关系

内存池可以为读写缓冲区提供内存。例如,在一个网络应用程序中,内存池可以预先分配一些内存块作为读写缓冲区,这样在进行网络数据的读写时,就可以直接从内存池中获取缓冲区,而不需要每次都进行内存分配。同时,当缓冲区不再使用时,也可以将其归还给内存池,以便后续复用。

相关推荐
涛ing4 分钟前
【Linux “less“ 命令详解】
linux·运维·c语言·c++·人工智能·vscode·bash
ghost14330 分钟前
C#学习第17天:序列化和反序列化
开发语言·学习·c#
愚润求学33 分钟前
【数据结构】红黑树
数据结构·c++·笔记
xxjiaz38 分钟前
二分查找-LeetCode
java·数据结构·算法·leetcode
nofaluse1 小时前
JavaWeb开发——文件上传
java·spring boot
難釋懷1 小时前
bash的特性-bash中的引号
开发语言·chrome·bash
爱的叹息2 小时前
【java实现+4种变体完整例子】排序算法中【插入排序】的详细解析,包含基础实现、常见变体的完整代码示例,以及各变体的对比表格
java·算法·排序算法
Hello eveybody2 小时前
C++按位与(&)、按位或(|)和按位异或(^)
开发语言·c++
爱的叹息2 小时前
【java实现+4种变体完整例子】排序算法中【快速排序】的详细解析,包含基础实现、常见变体的完整代码示例,以及各变体的对比表格
java·算法·排序算法
6v6-博客2 小时前
2024年网站开发语言选择指南:PHP/Java/Node.js/Python如何选型?
java·开发语言·php