为什么存在动态内存分配
我们已经掌握的内存开辟方式有:
int val = 20;
char arr[10] = {0};
但是上述的开辟空间方式有两个特点:
1.空间开辟大小是固定的。
2.数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配。
但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。这时候就只能试试动态内存开辟了。
一、动态内存函数的介绍
malloc和free
C语言提供了一个动态内存开辟的函数:
void* malloc (size_t size);
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
如果开辟成功,则返回一个指向开辟好空间的指针。
如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。(这一点在面试中非常重要)
返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。
C语言提供了另外一个函数free,专门是用来做动态内存的释放和回收的,函数原型如下:
void free (void* ptr);
free函数用来释放动态开辟的内存。
如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
如果参数 ptr 是NULL指针,则函数什么事都不做。
常见的动态内存错误
对动态开辟的空间越界访问
void test()
{
int i = 0;
int *p = (int *)malloc(10*sizeof(int));
if(NULL == p)
{
exit(EXIT_FAILURE);
}
for(i=0; i<=10; i++)
{
*(p+i) = i;//当i是10的时候越界访问
}
free(p);
}
对非动态开辟内存使用free释放
void test()
{
int a = 10;
int *p = &a;
free(p);
}
使用free释放一块动态开辟内存的一部分。原因是p++之后,已经不指向这段内存空间的首地址了,释放了一部分动态内存。
char* p = (char*)malloc(10);
p++;
free(p);
并且,有些编译器(例如GCC)使用malloc的时候,会在malloc返回的首地址的前面若干个字节中存放该内存的长度。若p++,则读取不到正确的长度信息,导致未定义行为。
对同一块动态内存多次释放
void test()
{
int *p = (int *)malloc(100);
free(p);
free(p);//重复释放
}
内存泄漏
如果malloc了之后,没有调用free释放会怎么样呢?
“内存泄漏”。实际开发中,尤其是服务器开发里,特别害怕内存泄漏。典型的原因就是服务器往往是7*24持续运行,运行过程中需要处理很多的请求,每次请求处理时如果出现内存泄漏,会积少成多。内存泄漏到一定程度时,会导致操作系统没有足够的内存可以分配,从而导致程序崩溃。
内存泄漏的问题不怕快,就怕慢,泄露慢就很难定位。因此,常使用“例行重启”来解决这个问题。
C++中引入了“智能指针”,但是不够智能,智能一定程度地缓解内存泄漏问题,不能彻底解决。
相比之下,Java/Python/JS/Go…里面大多都集成了垃圾回收机制(GC),能够更好地解决内存泄漏问题。但是,GC中也存在一个致命的问题 STW “stop the world”。尽管如此,Java中对于STW问题其实是进行了一系列的探索和优化的,可以把STW的时间降低到 < 1ms。
下面我们来看一个典型的内存泄漏的例子:
void* p = malloc(10);
p = malloc(10);
free(p);
第一次调用malloc后,指针p中存放了这段内存空间的首地址;第二次调用malloc,开辟了一段新的内存空间,它的首地址存入p,覆盖了第一段空间的首地址。旧地址“丢了”,也就无法释放。
calloc
C语言还提供了一个函数叫calloc,与malloc的区别在于calloc会在返回地址之前将申请空间的每个字节初始化为零。原型如下:
void* calloc (size_t num, size_t size);
realloc
realloc函数的出现让动态内存管理更加灵活。它可以做到对动态开辟的内存的大小进行调整。函数原型如下:
void* realloc (void* ptr, size_t size);
ptr是要调整的内存地址
size是调整之后的新大小
返回值为调整之后的内存起始位置
如果realloc的时候发现内存的空隙不足以容纳下新增的数
据,此时就会触发搬运操作,在内存中重新找一个更大的,连续的空间,然后把原来的内存的数据给自动的拷贝过去
原内存空间就被自动释放了。
整体搬运:
二、内存区域的划分
一个计算机(操作系统)上,同一时刻运行着很多个程序/进程。操作系统会给每个进程划分一定的内存空间。每个进程的内存空间,又会分成几个大的区域。
按照地址高低可以这样划分:
低地址
代码段:存放二进制指令
数据段:存放全局变量和static修饰的变量
堆:malloc等操作动态申请的内存
栈:存放局部变量和函数之间的调用关系
高地址
对于递归函数来说,如果没有递归结束条件,就会出现栈溢出(stack overflow)的情况。
堆有多大?
内存有多大,堆就有多大(一个程序的内存空间绝大部分都给了堆)。例如256G内存,堆至少能占250G。
栈的空间比较小,堆的空间比较大。
栈上申请释放内存速度极快(本质就是一条机器指令)。堆上申请释放内存很慢(涉及到操作系统内核中的一些工作)
在实际开发中,
如果某个内存较小,并且需要频繁申请释放,使用栈。如果某个内存较大,并且不需要频繁申请释放,使用堆。
如果某个内存较小,也不需要频繁申请释放,都行。
如果某个内存较大,也需要频繁申请释放,只能使用堆。
三、四个经典笔试题
void GetMemory(char *p)
{
p = (char *)malloc(100);
}
void Test(void)
{
char *str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}
解答:
由于p是局部变量,是实参的一份拷贝,因此,对这份拷贝所作的任何操作都无法影响实参。所以,经过GetMemory之后,str仍然是NULL,试图将一个字符串拷贝到空指针里必然会导致内存访问越界。
此外,还有以下问题:
(1)没有free,内存泄漏。
(2)malloc返回结果没有判空。
(3)GetMemory函数的参数没有判空。
char *GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char *str = NULL;
str = GetMemory();
printf(str);
}
解答:
显而易见,p是局部变量,函数执行完之后,p这个变量的内存就被释放了,试图打印的话就是未定义行为。
void GetMemory(char **p, int num)
{
*p = (char *)malloc(num);
}
void Test(void)
{
char *str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}
解答:
有了第1题的经验,我们可以使用二级指针实现开辟空间。由于p是局部变量,是实参的一份拷贝,所以任何对其的直接操作都是没有办法传出函数的范围的。但是对其进行解引用操作可以将变化传出去。因此这份代码是可以成功开辟空间并且打印出hello的。然而,内存泄漏和没有判空的问题依然存在。
void Test(void)
{
char *str = (char *) malloc(100);
strcpy(str, "hello");
free(str);
if(str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
解答:
free操作不会把指针中存放的地址变为NULL,满足判断条件,因此可以打印出world。