90分钟深入解析Linux内存架构、numa优势、slab实现及vmalloc原理 - C/C++ Linux服务器开发学习指南
推荐视频:

了解 Linux 内存架构需要 90 分钟,要知道 numa 的优势,还要明白 slab 的实现以及 vmalloc 原理。
手写网络协议栈并进行协议封装,利用 netmap 技术,通过 dpdk 网卡进行数据抓取,还涉及柔性数组。
C/C++Linux 服务器开发的学习地址是 C/C++Linux 服务器开发/后台架构师【零声教育】的学习视频教程在腾讯课堂。
一、为什么存在动态内存分配
在之前我们都是这样开辟空间的:
int i = 20; //在栈空间开辟4个字节
在栈空间开辟了 10 个字节的连续空间,该空间由字符数组 arr 表示,数组初始值全部为 0 。
特点
1️、开辟的空间大小是固定的
2️、数组在声明的时候,必需包含常量值 (指定数组长度)
小结
以往开辟空间的方式存在不足,不够灵活,且具有很大的局限性。有时候,我们所需要的空间大小只有在程序运行的过程中才能够知晓。
所以这篇文章主要了解在内存堆上开辟空间所使用的函数
二、动态内存函数的介绍
malloc和free
这两个函数总是一同出现。其中一个函数的作用是开辟内存,另一个函数的作用是释放内存。如果单独使用这两个函数,极有可能会致使程序出现错误。
动态内存开辟的函数malloc
函数原型为 void* 类型,其中包含名为 malloc 的函数,该函数带有一个 size_t 类型的参数 size 。
函数说明
动态内存释放函数free
函数原型 void free (void* ptr);
函数说明
malloc 被声明在 stdlib.h 头文件中,free 也被声明在 stdlib.h 头文件中。接下来我给出一个开辟内存并释放内存的例子:
#include
int main()
{
//动态内存开辟的
创建一个整型指针 p,通过 malloc 函数分配了 10 个整型大小的内存空间,其类型被强制转换为 (int*),这里的 malloc 函数返回的是 void* 类型的指针。
//使用这些空间的时候首先判断空间是否开辟成功
if (p == NULL)
{
在 main 函数中,如果开辟空间失败,就通过 perror 函数来打印错误信息。
return 0;
}
//使用
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;//通过指针加减整数的方式赋初值
}
for (i = 0; i < 10 i printfd p ipi -->p 指向开辟的那块空间的起始地址,(p+i)这两种访问方式是等价的。
//相当于数组名通过数组名加下标的方式访问访问开辟的空间
}
//回收空间
free 函数会释放 p 所指向的内存空间。然而,它不会释放 p 指针内部地址的内容。这样就可能导致 p 能够通过该地址访问之前的内存空间,进而引发内存非法访问的情况。
p = NULL;//所以自己动手把p置为NULL
return 0;
}
这里有几个细节的地方学要注意:
malloc 开辟空间后,free 函数会释放 P 所指向的内存空间。然而,free 函数不会释放 p 指针内部地址的内容。这样就可能导致 p 通过地址再次访问之前的内存空间,从而引发内存非法访问的问题。因此,一定要手动将 P 置为 NULL。
calloc
C 语言提供了一个名为 calloc 的函数。这个 calloc 函数是用于动态内存分配的。
函数的说明为:
举个例子我们使用calloc开辟10个整形空间的大小
#include
int main()
{
//int*p = (int*)malloc(40);
创建一个整型指针 p,通过 calloc 函数分配了 10 个整型大小的内存空间。
if (p == NULL)
return 1;//如果为空直接结束后面的执行
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d\n", *(p + i));
}
free(p);
p = NULL;
return 0;
}
打印calloc开辟空间里面的内容如图:
如果我们对申请的内存空间的内容有初始化的要求,那么可以利用 calloc 函数来轻松完成任务。
文章有福利,若需要 C/C++ Linux 服务器架构师的学习资料,可加群 812855908,资料包含 C/C++、Linux、golang 技术、内核、Nginx、ZeroMQ、MySQL、Redis、fastdfs、MongoDB、ZK、流媒体、CDN、P2P、K8S、Docker、TCP/IP、协程、DPDK、ffmpeg 等。
realloc
realloc 函数的出现使动态内存管理变得更加灵活。有时我们会发现之前申请的空间过小,有时又会觉得申请的空间过大。为了合理地管理内存,我们需要对内存的大小进行灵活调整。而 realloc 函数能够实现对动态开辟内存大小的调整。
函数原型为:存在一种函数,其名为 realloc,该函数具有特定的形式,它接收两个参数,一个参数是 void* 类型的 ptr,另一个参数是 size_t 类型的 size,函数返回值为 void* 类型。
函数说明:
realloc在调整内存空间的是存在两种情况:
情况1:原有空间之后有足够大的空间
情况2:原有空间之后没有足够大的空间
以下面的的代码为例,下图分析两种情况:
情况一:
情况二:
#include
int main()
{
开辟了 3 个大小的整形空间。
if (p == NULL)
{
perror("main");
return 1;
}
//如果这里需要p指向的空间更大,需要6个int的空间
//realloc调整空间
定义一个整型指针 ptr ,通过 realloc 函数重新分配内存空间,将 p 所指向的内存空间大小调整为 6 个整型大小,并将调整后的内存地址强制转换为整型指针赋值给 ptr 。
if (ptr != NULL)
{
p = ptr;
}
//使用
int i = 0;
for (i = 0; i < 6; i++)
{
printf("%d ", *(p + i));
}
//回收空间
free(p);
p = NULL;
return 0;
}
情况 1 时,若要扩展内存,就在原有内存之后直接追加空间,且原来空间的数据保持不变。情况 2 时,若原内存空间后面没有足够空间可开辟,就在堆区重新找一块空间开辟内存,之后会把原来内存中的数据移动到新空间。
常见的动态内存错误
对NULL指针的解引用操作
空指针即不存在指向的指针,不能对其进行解引用操作,也不能进行加减整数等操作,对它的任何操作都是不合法的,会导致程序崩溃。malloc 函数在开辟内存失败时,会返回空指针,因此在使用 malloc 函数开辟的空间之前,要判断是否返回了空指针。
定义了一个预处理指令 _CRT_SECURE_NO_WARNINGS 并将其值设为 1 。 这是为了在某些编译器环境下关闭一些关于安全的警告信息 。 通常在使用一些旧的 C 标准库函数时会用到这个指令 。 它可以避免一些不必要的警告提示 。 使得代码在编译时更加简洁和高效 。
#include
int main()
{
定义一个整型指针 p ,通过 malloc 函数分配了 10000000000000000 个字节的内存空间,并将其强制转换为整型指针类型。
//开辟失败,返回空指针没有判断就使用
//使用
int i = 0;
for (i = 0; i < 6; i++)
{
printf("%d ", *(p + i));
}
}
对动态开辟空间的越界访问
其实这个理解起来并不困难。就如同静态地创建 10 个数组元素那样,你是不能去访问 20 个元素的;就像下面的代码所展示的,malloc 动态地开辟了 10 个整形大小的空间,然而在使用这个空间的时候却去访问了 40 个整形元素,这种情况也是不合法的。
#define _CRT_SECURE_NO_WARNINGS 1
#include
int main()
{
定义一个整型指针 p ,通过 malloc 函数分配了 10 个整型大小的内存空间 ,并将其强制转换为整型指针类型 。
if (p == NULL)
{
return 1;
}
int i = 0;
//越界访问
for (i = 0; i < 40; i++)
{
*(p + i) = i;
}
free(p);
p = NULL;
return 0;
}
对非动态开辟内存使用free释放
通过什么方式创建,就要通过什么方式释放
int main()
{
int arr[10] = { 0 };//栈区
int* p = arr;
//使用
free(p);//使用free释放非动态开辟的空间
p = NULL;
return 0;
}
使用 free 来释放一块动态开辟内存的一部分。在使用 free(p)时,p 必须指向开辟空间的起始位置,只有这样才能释放开辟的整块动态空间。如果 p 由于使用原因发生了移动,那么一定要定义另一个指针来记录 p 的开始指向位置,否则进行的内存释放将只是局部的内存释放。下面代码中利用指针的移动给空间内容进行了赋值,p 进行了移动,然而却没有记录 p 的起始位置。
int main()
{
分配了 10 个 int 类型大小的内存空间,并将其地址存储在指针 p 中。指针 p 指向了一段连续的内存区域,该区域可以存储 10 个 int 类型的值。通过 malloc 函数动态地分配了内存,在使用完这段内存后,需要使用 free 函数来释放它,以避免内存泄漏。
if (p == NULL)
{
return 1;
}
int i = 0;
for (i = 0; i < 5; i++)
{
*p++ = i;
}
free(p);
p = NULL;
return 0;
}
对同一块动态内存多次释放
开辟了一块动态空间,一次释放就可以。然而,有时因为程序较为复杂,这块空间会在多个函数中被使用,这样就有可能会进行多次释放。
int main()
{
int* p = (int*)malloc(100);
//使用
//释放
free(p);
p = NULL;
test()
{
//释放
free(p);
return 0;
}
}
动态开辟内存忘记释放(内存泄漏)
以下 p 为局部变量,在该函数之外就会被销毁。在下面的 main 函数内无法再使用它,之后也无法找到 p 原来所指向空间的内容。在此情况下不能将这块空间释放掉,从而会造成内存泄漏。随着时间的推移,会消耗大量内存。如果服务器中存在内存泄漏,可能会导致该服务器崩溃。
void test()
{
int* p = (int*)malloc(100);
如果 p 等于 NULL ,因为 p 是局部变量,所以在这个函数之外就会被销毁 。
{
return;
}
else
{
//使用
}
一块空间开辟出来供自己使用,使用完毕后一定要记得将其释放。如果在这里不释放,那么在下面就无法将这块空间释放掉,从而会导致内存泄漏。
}
int main()
{
test();//函数调用
//....
return 0;
}
几个经典的笔试题
题目一:
首先思考这个代码有哪些问题??
void GetMemory(char* p)
{
p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}
int main()
{
Test();
return 0;
}
这个代码有两个问题:
1. 对空指针的使用 2. 存在内存泄漏
图示解释
代码修改的两种方法
在函数调用时,传值调用是无法改变实参的大小的,要传地址。
//改1
获取内存,使用二级指针来接收内存。具体为,定义一个函数 void GetMemory(char** p) ,通过这个函数来实现用二级指针接收内存的操作。
{
*p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str);//传一级指针的地址
strcpy(str, "hello world");
printf(str);
free(str);
str = NULL;
}
int main()
{
Test();
return 0;
}
//改:2
函数 GetMemory 中,其参数为一个字符指针 p。该函数会改变其返回值,返回的是一个动态开辟空间的起始地址,这个起始地址的类型为字符指针(char*)。
//{
// p = (char*)malloc(100);
// return p;
//}
//void Test(void)
//{
// char* str = NULL;
// str = GetMemory(str);
将字符串 "hello world" 复制到 str 中。
// printf(str);//?
printf 输出了 "hello world";有一个字符指针 p,它指向了 "hello world" 这个字符串。
// free(str);
// str = NULL;
//}
//int main()
//{
// Test();
// return 0;
//}
题目二:
静态开辟的空间位于栈上,当该空间超出其作用域时就会被销毁。动态开辟的空间是在堆上进行开辟的,对于这种空间,要么需要手动去释放它,要么会在程序结束时自动释放。因此,下面的代码,它随机打印内存中的一些值,但并未达到预先设想的效果。
//GetMemory 函数内部创建的数组是在栈区上创建的
//出了函数,p数组的空间就还给了操作系统
返回的地址本身没有实际意义。如果依据返回的地址去访问内存,那就是在进行非法访问内存的行为。
char* GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory();
printf(str);它没有返回确定的地址,而是随机打印内存中的一些值。
}
题目三
返回&x是无效的,局部变量返回地址是无效的
int test (void)
{
int x = 10;
return (&x);
}
题目四
开辟空间没有free释放掉
获取内存。需要传入一个指向字符指针的指针 p 以及一个整数 num 。通过这个函数可以为字符指针分配指定大小的内存空间。
{
*p = (char*)malloc(num);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}
int main()
{
Test();
return 0;
}
题目六
动态空间开辟好之后,free 会释放所开辟的空间。在这种情况下,维护开辟空间起始位置的指针应该被置为空指针,这几个步骤是紧密相连且不可或缺的。尽管 free(str) 释放了已开辟的空间,然而 str 中仍然存储着开辟空间的起始地址,free 不会释放 str 里面的内容,这会导致非法访问,进而引发程序错误。
void Test(void)
{
这个内存区域用于存储字符数据。
strcpy(str, "hello");
free(str);
//这里可以加上str=NULL就对了
if (str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
int main()
{
Test();
return 0;
}
C/C++程序的内存开辟
C/C++程序的内存分配有几个区域,如下图所示:不同的变量在程序的内存中会占据不同的区域。理解这些变量所在的区域,以及它们的作用域和生命周期,能够帮助我们更好地编写程序。
执行函数时,函数内的局部变量存储单元可在栈上创建。函数执行结束后,这些存储单元会自动被释放。栈内存分配运算被内置于处理器的指令集中,其效率很高。不过,栈区分配的内存容量是有限的。栈区主要用来存放运行函数所分配的局部变量、函数参数、返回数据以及返回地址等。
堆区(heap):通常是由程序员来进行分配和释放的。如果程序员没有去释放它,那么在程序结束的时候,可能会由操作系统(OS)来进行回收。它的分配方式和链表比较类似。
数据段(静态区)(static)用于存放全局变量和静态数据。在程序结束后,这些数据会由系统进行释放。
代码段的作用是存放函数体,这些函数体包括类成员函数和全局函数的二进制代码。
编译生成的可执行代码位于代码段区域,其中包含常量字符串。
也是放在代码段里面的。
借助这幅图,我们可以对 static 关键字修饰的局部变量有更好的理解。
普通的局部变量实际上是在栈区分配空间的。栈区的一个特点是,在栈区上创建的变量,一旦超出了其作用域,就会被销毁。
被 static 修饰的变量存放在数据段(静态区)。在数据段上创建的变量,其特点是直到程序结束才会被销毁。因此,这类变量的生命周期变长了。
柔性数组
柔性数组这个概念我们听闻较少,然而它确实是存在的。在 C99 里,结构中的最后一个元素可以是大小未知的数组,这种情况被称作柔性数组成员。我对柔性数组的理解是,柔性数组是在结构体里的一个成员。它一开始的大小是零。在使用时,依据实际情况的需求,利用动态内存开辟函数来改变它的大小,从而实现数组内容改变的效果。
如:
struct S
{
int n;
int arr[];//大小是未知
int arr[0]; 这种写法大小是未知的。这种所谓的“柔性数组写法”其实是不正确的,因为它不是真正意义上的柔性数组,这种写法本身就是非法的。
};
柔性数组的特点:
如下代码进行结构体的大小的计算:
#include
struct s
{
int i;
int a[];//柔性数组成员
}a;
int main()
{
输出的是 4,即 sizeof(struct s) 的值为 4 ,通过 printf("%d\n", sizeof(struct s) ); 语句来输出。
return 0;
}
下面为柔性数组的简单使用:
代码一
首先数组元素个数为10个,不够时再动态的开辟为20个
#include
struct S
{
int n;//4
int arr[0];//大小是未知
};
int main()
{
//期望arr的大小是10个整形
创建了一个名为 ps 的结构体 S 指针,通过 malloc 函数分配了 sizeof(struct S)加上 10 乘以 sizeof(int)大小的内存空间。
ps->n = 10;
int i = 0;
//使用柔性数组
for (i = 0; i < 10 i ps->arr[i] = i;
}
//增加数组元素个数
首先定义一个指向 struct S 的指针 ptr 。然后通过 realloc 函数对 ps 进行重新分配内存空间,分配的大小为 sizeof(struct S) 加上 20 乘以 sizeof(int) 。最后将重新分配后的内存地址赋值给 ptr 。
if (ptr != NULL)
{
ps = ptr;
}
//使用和上面的一样
//释放
free(ps);
ps = NULL;
return 0;
}
有些老铁或许认为,柔性数组的存在是没必要的。即便没有柔性数组,我们也能够创建一个结构体的数组成员,从而达成动态变化的效果。那么,我们通过下面的代码来简单地进行分析吧,即不使用柔性数组却实现上面柔性数组的功能,然后进行对比。
代码二
#include
struct S
{
int n;
int* arr;
};
int main()
{
分配了一个大小为 struct S 大小的内存空间,并将其地址强制转换为 struct S* 类型,然后将该地址赋值给指针 ps 。
if (ps == NULL)
return 1;
ps->n = 10;
创建一个名为 ps 的指针变量 arr,通过 malloc 函数分配 10 个整型大小的内存空间,并将其强制转换为整型指针类型。
if (ps->arr == NULL)
return 1;
//使用数组
int i = 0;
for (i = 0; i < 10 i ps->arr[i] = i;
}
//增加
定义一个整型指针 ptr,通过 realloc 函数重新分配 ps 所指向结构体中 arr 数组的内存空间,将其大小调整为 20 个整型的大小。
if (ptr != NULL)
{
ps->arr = ptr;
}
//使用和上面的一样只是数组元素改变
//释放
free(ps->arr);
ps->arr = NULL;
free(ps);
ps = NULL;
return 0;
}
通过以上代码和图示进行分析后,要实现柔性数组的功能,我们使用上面的代码会有以下这些问题:
上述代码 1 能够完成与代码 2 同样的功能,然而方法 1 的实现具有两个益处:
第一个好处在于:在内存释放方面较为方便。对于包含柔性数组的空间开辟,仅需一次 malloc 操作;而对于不包含柔性数组的情况,则需要两次 malloc 操作,并且需要两次 free 来释放内存。
第二个好处在于:依据空间局部性原理,这样对访问速度有利。连续的内存既有益于提升访问速度,又有益于减少内存碎片。多次开辟堆上面的空间,会使堆空间残留诸多未被利用的内存块(即内存碎片),从而占用内存,对程序运行产生影响。