C++ 从入门到精通之九

大纲

C++ 面向对象深入

new 和 delete 运算符

new 和 delete 的底层干了什么

  • newdelete 使用案例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

class MyTest {

public:
// 构造函数
MyTest() {

}

// 析构函数
~MyTest() {

}
};

int main() {
MyTest* mt = new MyTest();
delete mt;
return 0;
}
  • new 操作的底层实现细节:
1
2
3
4
MyTest* mt = new MyTest(); 
|_____ operator new()
|_____ malloc() // C 语言中的 malloc() 函数,负责分配内存空间
|_____ MyTest::MyTest // 类的构造函数,负责初始化对象
  • delete 操作的底层实现细节:
1
2
3
4
delete mt;
|_____ MyTest::~MyTest // 类的析构函数,负责销毁对象
|_____ operator delete()
|_____ free() // C 语言中的 free() 函数,负责释放内存空间

new 加括号与不加括号的区别

思考问题

在 C++ 中,使用 new 创建对象时,加括号与不加括号的区别是什么?

  • 1、如果是一个空类,两种写法没有任何区别
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

class MyTest {

};

int main() {
// 加括号 ()
MyTest* mt = new MyTest();
// 不加括号 ()
MyTest* mt2 = new MyTest;
return 0;
}
  • 2、如果类中有成员变量,带括号的 new 会将一些和成员变量相关的内存置零,但不是整个对象的内存全部置零
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

class MyTest {
private:
int m_i; // 成员变量
};

int main() {
// 加括号 (),成员变量 m_i 的值为 0
MyTest* mt = new MyTest();
// 不加括号 (),成员变量 m_i 的值为随机数
MyTest* mt2 = new MyTest;
return 0;
}
  • 3、如果类中有成员变量,且有自定义的空构造函数,两种写法没有任何区别
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

class MyTest {

public:
// 空构造函数
MyTest() {

}

private:
int m_i; // 成员变量
};

int main() {
// 加括号 (),成员变量 m_i 的值为随机数
MyTest* mt = new MyTest();
// 不加括号 (),成员变量 m_i 的值为随机数
MyTest* mt2 = new MyTest;
return 0;
}

new 底层实现细节深入探秘

new 额外分配的内存空间

new 的内存分配并不是简单地 “分配几个字节”。实际进行内存分配时,内存分配器通常需要维护额外的管理信息,例如内存块的大小、状态以及空闲块之间的关系等,因此实际占用的内存范围往往会比用户请求的大小更大。以申请一小块内存为例,并不意味着底层只需要处理对应的几个字节,而是在这块内存附近可能还需要维护额外的元数据。此外,new 的工作也不只是分配内存。它还涉及对象的构造;对应的 delete 则需要先调用对象的析构函数,再释放底层内存。因此,C++ 的动态内存管理相比简单的内存分配函数,包含了更多与对象生命周期相关的处理。需要注意的是,具体的内存布局和额外开销取决于编译器、标准库以及底层内存分配器的实现,不能简单认为 new 一定会额外固定增加多少字节。


特别注意

在分配内存时,为了记录和管理分配出去的内存,编译器会额外多分配不少内存,从而造成了浪费;尤其是频繁分配小块内存时,造成的浪费更严重。

如何记录 new 分配的内存大小供 delete 使用

C++ 编译器或运行时库在 new 分配内存时,通常会在用户请求的内存前额外多分配一块空间,用于存储内存块的大小信息,然后返回偏移后的指针;delete 根据指针向前访问这个内存块大小信息,从而知道实际分配的内存大小并释放。new 的底层通常调用 malloc() 来分配内存,而 malloc() 自身在内存块前维护了内存块的大小信息,以便 free() 正确释放内存;这些内存块大小信息由底层内存管理器维护,而不是由 new 本身维护。

  • 理解注意事项:
    • (1) 当调用 malloc(totalSize) 时,底层内存管理器(C 标准库)会记录这块内存的实际大小和管理信息(通常在内存块头部隐藏存储)。
    • (2) 当调用 free(ptr) 时,它会读取自己内部的管理信息来知道这块内存的大小,然后回收,不需要用户手动告诉它大小。
    • (3) 因此在 operator delete 中,用户只需要传给 free() 原始分配的指针,底层内存管理器会自动处理。

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
35
36
37
38
39
40
41
42
43
#include <iostream>

using namespace std;

class MyClass {
public:
// 构造函数
MyClass() {
cout << "MyClass() called" << endl;
}

// 析构函数
~MyClass() {
cout << "~MyClass() called" << endl;
}
};

void test01() {
cout << "---------- test01() ----------" << endl;

// 空类(不包含任何成员变量)占一个字节大小
MyClass mc;
size_t len = sizeof(mc);
cout << "size: " << len << endl; // 输出 1
}

