环境:CLion2021.3;64位macOS Big Sur
文章目录
地表最强C语言系列传送门:
「地表最强」C语言(一)基本数据类型
「地表最强」C语言(二)变量和常量
「地表最强」C语言(三)字符串+转义字符+注释
「地表最强」C语言(四)分支语句
「地表最强」C语言(五)循环语句
「地表最强」C语言(六)函数
「地表最强」C语言(七)数组
「地表最强」C语言(八)操作符
「地表最强」C语言(九)关键字
「地表最强」C语言(十)#define定义常量和宏
「地表最强」C语言(十一)指针
「地表最强」C语言(十二)结构体、枚举和联合体
「地表最强」C语言(十三)动态内存管理,含柔性数组
「地表最强」C语言(十四)文件
「地表最强」C语言(十五)程序的环境和预处理
「地表最强」C语言(十六)一些自定义函数
「地表最强」C语言(十七)阅读程序
十三、动态内存管理
13.1 为什么存在动态内存分配
举一个简单的例子:张三接触到了数组没多久,想用数组将自己好朋友全部保存起来,于是定义了一个可以存放10个人的数组;过了几天他想试着将全班的人的信息都存放在数组中,于是便重新定义了一个可以存放50个人的数组;又过了几天他想将和他同届的人全部存放在数组中,于是又重新定义了一个可以存放1000人的数组;又过了几天…
就这样不断的折腾,有一天他瘫坐在椅子上心想:要是数组的大小能够动态分配就好了,这样既不会浪费空间,也不会造成空间不足的情况,而且维护起来还很方便。
13.2 动态内存函数的介绍:malloc、free、calloc、realloc
- void* malloc( size_t size )
malloc函数向内存的堆区申请一块连续可用的空间,并返回指向这块空间的指针:
(1)若开辟成功,则返回一个指向开辟好空间的指针
(2)若开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做空指针检查
(3)返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定,即强制类型转换
(4)如果参数size为0,malloc的行为是标准为定义的,取决于编译器
int *p = (int *) malloc(10 * sizeof(int));
//使用这些空间的时候要检查是否为空指针
if (NULL == p) {
perror("main");//若有错误,打印main:xxxxxxx
return 0;
}
//使用
for (int i = 0; i < 10; i++) {
*(p + i) = i;
}
for (int i = 0; i < 10; i++) {
printf("%d ", p[i]);//*(p+i)
}
// 回收空间void free( void* ptr ),和malloc等成对出现
// 如果参数ptr指向的空间不是动态开辟的,那free函数的行为是未定义的
// 如果参数ptr是NULL指针,则函数什么都不做。
free(p);//只是将空间还给操作系统,p中仍然存放着那块空间的地址,为了防止非法访问,最好将p置空
p = NULL;//手动将p置空
- void* calloc( size_t num, size_t size )
// 分配10个int大小的空间
int *p = (int *) calloc(10, sizeof(int));//calloc会对分配的内存初始化,malloc不会;参数不一样,其他和malloc完全一样
if (NULL == p) {
perror("main");
return 1;
}
for (int i = 0; i < 10; i++)
printf("%d ", p[i]);
free(p);
p = NULL;
- void *realloc( void *ptr, size_t new_size )
(1)若原空间后边的空间足够新的空间,直接将后边的空间分配给p,返回原地址p;
(2)若原空间后边的空间不足够开辟新的空间,则会新开辟一块空间,将原空间的数据拷贝到新空间,返回新空间地址,将原空间释放;
(3)若找不到合适的空间来调整大小,则返回空指针。因此不要直接用原指针接收返回值,防止原地址丢失。
int *p = (int *) calloc(10, sizeof(int));//先分配10个int的空间
if (NULL == p) {
perror("main");
return 1;
}
for (int i = 0; i < 10; i++)
*(p + i) = 5;
//若此时需要p指向的空间更大,需要20个int的空间,使用realloc调整空间
// 若原空间后边的空间足够新的空间,直接将后边的空间分配给p,返回原地址p;
// 若原空间后边的空间不足够开辟新的空间,则会新开辟一块空间,将原空间的数据拷贝到新空间,返回新空间地址,将原空间释放;
// 若找不到合适的空间来调整大小,则返回空指针。因此不要用原指针接收返回值
int *ptr = (int *) realloc(p, 20 * sizeof(int));
if (NULL != ptr)
p = ptr;//确定分配成功再赋值给p,方便维护
free(p);
p = NULL;
// realloc(NULL,40)当第一个参数传递空指针时,效果和malloc一样
- void free (void* ptr)
回收申请的空间,一般和上面三个函数成对出现
13.3 常见的动态内存错误
- 对空指针的解引用操作,解决方法:对malloc函数的返回值做判空判断
int* p = (int*)malloc(10000000000000);
for (int i = 0; i < 10; ++i) {
*(p+i) = i;
}
申请的空间太大,导致申请失败,而申请失败则会返回空指针,即p其实是空指针,后又对其解引用,导致错误
- 对动态开辟空间的越界访问
int *p = (int *) malloc(10 * sizeof(int));
if (NULL == p)
return 1;
//越界访问只开辟了10个int型的空间
for (int i = 0; i < 40; ++i) {
*(p + i) = i;//只开辟了10个int型的空间,无法访问到10以后的空间
}
free(p);
p = NULL;
- 对非动态开辟内存使用free释放
int arr[10] = {0};//局部变量,存放在栈区
int* p = arr;
free(p);//err,p不是动态开辟的空间,不能用free释放
p = NULL;
- 使用free释放一块动态开辟内存的一部分
int *p = (int *) malloc(10 * sizeof(int));
if (NULL == p)
return 1;
for (int i = 0; i < 5; ++i) {
*p++ = i;
}
free(p);//此时p已经不指向动态开辟空间的首地址,此时free有两个风险:
//1.只free掉一部分,这是个操作本身就很离谱
//2.动态申请的空间的起始位置无法找到,可能会内存泄漏
p = NULL;
- 对同一块动态内存多次释放
int* p = (int*)malloc(100);
free(p);
free(p);//err,避免方法:第一次释放完后p=NULL;free(NULL)什么事情都不会发生
- 动态开辟内存忘记释放(内存泄漏)
动态开辟的空间只有两种情况才能被销毁:
(1)主动free
(2)程序结束,注意是程序而非函数
void test()
{
int* p = (int*)malloc(100);
if(NULL == p)
return;
//假装在这里使用了p,但是最后没有释放
}
int main()
{
test();//出了这个函数,想释放都无法释放,因为局部变量p已经被销毁,再也无法找到那块儿空间
//这样每次调用test都会泄漏一点儿内存,久而久之内存就会被占满
return 0;
}
13.4 几个经典的笔试题
void GetMemory(char* p)
{//p是str的一份临时拷贝,p改变不会影响str
p = (char*)malloc(100);//出函数后行参p销毁,且没有释放空间,导致内存泄漏
}
void Test(void)
{
char* str = NULL;
GetMemory(str);//值传递,并没有改变str,str仍是空指针
strcpy(str,"Hello World");//往空指针里拷贝,失败
printf(str);
}
int main()
{
Test();
return 0;
}
//改正确1
void GetMemory(char* *p)
{
p = (char*)malloc(100);//出函数后行参p销毁,且没有释放空间,导致内存泄漏
}
void Test(void)
{
char* str = NULL;
GetMemory(&str);
strcpy(str,"Hello World");//往空指针里拷贝,失败
printf(str);
free(str);
str = NULL;
}
//改正确2
char* GetMemory(char* p)
{
p = (char*)malloc(100);//出函数后行参p销毁,且没有释放空间,导致内存泄漏
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory(str);
strcpy(str,"Hello World");//往空指针里拷贝,失败
printf(str);
free(str);
str = NULL;
}
//返回栈空间地址的问题
char *GetMemory(void) {
char p[] = "hello world";//数组是在栈上创建的,出了此函数,这块儿空间就还给了操作系统,即使返回地址,也是没有意义的,若访问就是非法访问
return p;//虽然可以return字符串的首地址,但是出了这个函数后,局部变量p就被销毁了,原空间有可能已经被其他的程序使用,而不是hello world
}
void Test(void) {
char *str = NULL;
str = GetMemory();
printf(str);//非法访问空间
}
int main()
{
Test();
return 0;
}
void GetMemory(char** p,int num)
{
*p = (char*)malloc(num);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str, 100);
strcpy(str,"Hello World");
printf(str);
//使用结束后没有free,导致内存泄漏
}
int main()
{
Test();
return 0;
}
void Test(void)
{
char* str = (char*)malloc(100);
if(str == NULL)
return ;
strcpy(str,"hello");
free(str);
//free不会将str置空,但是已经将空间还给操作系统了,再访问就是非法的
//考察的是free后一定要置空
if(str != NULL)
{
strcpy(str,"world");
printf(str);
}
}
再次说明一下C/C++程序的内存开辟
1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返 回地址等。
2. 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配方式类似于链表。
3. 数据段(静态区)(static)存放全局变量、静态数据。程序结束后由系统释放。
4. 代码段:存放函数体(类成员函数和全局函数)的二进制代码。
13.5 柔性数组
C99中,允许结构体中的最后一个元素是未知大小的数组,这就叫做柔性数组成员。
柔性数组成员前必须至少有一个其他成员;
sizeof计算带有柔性数组的结构体的大小不包括柔性数组的大小;
包含柔性数组成员的结构用malloc函数进行动态内存分配,并且分配的内存大小应该大于结构体的大小,以适应柔性数组的预期大小。
struct S {
int n;
// int arr[];//大小未知,是柔性数组,写法1
int arr[0];//大小未知,是柔性数组,写法2,即方括号内的数字为0或空则是柔性数组
};
int main()
{
struct S s = {0};//实际上不应该这样创建对象,而应该动态内存分配,这里只是为了展示一下大小的计算。
printf("%d ", sizeof(s));//4,说明计算带有柔性数组的结构体大小,计算的结果不包括柔性数组
return 0;
}
柔性数组的使用:
int main()
{
struct S* ps = (struct S*)malloc(sizeof(struct S) + 10*sizeof(int));
//动态分配内存时,柔性数组所需空间要额外计算,原因上边已经说明,这里假设暂时需要10个int的空间
if(ps == NULL)
return 1;
//使用
ps->n = 10;
for(int i = 0; i < 10; i++)
*(ps->arr[i]) = i;
//假设发现数组大小不够用,需要20个int的空间,于是扩容
struct S* tmp = (struct S*)realloc(ps, sizeof(struct S) + 20*sizeof(int));
if(tmp != NULL)
ps = tmp;
//假装在这里使用了扩容后的ps
//使用后释放
free(ps);
ps = NULL;
return 0;
}
其实柔性数组就是可以动态的改变结构体中数组的大小,我掐指一算发现,即使不使用柔性数组,也可以实现相同的功能,只要将结构体内的数组单独动态开辟内存就可以了;而动态内存开辟的空间是放在堆区的,那么对于这个结构体的其他变量,想要也放在堆区的话(根据空间局部性原理,这样可以提高效率,后面总结会说明),结构体也需要动态开辟内存,于是一个想法产生了:
struct S2 {
int n;
int *arr;
};
int main()
{
struct S2* ps2 = (struct S2*)malloc(sizeof(struct S2));
if(ps2 == NULL)
return 1;
ps2->n = 10;
ps2->arr = (int*)malloc(10 * sizeof(int));
for(int i = 0; i < 10; ++i)
ps2->arr[i] = i;
//发现数组不够用,想扩容到20个int的空间
int* tmp = (int*)realloc(ps2->arr,10 * sizeof(int))
if(tmp != NULL)
ps2->arr = tmp;
//假装使用
//使用完毕,释放
free(ps2->arr);//注意先释放数组,若先释放结构体,数组这块儿空间将找不到,导致内存泄漏
ps2->arr = NULL;
free(ps2);
ps2 = NULL;
return 0;
}
对比发现使用柔性数组的好处:
- 内存释放方便:可以看到,不使用柔性数组的时候,结构体的数组成员变量也是需要动态分配的,这样一来就造成了使用结构体需要进行两次内存分配,也就意味着结束时需要调用两次free,这就容易发生错误
- 有利于提高访问速度:实际上,单次动态内存分配分配的是连续的空间,但是多次动态内存分配分配的并不是连续的空间,不知道在哪里分配的,因此多次分配就容易造成内存碎片的产生,这样内存利用率也降低了
内存池:为了提高内存利用率,操作系统为当前程序分配的一块儿内存,此程序就使用这块内存
局部性原理:
(1)空间局部性:当使用一块儿内存的时候,接下来80%的可能会使用此块内存周边的内存,因此采用柔性数组时,整个结构体内的成员的地址都是较为连续的,访问效率也就提高了,而不使用柔性数组,多次动态开辟的空间在内存上不是连续的,影响效率
(2)时间局部性