void test02() {
// 分配整型数组
int* iarray = new int[2];

// 释放整型数组,如果这里不执行 delete[],会泄露 16 个字节内存(不同平台存在差异)
delete[] iarray;
}

void test03() {
cout << "---------- test03() ----------" << endl;

// 分配对象数组
MyClass* parray = new MyClass[2];

// 释放对象数组,如果这里不执行 delete[],会泄露 18 个字节内存(不同平台存在差异)
delete[] parray;
}

为什么在上述的 test03() 函数中,如果不手动通过 delete[] 释放对象数组时,会泄露 18 个字节呢?

  • (1) 对象大小

    • MyClass 类只有构造函数和析构函数,没有成员变量,对象本身大小为 1 字节(空类保证每个对象都有唯一地址)。
    • 在大多数 64 位编译器下,空类大小通常是 1 字节。
    • 如果类里有成员变量,例如 int m_i,类的大小通常是 sizeof(int),按 4 或者 8 字节对齐。
  • (2) 数组长度信息

    • 编译器在用 new[] 分配数组时,通常会额外在内存块前额外多分配一块空间来存储数组长度信息,用于告诉 delete[] 需要调用多少次析构函数。
    • 这个数组长度通常是 size_t(在 64 位系统上占 8 字节)。
  • (3) 内存块大小信息

    • new 底层调用 malloc() 分配内存,malloc 自身会在内存块前额外多分配一块空间,用于存储内存块的大小信息,以便 free() 回收内存。
    • 这内存块大小信息通常占 8 ~ 16 字节(依赖系统和实现)。
  • (4) 内存布局示意(简化版)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    +------------------------+ 
    | 内存块大小 = 8 ~ 16 字节 | <-- malloc 内部隐藏头(管理内存块大小)
    +------------------------+
    | 数组长度 size_t = 8 字节 | <-- 编译器存储数组元素数量
    +------------------------+
    | 对象 MyClass[0] = 1 字节 |
    +------------------------+
    | 对象 MyClass[1] = 1 字节 |
    +------------------------+
  • (5) 实际泄露的内存大小

    • 对象数据本身大小:2 × sizeof(MyClass)
    • 额外存储的数组长度信息:sizeof(size_t)(在 64 位系统上占 8 字节)
    • 额外存储的内存块大小信息:约占 8 ~ 16 字节(依赖系统和实现)
  • (6) 举例说明

    • 假设是 64 位系统,空类大小占 1 字节,数组长度(size_t)占 8 字节,内存块大小信息占 8 字节:
      1
      总的内存泄漏大小 ≈ 8 (内存块大小) + 8 (数组长度) + 2 × 1 (对象大小) = 18 字节
    • 如果类有一个 int 成员变量(4 字节),并按 4 字节对齐:
      1
      2
      sizeof(MyClass) = 4字节
      总的内存泄漏大小 ≈ 8 (内存块大小) + 8 (数组长度) + 2 × 4 (对象大小) = 24 字节

为什么在上述的 test02() 函数中,如果不手动通过 delete[] 释放对象数组时,会泄露 16 个字节呢?

  • (1) 对象大小

    • int 在 64 位系统上通常是占用 4 字节(32 位整数)。
    • 数组有 2 个元素,所以对象数据占用:
      1
      2 × sizeof(int) = 2 × 4 = 8 字节
  • (2) 数组长度信息

    • 编译器在用 new[] 分配数组时,通常会额外在内存块前额外多分配一块空间来存储数组长度信息,用于告诉 delete[] 需要调用多少次析构函数。
    • 对于 int 这种基础类型没有析构函数,数组长度信息通常会被编译器优化掉不存储(也就是说不会占用额外字节)。
    • 因此数组长度占用 0 字节。
  • (3) 内存块大小信息

    • new 底层调用 malloc() 分配内存,malloc 自身会在内存块前额外多分配一块空间,用于存储内存块的大小信息,以便 free() 回收内存。
    • 这内存块大小信息通常占 8 ~ 16 字节(依赖系统和实现)。
  • (4) 内存布局示意(简化版)

    1
    2
    3
    4
    5
    6
    7
    +------------------------+ 
    | 内存块大小 = 8 ~ 16 字节 | <-- malloc 内部隐藏头(管理内存块大小)
    +------------------------+
    | 对象 MyClass[0] = 4 字节 |
    +------------------------+
    | 对象 MyClass[1] = 4 字节 |
    +------------------------+
  • (5) 实际泄露的内存大小

    • 对象数据本身大小:2 × sizeof(int)
    • 额外存储的数组长度信息:0 字节(基础类型)
    • 额外存储的内存块大小信息:约占 8 ~ 16 字节(依赖系统和实现)
      1
      总的内存泄漏大小 ≈ 8 (内存块大小) + 2 x 4 (对象大小) = 16 字节

关键点

  • 数组长度信息:由编译器自己存储,用于告诉 delete[] 需要调用多少次析构函数。
  • 内存块大小信息:由 malloc() / 底层内存管理器存储,用于告诉 free() 实际回收的内存大小。
  • delete[] 需要的额外信息

    • 当用 new MyClass[2] 分配数组内存时,编译器必须知道数组里有多少个对象,以便告诉 delete[] 需要调用多少次析构函数。
    • 所以编译器通常会在数组实际内存的前面额外多分配一块空间,用于存储一个 size_t(或者其它形式)记录数组长度,这不是整个内存块的大小,而是数组长度信息。
    • 这块数组长度信息只为 delete[] 调用析构函数而服务,与底层内存管理器(malloc)内部记录的 “内存块大小信息” 是两回事。
  • operator delete / free 需要的额外信息

    • 底层内存管理器(malloc/free)内部会记录每块内存的大小,但这是给 free() 自己用的,不是编译器额外添加给 delete[] 使用的。
    • 用户不需要知道每块内存的大小,也不需要将内存大小传给 free(),因为 free() 会自动查到。

特别注意

在 C++ 中,使用 new[] 分配数组内存后,必须使用 delete[] 释放内存;若误用 delete 释放数组内存,仅当数组元素类型为基础类型或者不含自定义析构函数的类类型时,行为才不是未定义的(通常不会引发错误),否则会导致内存泄漏或程序崩溃。正确写法应始终成对使用 new[]delete[]

重载 new、delete 运算符

类重载 new、delete 运算符

在类内部重载 newdelete 运算符时,operator new 本质上负责分配原始内存,而不是创建对象,因此返回类型使用 void*。对象的构造由后续的构造函数完成,最终 new 表达式才得到 MyClass*。对应地,operator delete 负责释放原始内存,而对象析构由析构函数完成。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <cstdlib>
#include <iostream>
#include <new>

using namespace std;

class MyClass {
public:
// 构造函数
MyClass() {
cout << "MyClass() called" << endl;
}

// 析构函数
~MyClass() {
cout << "~MyClass() called" << endl;
}

// 类内部重载 new 运算符,用于对象分配内存
static void* operator new(size_t size) {
cout << "operator new called, size: " << size << endl;
void* ptr = malloc(size);
if (!ptr) {
throw bad_alloc();
}
return ptr;
}

// 类内部重载 delete 运算符,用于对象释放内存
static void operator delete(void* ptr) {
cout << "operator delete called" << endl;
if (ptr) {
free(ptr);
}
}

// 类内部重载 new[] 运算符,用于数组分配内存
static void* operator new[](size_t size) {
cout << "operator new[] called, size: " << size << endl;
void* ptr = malloc(size);
if (!ptr) {
throw bad_alloc();
}
return ptr;
}

// 类内部重载 delete[] 运算符,用于数组释放内存
static void operator delete[](void* ptr) {
cout << "operator delete[] called" << endl;
if (ptr) {
free(ptr);
}
}

public:
int m_i;
};

int main() {
// 单个对象
MyClass* mc = new MyClass();
delete mc;

cout << "------------------" << endl;

// 对象数组
MyClass* marry = new MyClass[3];
delete[] marry;

return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
operator new called, size: 4
MyClass() called
~MyClass() called
operator delete called
------------------
operator new[] called, size: 20
MyClass() called
MyClass() called
MyClass() called
~MyClass() called
~MyClass() called
~MyClass() called
operator delete[] called

特别注意

  • 在 C++ 中,newdelete 具备对堆上分配的内存进行初始化和释放的能力,这是 malloc()free() 不具备的。
  • 对象构造时,先使用 new 分配对象内存,然后再调用对象的构造函数;对象析构时,先调用对象的析构函数,再调用 delete 释放对象内存。
全局重载 new、delete 运算符

在 C++ 中,全局重载 newdelete 运算符的情况比较少见,因为它们会影响整个程序的内存分配与释放行为。所有通过普通 newdelete 进行的动态内存操作,都可能受到全局重载的影响。因此,全局重载通常需要非常谨慎,不仅要考虑内存分配效率,还要正确处理内存对齐、异常安全、数组分配、线程安全等问题。一旦实现存在 Bug,可能影响程序中的大量代码,甚至导致难以定位的内存错误。实际开发中,如果只是希望针对某一种类型或某一类对象优化内存分配,通常更适合在类内部重载 newdelete 运算符,或者单独实现内存池,而不是直接修改全局的内存分配行为。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <cstdlib>
#include <iostream>
#include <new>

// 重载全局 new 运算符,用于对象分配内存
void* operator new(size_t size) {
return malloc(size);
}

// 重载全局 delete 运算符,用于对象释放内存
void operator delete(void* ptr) {
free(ptr);
}

// 全局重载 new[] 运算符,用于数组分配内存
void* operator new[](size_t size) {
return malloc(size);
}

// 全局重载 delete[] 运算符,用于数组释放内存
void operator delete[](void* ptr) {
free(ptr);
}

class MyClass {
public:
MyClass() {
std::cout << "MyClass::MyClass()" << std::endl;
}

~MyClass() {
std::cout << "MyClass::~MyClass()" << std::endl;
}

private:
int m_i = 0;
};

int main() {
int* p_int = new int(12);
delete p_int;

char* p_chars = new char[3];
delete[] p_chars;

MyClass* p_obj = new MyClass();
delete p_obj;

MyClass* p_obj_arr = new MyClass[2];
delete[] p_obj_arr;

return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
MyClass::MyClass()
MyClass::~MyClass()
MyClass::MyClass()
MyClass::MyClass()
MyClass::~MyClass()
MyClass::~MyClass()
多种 new 运算符重载版本

在 C++ 中,可以重载多个版本的 operator new,只要每个版本的参数列表不同即可(即通过参数个数或参数类型来区分重载)。不过,无论重载哪个版本,其第一个参数都是固定的,必须是 std::size_t 类型,用来表示所要 new 的对象的字节大小(即 sizeof() 的结果)。这也是编译器在调用 operator new 时自动传入的第一个实参,后续参数则由用户在 new 表达式中显式提供(如定位 newvoid* 或自定义的额外参数)。需要注意的是,除了定位 new 重载之外,其他重载只改变内存分配函数本身的行为,并不改变 new 表达式 “先分配内存、再调用构造函数” 的整体语义,因此仍应遵循相应的失败约定,并为对应的 operator delete 提供匹配版本以正确释放内存。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <cstdlib>
#include <iostream>
#include <new>

class MyClass {
public:
MyClass() : m_a(0) {
std::cout << "MyClass::MyClass()" << std::endl;
}

MyClass(const int val) : m_a(val) {
std::cout << "MyClass::MyClass(const int val)" << std::endl;
}

~MyClass() {
std::cout << "MyClass::~MyClass()" << std::endl;
}

// 重载 new 运算符
static void *operator new(size_t size) {
std::cout << "MyClass::operator new(size_t size)" << std::endl;
void *ptr = malloc(size);
if (ptr == nullptr) {
throw std::bad_alloc();
}
return ptr;
}

// 重载 new 运算符
static void *operator new(size_t size, int val) {
std::cout << "MyClass::operator new(size_t size, int val)" << std::endl;
void *ptr = malloc(size);
if (ptr == nullptr) {
throw std::bad_alloc();
}
return ptr;
}

// 重载 delete 运算符
static void operator delete(void *ptr) {
std::cout << "MyClass::operator delete(void *ptr)" << std::endl;
free(ptr);
}

private:
int m_a;
};

int main() {
MyClass *mc1 = new MyClass();
delete mc1;

MyClass *mc2 = new (123) MyClass();
delete mc2;

return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
MyClass::operator new(size_t size)
MyClass::MyClass()
MyClass::~MyClass()
MyClass::operator delete(void *ptr)
MyClass::operator new(size_t size, int val)
MyClass::MyClass()
MyClass::~MyClass()
MyClass::operator delete(void *ptr)

定位 new

定位 new(placement new) 是 C++ 提供的一种在指定内存地址上直接构造对象的方式,语法格式:new (内存地址) 类类型(构造参数)。它不会重新分配内存,而是利用已经准备好的内存空间调用对象的构造函数,因此常用于内存池、对象池、共享内存、预分配内存等场景。需要注意的是,定位 new 只负责在指定内存地址上构造对象,不负责分配或释放内存,对象使用完毕后通常需要显式调用析构函数,再根据内存的实际来源决定是否释放内存。在 C++ 中,只有定位 new 的概念,不存在定位 delete 的概念。

定位 new 的使用
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
35
36
37
38
39
#include <iostream>

class MyClass {

public:
MyClass() : m_a(0) {
std::cout << "MyClass::MyClass()" << std::endl;
}

MyClass(const int val) : m_a(val) {
std::cout << "MyClass::MyClass(const int val)" << std::endl;
}

~MyClass() {
std::cout << "MyClass::~MyClass()" << std::endl;
}

private:
int m_a;
};

int main() {
// 预先分配内存
void *ptr = (void *)new char[sizeof(MyClass)];

// 定位 new,会调用无参构造函数
MyClass *mc = new (ptr) MyClass();

// 或者,定位 new,调用有参构造函数
// MyClass *mc = new (ptr) MyClass(2);

// 显示调用析构函数
mc->~MyClass();

// 释放原始内存
delete[] (char *)ptr;

return 0;
}

程序运行输出的结果如下:

1
2
MyClass::MyClass()
MyClass::~MyClass()

特别注意

在上面的案例代码中,为什么不能直接 delete mc?因为 mc 不是通过普通 new MyClass() 得到的。简而言之,谁负责构造,谁负责销毁;定位 new 只负责在已有内存上构造对象,因此不能使用普通 delete 销毁对象,而应该显式调用对象的析构函数,再手动释放原始内存。值得一提的是,C++ 中构造函数不能主动调用,必须通过对象初始化或定位 new 等方式调用;析构函数可以显式调用。

定位 new 的重载

C++ 中的定位 new(placement new)重载,通常是指为 operator new 重载增加一个额外的 void* 参数版本,其标准形式声明在 <new> 头文件中,作用是在调用者已经提供的、指向某块已分配内存的地址上构造一个对象,它本身并不分配内存,只返回传入的指针,随后由编译器在该内存地址上调用对象的构造函数。它与普通 operator new 重载的关键区别在于:普通 operator new 重载负责从堆上真正分配原始内存并返回地址,失败时按约定抛出 std::bad_alloc 或返回空指针,需要配套的 operator delete 来释放内存;而定位 new 重载只做 “将对象构造在指定内存地址上” 这件事,不负责分配内存,因此不应为它配套调用普通的 operator delete 来释放内存,对象的销毁需要显式调用析构函数,内存的释放则完全由提供该内存地址的代码负责。此外,由于定位 new 的参数列表里带有 void*,它可以被用户自定义重载以接受更多参数(如日志、分配器上下文等),但 C++ 标准库提供的那个 void* 版本是唯一不能被用户替换的;同时,定位 new 也不参与 “内存分配失败抛出 bad_alloc“ 这一常规分配语义,它更多是构造语义的入口,常用于容器、内存池、std::allocator 等需要将内存分配与对象构造分离的场景。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <cstdlib>
#include <iostream>
#include <new>

class MyClass {
public:
MyClass() : m_a(0) {
std::cout << "MyClass::MyClass()" << std::endl;
}

MyClass(const int val) : m_a(val) {
std::cout << "MyClass::MyClass(const int val)" << std::endl;
}

~MyClass() {
std::cout << "MyClass::~MyClass()" << std::endl;
}

// 重载 new 运算符(需要分配内存空间)
static void *operator new(size_t size) {
std::cout << "MyClass::operator new(size_t size)" << std::endl;
void *ptr = malloc(size);
if (ptr == nullptr) {
throw std::bad_alloc();
}
return ptr;
}

// 重载定位 new 运算符(不需要分配内存空间)
static void *operator new(size_t size, void *ptr) {
std::cout << "MyClass::operator new(size_t size, void *ptr)" << std::endl;
// 直接返回传入的内存地址
return ptr;
}

private:
int m_a;
};

int main() {
// 预先分配内存
void *ptr = (void *)new char[sizeof(MyClass)];

// 定位 new,会调用无参构造函数
MyClass *mc = new (ptr) MyClass();

// 或者,定位 new,调用有参构造函数
// MyClass *mc = new (ptr) MyClass(2);

// 显示调用析构函数
mc->~MyClass();

// 释放原始内存
delete[] (char *)ptr;

return 0;
}

程序运行输出的结果如下:

1
2
3
MyClass::operator new(size_t size, void *ptr)
MyClass::MyClass()
MyClass::~MyClass()

内存池的简单实现

案例代码实现

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
#include <cstddef>
#include <iostream>

class MyClass {
public:
MyClass() {
std::cout << "MyClass::MyClass()" << std::endl;
}

~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

static void* operator new(std::size_t size);

static void operator delete(void* phead);

static int m_new_count; // 创建对象计数统计,每 new 一次就统计一次

static int m_malloc_count; // 内存分配计数统计,每 malloc() 一次就统计一次

private:
MyClass* m_next; // 指向下一块空闲内存

static MyClass* m_free_posi; // 总是指向一块可以分配出去的内存(空闲内存)的首地址

static int m_trunk_count; // 一次分配多少倍的该类内存
};

// 静态成员初始化
int MyClass::m_new_count = 0;
int MyClass::m_malloc_count = 0;
MyClass* MyClass::m_free_posi = nullptr;
int MyClass::m_trunk_count = 5; // 一次分配 5 倍的该类内存作为内存池的大小

void* MyClass::operator new(std::size_t size) {
MyClass* tmplink;

// 分配内存
if (m_free_posi == nullptr) {
// 计算真正需要分配的内存大小
size_t realsize = size * m_trunk_count;

// 调用传统的 new 操作,底层会调用 malloc() 分配内存
m_free_posi = reinterpret_cast<MyClass*>(new char[realsize]);

tmplink = m_free_posi;

// 将分配出来的这一大块内存(由多个小块内存组成)链接起来,供后续使用
for (int i = 0; i < m_trunk_count - 1; ++i) {
tmplink->m_next = tmplink + 1;
++tmplink;
}

// 最后一块内存没有指向下一块空闲内存
tmplink->m_next = nullptr;

// 内存分配计数统计
++m_malloc_count;
}

// 记录准备分配出去的内存的首地址
tmplink = m_free_posi;

// 指向下一块可以分配出去的内存(空闲内存)的首地址
m_free_posi = m_free_posi->m_next;

// 创建对象计数统计
++m_new_count;

return tmplink;
}

void MyClass::operator delete(void* phead) {
if (phead == nullptr) {
return;
}

// 将释放的内存重新加入空闲链表
MyClass* p = static_cast<MyClass*>(phead);

p->m_next = m_free_posi;

// m_free_posi 总是指向一块可以分配出去的内存(空闲内存)的首地址
m_free_posi = p;
}

int main() {
MyClass* p1 = new MyClass;
MyClass* p2 = new MyClass;
MyClass* p3 = new MyClass;
MyClass* p4 = new MyClass;
MyClass* p5 = new MyClass;

delete p1;
delete p2;

MyClass* p6 = new MyClass;
MyClass* p7 = new MyClass;

delete p3;
delete p4;
delete p5;
delete p6;
delete p7;

std::cout << "new count: " << MyClass::m_new_count << std::endl;
std::cout << "malloc count: " << MyClass::m_malloc_count << std::endl;

return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MyClass::MyClass()
MyClass::MyClass()
MyClass::MyClass()
MyClass::MyClass()
MyClass::MyClass()
~MyClass()
~MyClass()
MyClass::MyClass()
MyClass::MyClass()
~MyClass()
~MyClass()
~MyClass()
~MyClass()
~MyClass()
new count: 7
malloc count: 1

案例代码分析

使用内存池后,通过 new 创建对象的图解



使用内存池后,通过 delete 销毁对象的图解


案例代码问题

上述案例代码只为演示内存池的简单实现,其中存在以下多个问题;若需要真正实现 C++ 内存池,强烈建议参考 Nginx 内存池源码和 SGI STL 内存池源码的底层实现。

  • 内存块中并没有真正构造 MyClass 对象

    • operator new() 中通过 new char[realsize] 只是申请了一整块原始字节内存,并没有真正构造多个 MyClass 对象。当前实现只是将这块内存划分成多个节点,并利用其中的空间保存 m_next,属于为了演示内存池原理而进行的简化实现。严格的实现需要结合对象生命周期和 placement new 等机制处理。比如,更规范的做法通常是:内存池只管理原始内存,使用 void* / 字节指针维护空闲内存链表;真正分配内存给对象时,再进行对象构造;释放内存时,析构对象,然后把原始内存重新放回内存池中。
  • new char[realsize] 的内存对齐问题

    • 当前内存池直接将 char 数组划分成多个 MyClass 大小的内存块,并转换成 MyClass* 使用。对于当前简单的 MyClass 通常不会出现明显问题,但如果类包含特殊对齐要求的成员变量(如 alignas(64) char data[64];),简单的字节划分可能无法保证每个内存块都满足 MyClass 的对齐要求,那么这种实现就可能出问题。因此从通用内存池设计角度看,不能简单认为 new char[] + reinterpret_cast<MyClass*> 永远安全。
  • 内存池没有真正释放

    • 内存池第一次申请的大块内存使用 new char[] 分配后,后续只是不断重复利用,并没有在内存池生命周期结束时执行对应的 delete[],所以从严格意义上说存在内存泄漏。虽然操作系统最终会回收进程占用的内存,但从 C++ 资源管理角度来看,这仍然属于内存池缺少完整的生命周期管理。
  • 没有考虑多线程

    • 当前实现中的 m_free_posim_new_countm_malloc_count 都是多个线程共享的数据,没有进行线程同步保护。如果多个线程同时执行 newdelete,可能同时修改空闲链表,从而产生数据竞争甚至破坏内存池。因此当前版本只适合单线程环境,多线程场景需要增加锁或采用其他并发内存池方案。

内存池的优化实现

在前面的内存池案例代码中,每个 MyClass 对象都包含一个 m_next 指针。当空闲内存块被分配给对象使用后,m_next 指针不再用于维护空闲链表,这部分的内存空间实际上不会被使用到。为了减少这种额外的成员开销,可以利用嵌入式指针:仅在内存块处于空闲状态时,利用其自身的内存空间保存下一块空闲内存块的地址,从而维护空闲链表。

嵌入式指针概念

在 C++ 中,嵌入式指针(Embedded Pointer)通常指:把一个指针直接存放在空闲内存块内部,用这个指针记录下一个空闲内存块的位置。它常用于内存池、对象池、Free List(空闲链表)等场景。为什么叫 “嵌入式”?因为这个指针不是额外分配的内存,而是直接 “嵌入” 到空闲内存块本身。

  • 假设内存池中有多个空闲内存块:
1
2
3
4
5
+----------------+      +----------------+      +----------------+
| next 指针 | ---> | next 指针 | ---> | next 指针 |
| | | | | nullptr |
+----------------+ +----------------+ +----------------+
空闲块1 空闲块2 空闲块3
  • 每个空闲块的前几个字节(不同平台字节数有差异)不再存放用户对象,而是直接存放一个指针,例如:
1
2
3
struct FreeBlock {
FreeBlock* next;
};

这里的 next 就可以理解为嵌入式指针,完整的案例代码如下:

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
#include <iostream>

class MyTest {
public:
struct FreeBlock {
struct FreeBlock *next; // 嵌入式指针(64 位环境下,一个指针本身占用 8 个字节)
};

private:
int m_i;
int m_j;
int m_k;
};

int main() {
MyTest mytest;
std::cout << sizeof(mytest) << std::endl; // 输出 12

MyTest::FreeBlock *ptemp;
ptemp = reinterpret_cast<MyTest::FreeBlock *>(&mytest); // 将 mytest 对象的首地址给了 ptemp 指针,这个 ptemp 指针就指向了 mytest 对象的首地址

std::cout << sizeof(MyTest::FreeBlock) << std::endl; // 输出 8
std::cout << sizeof(ptemp->next) << std::endl; // 输出 8
ptemp->next = nullptr;

return 0;
}

总结

  • 嵌入式指针是利用空闲内存块自身的空间保存下一个空闲内存块的地址,从而将多个空闲内存块组织成链表。
  • 使用嵌入式指针的前提是,类对象的 sizeof() 大小不能小于嵌入式指针本身的大小,否则无法在空闲内存块中存放该嵌入式指针

案例代码实现

使用嵌入式指针优化内存池的实现

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#include <cstdlib>
#include <iostream>
#include <new>
#include <vector>

// 内存池类
class MyAllocator {
public:
// 析构函数
~MyAllocator() {
// 释放所有已分配的内存空间
for (void* memory_block : m_memory_blocks) {
free(memory_block);
}
}

// 分配内存
void* allocate(size_t size) {
// 要求每个内存块至少要能够存放 Object 中的 next 嵌入式指针
if (size < sizeof(Object)) {
throw std::bad_alloc();
}

Object* tmplink;

// 分配内存空间
if (m_free_posi == nullptr) {
// 计算真正需要分配的内存大小
size_t real_size = size * m_trunk_count;

// 调用 malloc() 真正分配内存
void* memory_block = (Object*)malloc(real_size);

// 判断内存是否分配成功
if (memory_block == nullptr) {
throw std::bad_alloc();
}

// 记录调用 malloc() 返回的原始内存地址
m_memory_blocks.push_back(memory_block);

m_free_posi = (Object*)memory_block;

tmplink = m_free_posi;

// 将分配出来的这一大块内存(由多个小块内存组成)链接起来,供后续使用
for (int i = 0; i < m_trunk_count - 1; ++i) {
tmplink->next = (Object*)((char*)tmplink + size);
tmplink = tmplink->next;
}

// 最后一块空闲内存没有指向下一块空闲内存
tmplink->next = nullptr;
}

// 记录准备分配出去的内存的首地址
tmplink = m_free_posi;

// 指向下一块可以分配出去的内存(空闲内存)的首地址
m_free_posi = m_free_posi->next;

return tmplink;
}

// 回收内存空间
void deallocate(void* phead) {
if (phead == nullptr) {
return;
}

// 将释放的内存重新加入空闲链表
Object* p = (Object*)phead;

p->next = m_free_posi;

// m_free_posi 总是指向一块可以分配出去的内存(空闲内存)的首地址
m_free_posi = p;
}

private:
// 声明在类的内部,让其只能在类内使用
struct Object {
struct Object* next; // 嵌入式指针(64 位环境下,一个指针本身占用 8 个字节)
};

size_t m_trunk_count = 5; // 一次分配多少倍的该类内存
Object* m_free_posi = nullptr; // 总是指向一块可以分配出去的内存(空闲内存)的首地址
std::vector<void*> m_memory_blocks; // 保存每次调用 malloc() 返回的原始内存地址
};

// 普通类
class Student {
public:
// 内存分配器(静态成员)
static MyAllocator allocator;

// 重载 new 运算符
static void* operator new(size_t size) {
return allocator.allocate(size);
}

// 重载 delete 运算符
static void operator delete(void* p) {
allocator.deallocate(p);
}

private:
int age = 0;
int code = 0;
};

// 静态成员初始化
MyAllocator Student::allocator;

int main() {
Student* students[100];

for (int i = 0; i < 10; ++i) {
students[i] = new Student();
std::cout << students[i] << std::endl;
}

for (int i = 0; i < 10; ++i) {
delete students[i];
}

return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
0x232506d1690
0x232506d1698
0x232506d16a0
0x232506d16a8
0x232506d16b0
0x232506d16e0
0x232506d16e8
0x232506d16f0
0x232506d16f8
0x232506d1700

为了更好地复用内存池代码,可以引入宏定义

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#include <cstdlib>
#include <iostream>
#include <new>
#include <vector>

// 内存池类
class MyAllocator {
public:
// 析构函数
~MyAllocator() {
// 释放所有已分配的内存空间
for (void* memory_block : m_memory_blocks) {
free(memory_block);
}
}

// 分配内存
void* allocate(size_t size) {
// 要求每个内存块至少要能够存放 Object 中的 next 嵌入式指针
if (size < sizeof(Object)) {
throw std::bad_alloc();
}

Object* tmplink;

// 分配内存空间
if (m_free_posi == nullptr) {
// 计算真正需要分配的内存大小
size_t real_size = size * m_trunk_count;

// 调用 malloc() 真正分配内存
void* memory_block = (Object*)malloc(real_size);

// 判断内存是否分配成功
if (memory_block == nullptr) {
throw std::bad_alloc();
}

// 记录调用 malloc() 返回的原始内存地址
m_memory_blocks.push_back(memory_block);

m_free_posi = (Object*)memory_block;

tmplink = m_free_posi;

// 将分配出来的这一大块内存(由多个小块内存组成)链接起来,供后续使用
for (int i = 0; i < m_trunk_count - 1; ++i) {
tmplink->next = (Object*)((char*)tmplink + size);
tmplink = tmplink->next;
}

// 最后一块空闲内存没有指向下一块空闲内存
tmplink->next = nullptr;
}

// 记录准备分配出去的内存的首地址
tmplink = m_free_posi;

// 指向下一块可以分配出去的内存(空闲内存)的首地址
m_free_posi = m_free_posi->next;

return tmplink;
}

// 回收内存空间
void deallocate(void* phead) {
if (phead == nullptr) {
return;
}

// 将释放的内存重新加入空闲链表
Object* p = (Object*)phead;

p->next = m_free_posi;

// m_free_posi 总是指向一块可以分配出去的内存(空闲内存)的首地址
m_free_posi = p;
}

private:
// 声明在类的内部,让其只能在类内使用
struct Object {
struct Object* next; // 嵌入式指针(64 位环境下,一个指针本身占用 8 个字节)
};

size_t m_trunk_count = 5; // 一次分配多少倍的该类内存
Object* m_free_posi = nullptr; // 总是指向一块可以分配出去的内存(空闲内存)的首地址
std::vector<void*> m_memory_blocks; // 保存每次调用 malloc() 返回的原始内存地址
};

/////////////////////////////////////////////////////////////////////////////////////////

// 宏定义 - 声明内存池
#define DECLARE_POOL_ALLOCATOR() \
public: \
static MyAllocator allocator; \
void* operator new(size_t size) { \
return allocator.allocate(size); \
} \
void operator delete(void* p) { \
allocator.deallocate(p); \
}

// 宏定义 - 定义内存池
#define IMPLEMENT_POOL_ALLOCATOR(ClassName) \
MyAllocator ClassName::allocator; \

/////////////////////////////////////////////////////////////////////////////////////////

// 普通类
class Student {
DECLARE_POOL_ALLOCATOR()
private:
int age = 0;
int code = 0;
};

IMPLEMENT_POOL_ALLOCATOR(Student)

int main() {
Student* students[100];

for (int i = 0; i < 10; ++i) {
students[i] = new Student();
std::cout << students[i] << std::endl;
}

for (int i = 0; i < 10; ++i) {
delete students[i];
}

return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
0x232506d1690
0x232506d1698
0x232506d16a0
0x232506d16a8
0x232506d16b0
0x232506d16e0
0x232506d16e8
0x232506d16f0
0x232506d16f8
0x232506d1700

案例代码分析

使用嵌入式指针后的内存池存储结构,绿色部分是空闲内存块,紫色部分是嵌入式指针

案例代码问题

上述案例代码只为演示内存池的简单实现,其中存在以下多个问题;若需要真正实现 C++ 内存池,强烈建议参考 Nginx 内存池源码和 SGI STL 内存池源码的底层实现。

  • 内存对齐问题

    • 内存池通过 地址 + size 计算下一个内存块地址,因此需要保证每个内存块的起始地址满足对象的对齐要求。当前 Studentsizeof(Student) 刚好能够满足其内存对齐要求,因此通常不存在问题。但是,如果将 MyAllocator 扩展为一个通用内存池,支持任意数据类型,这就可能无法满足内存对齐要求,此时需要正式考虑 alignof(T),特别是 C++ 17 的过度对齐(Over-Aligned)类型。
  • 没有考虑多线程

    • 当前实现中的 m_free_posim_memory_blocks 都是多个线程共享的数据,没有进行线程同步保护。如果多个线程同时执行 newdelete,可能同时修改空闲链表,从而产生数据竞争甚至破坏内存池。因此当前版本只适合单线程环境,多线程场景需要增加锁或采用其他并发内存池方案。