23、C语言内存分配全解析

C语言内存分配全解析

1. 静态与自动对象的区分

在C语言编程中,理解静态对象和自动对象的区别至关重要。函数 f3 f4 非常相似,唯一的区别在于整数的初始值在声明时的指定方式。这种初始化是在对象创建时进行的,而对象的创建时机取决于所使用的内存分配方法,可能是在函数调用时,也可能是在程序首次加载到内存时(即执行前)。

自动对象在每次执行进入其所在的代码块时创建,并在退出该代码块时销毁。以函数 f3 为例,每次调用 f3 时,整数 a 都会被创建并重新初始化,这可以从其始终返回值 1 得到证明。

静态对象则不同,它们仅在程序首次加载到内存时创建(并初始化)一次。因此,函数 f4 第一次调用返回 1 ,第二次调用返回 2 ,依此类推。

以下是示例代码:

#include <stdio.h>
int f1() {  auto int a ; a = 0; return ++a ; }
int f2() {static int s ; s = 0; return ++s ; }
int f3() {  auto int a = 0 ; return ++a ; }
int f4() {static int s = 0 ; return ++s ; }
int main()
{
    printf("f1(): %d %d %d\n", f1(), f1(), f1()) ;
    printf("f2(): %d %d %d\n", f2(), f2(), f2()) ;
    printf("f3(): %d %d %d\n", f3(), f3(), f3()) ;
    printf("f4(): %d %d %d\n", f4(), f4(), f4()) ;
    return 0 ;
}

程序输出:

f1(): 1 1 1
f2(): 1 1 1
f3(): 1 1 1
f4(): 3 2 1

这里需要注意的是,C语言允许函数参数的计算顺序因编译器而异。上述输出的第四行结果表明参数是从右到左处理的,但其他编译器可能会从左到右处理,从而导致值的顺序相反。

2. 对象销毁

“对象销毁”这个术语可能会引起一些误解。实际上,当一个对象“被销毁”时,其占用的内存会被释放,以供其他用途使用。对象的数据可能会在该内存中保留一段时间,直到该内存被重新分配并写入新的数据。

下面的示例展示了自动分配和静态分配的对象在销毁时的不同表现:

#include <stdio.h>
int *f1()
{
    static int s = 12345 ;
    return &s ;
}
int *f2()
{
    auto int a = 12345 ;
    return &a ;
}
void Demo(int *(*f)())
{
    int *p = (*f)() ;
    printf("Address=%08X, Contents=%d\n", p, *p) ;
    printf("Address=%08X, Contents=%d\n", p, *p) ;
    printf("\n") ;
}
int main() { Demo(f1) ; Demo(f2) ; return 0 ;}

程序输出:

Address=0008E88C, Contents=12345
Address=0008E88C, Contents=12345
Address=0000AECC, Contents=12345
Address=0000AECC, Contents=583820

在这个示例中,函数 f1 创建的是静态对象,函数 f2 创建的是自动对象。当 f2 返回时,自动对象被释放,其内存可供其他用途使用。在第一次调用 printf 时,该内存尚未被重新分配或覆盖,因此可以正确获取其值。但在 printf 执行期间,该内存被重新使用,第二次调用 printf 时显示的值已被修改。而静态对象直到程序终止才会被释放,其值保持不变。

3. 动态分配

动态对象通过库函数 malloc 从被称为堆的内存区域分配。通常使用 sizeof 运算符来确定存储所需类型对象所需的内存量,并将结果作为参数传递给 malloc 。如果堆中有足够的未使用空间来满足请求,就会分配该空间,并返回指向分配内存起始位置的指针。这个指针通常会被强制转换并保存到指向所需类型数据的指针中。

当对象不再需要时,程序员有责任通过调用库函数 free 来释放内存,传递给 free 的指针值应与 malloc 最初返回的指针值相同。在释放之前,对象在内存中的位置不会改变。

以下是动态分配内存的示例代码:

#include <stdlib.h> 
/* required to use malloc and free. */
#define NULL 
((void*) 0) 
/* may not be defined in stdlib.h 
*/
...
typedef ... ANYTYPE ;
...
ANYTYPE *p ;
p = (ANYTYPE *) malloc( sizeof(ANYTYPE) ) ; /* object created */
if (p == NULL) Error("Insufficient heap space") ;
...
...   The object is initialized and used here via the pointer p.
...
free(p) ;           /* object destroyed */

动态分配的优点是既提供了静态分配的持久性,又通过重用实现了内存的节约。然而,其缺点是给程序员带来了平衡每次 malloc 调用和相应 free 调用的负担。如果不这样做,可能会导致缓慢的“内存泄漏”,这很难调试。

4. 堆碎片化问题

在多次调用 malloc free 之后,堆中已分配的内存块可能会被太小而无法使用的空闲块分隔开,这种现象称为碎片化。碎片化最终可能导致即使可用的总空闲内存足够,分配请求也会失败。由于已分配的内存块必须保持固定位置, malloc 无法简单地重新排列堆来合并空闲块。

最坏情况下的碎片化发生在已分配的内存块较小且它们之间有较大的空闲块时。给定足够多的块,最大的单个空闲块可能只是总空闲内存的一小部分。这种情况是否会发生取决于块大小的多样性以及程序执行期间它们的分配和释放顺序。

为了应对碎片化问题,有以下一些策略:
- 使用更大的堆 :在大多数情况下,一个实用的解决方案是使用比所需“稍大”的堆,并进行广泛的测试以验证堆大小是否足够。
- 内存分配池 :这是一种常见的消除堆碎片化的策略。内存分配池是一组大小相等的内存块。通常会使用多个池,每个池使用不同的块大小。内存从满足请求的最小块大小的池中分配,即使这意味着分配的内存比请求的多,也会分配整个块。

以下是内存分配池的工作流程mermaid图:

graph LR
    A[程序开始] --> B[确定池参数]
    B --> C[从堆预分配池内存]
    C --> D[使用池分配和释放块]
    D --> E{块大小是否不再需要}
    E -- 是 --> F[将池内存返回堆]
    E -- 否 --> D
    F --> G[程序结束]
5. 可变大小的自动分配(alloca)

减少堆的使用可以减少对碎片化的担忧。从堆中分配动态内存的最常见原因是为那些在执行时大小未知的对象分配内存。如果这个对象只是在函数执行期间作为临时对象使用,我们可以使用库函数 alloca 从栈中分配自动内存,从而避免使用堆。

使用 alloca 有以下优点:
- 速度快 :与堆不同,不需要搜索未分配的足够大小的内存块列表。在大多数情况下,编译器会将 alloca 调用转换为一条简单的机器指令,仅调整栈指针寄存器中的值。
- 无需显式释放 :由于函数返回时总是会恢复栈指针,因此在函数内部分配的任何自动内存都会自动释放,完全消除了内存泄漏的可能性。这在函数中有多个返回语句时特别方便。

然而,使用 alloca 也有一些缺点:
- 不跨函数持久化 :使用 alloca 分配的内存不会在函数之间持久化,即不能在一个函数中分配内存,在另一个函数中释放。
- 无失败指示 :当没有足够的栈空间来满足请求时, alloca 没有失败指示。相反,栈的内容会被简单覆盖,应用程序很可能会崩溃。
- 调用位置限制 :由于 alloca 会修改栈指针,因此不能在另一个函数调用的参数列表中调用它,否则会导致不可预测的结果。

以下是使用 alloca 的示例代码:

FILE *OpenFile(char *name, char *ext, char *mode)
{
    int size = strlen(name) + strlen(ext) + 2 ;
    char *filespec = (char *) alloca(size) ;
    sprintf(filespec, "%s.%s", name, ext) ;
    return fopen(filespec, mode) ;
}
6. 可变大小数组(VSA)

一些C编译器支持使用可变大小数组(VSA)来实现与 alloca 类似的功能。与声明固定维度的数组不同,这些编译器允许在运行时计算数组的维度。在大多数情况下,VSA可以直接替代 alloca 调用,并且具有更简洁的语法。

以下是使用VSA的示例代码:

FILE *OpenFile(char *name, char *ext, char *mode)
{
    char filespec[strlen(name) + strlen(ext) + 2] ;
    sprintf(filespec, "%s.%s", name, ext) ;
    return fopen(filespec, mode) ;
}

虽然 alloca 和VSA都可以用于在运行时分配可变大小的内存,但它们在内存管理上有细微的差别。 alloca 在每次调用时分配内存,但直到调用 alloca 的函数返回时才释放任何已分配的内存。而VSA在进入其所在的代码块时分配内存,并在退出该代码块时释放内存。

以下是两者区别的对比表格:
| 分配方式 | 创建时机 | 释放时机 | 循环内表现 |
| ---- | ---- | ---- | ---- |
| alloca | 每次调用 | 函数返回 | 循环内累积分配 |
| VSA | 进入块 | 退出块 | 每次循环仅一个块 |

7. 递归函数与内存分配

递归函数是直接或间接调用自身的函数。例如,下面的函数将一个数字输出为十六进制字符序列:

void PutHex(unsigned n)
{
    static const char digits[] = "0123456789ABCDEF" ;
    if (n >= 16) PutHex(n / 16) ; /* display most significant digits first */
    putchar(digits[n % 16]) ; /* then display least significant digit. */
}

函数参数和其他临时对象一样,使用自动内存分配。每次函数调用自身时,会计算一组新的参数值,并在内存中分配新的位置来存储这些值。因此,在一系列递归调用的最深嵌套级别,会创建并共存多个不同的参数集。随着函数通过反向返回调用退出这些级别,为每个参数集分配的内存会被释放。

需要注意的是,上述代码中的十六进制数字字符数组使用了静态分配。这确保了只有一个数组实例存在,因为静态对象仅在程序加载到内存时分配一次。

8. 不同内存分配方式总结

为了更清晰地理解各种内存分配方式,我们可以通过以下表格进行总结:
| 分配方式 | 创建时机 | 实例数量 | 销毁时机 | 初始化时机 | 分配位置 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| 自动(auto) | 进入函数或块 | 多次调用创建多个 | 退出函数或块 | 进入函数或块 | 栈 |
| 静态(static) | 程序加载 | 单个 | 程序终止 | 程序加载 | 数据段 |
| 可变大小数组(VSA) | 进入块 | 每次循环一个 | 退出块 | 进入块 | 栈 |
| 动态(malloc) | 调用malloc | 按需创建 | 调用free | 由程序员决定 | 堆 |
| alloca | 调用alloca | 多次调用创建多个 | 函数返回 | 调用alloca | 栈 |

9. 内存分配相关问题解答
9.1 表格填充问题

根据前面所讲的各种内存分配方式的特点,我们来填充下面这个表格:
| 分配方式 | 创建(分配)时间 | 多次调用函数创建对象实例数量 | 销毁(释放)时间 | 初始化时间 | 分配位置 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| auto | 进入函数或块 | 多个 | 退出函数或块 | 进入函数或块 | 栈 |
| static | 程序加载 | 单个 | 程序终止 | 程序加载 | 数据段 |
| VSA | 进入块 | 每次循环一个 | 退出块 | 进入块 | 栈 |
| 动态 | 由程序员决定(调用malloc时) | 按需创建 | 由程序员决定(调用free时) | 由程序员决定 | 堆 |
| alloca | 调用alloca | 多次调用创建多个 | 函数返回 | 调用alloca | 栈 |

9.2 代码修正问题

下面的代码编译时没有致命错误,但执行结果不符合预期:

int *AddressOfCopy(int value)
{
    int copy = value;
    return &copy;
}

问题在于 copy 是一个自动变量,当函数返回时,它的内存会被释放。返回它的地址会导致悬空指针。修正后的代码如下:

#include <stdlib.h>

int *AddressOfCopy(int value)
{
    int *copy = (int *)malloc(sizeof(int));
    if (copy != NULL) {
        *copy = value;
    }
    return copy;
}

这里使用 malloc 动态分配内存,确保在函数返回后内存仍然有效。

9.3 各种内存分配方式特点问题

以下是对不同内存分配方式相关问题的解答:
- (a) 支持通过重用节约内存的方法 :动态分配(malloc)和可变大小数组(VSA)支持通过重用节约内存。动态分配可以在对象不再使用时释放内存供其他对象使用;VSA在退出块时释放内存,可被后续循环使用。
- (b) 可以在所有函数外部使用的方法 :静态分配(static)可以在所有函数外部使用,用于定义全局静态变量。
- (c) 在程序加载后但执行前进行初始化的方法 :静态分配(static)在程序加载时进行初始化,即在程序执行前完成。
- (d) 在执行期间可能导致同一变量多次初始化的方法 :自动分配(auto)和可变大小数组(VSA)在每次进入函数或块时会重新初始化变量。
- (e) 需要调用函数来分配内存的方法 :动态分配(malloc)和alloca需要调用相应的函数(malloc和alloca)来分配内存。
- (f) 在程序执行期间无形销毁对象的方法 :自动分配(auto)和可变大小数组(VSA)在退出函数或块时会自动销毁对象;alloca在函数返回时自动释放内存。
- (g) 可以在同一函数的多次调用之间保持对象值的方法 :静态分配(static)可以在同一函数的多次调用之间保持对象的值,因为它只在程序加载时初始化一次。
- (h) 使用不当可能导致“内存泄漏”的方法 :动态分配(malloc)如果使用不当,忘记调用 free 释放内存,会导致内存泄漏。
- (i) 阻止对对象应用“取地址”运算符的方法 :寄存器分配(register)不允许对对象应用“取地址”运算符,因为寄存器变量存储在CPU寄存器中,没有内存地址。
- (j) 一对花括号内的默认分配方式 :自动分配(auto)是一对花括号内的默认分配方式。
- (k) 可能被编译器隐式转换为自动分配的方法 :寄存器分配(register)如果编译器无法将变量分配到寄存器中,可能会将其隐式转换为自动分配。
- (l) 需要通过指针间接引用已分配对象的方法 :动态分配(malloc)和alloca通常需要通过指针间接引用已分配的对象。
- (m) 在函数内部不可用的分配方式 :没有明确在函数内部不可用的分配方式,但寄存器分配(register)在某些情况下可能受到限制。
- (n) 用于函数参数的分配方式 :函数参数使用自动分配(auto)。

9.4 内存需求比较问题

我们来比较以下三组程序的内存需求:

第一组

void f1(void) {auto char a[100000]; }
void f2(void) {auto char a[100000]; }
int main(void) { f1(); f2(); return 0; }

在这个程序中, f1 f2 中的数组都是自动分配,每次函数调用时在栈上分配内存,函数返回时释放。所以最大内存需求是100000字节。

第二组

void f1(void) {static char  a[100000]; }
void f2(void) {static char  a[100000]; }
int main(void) { f1(); f2(); return 0; }

这里的数组是静态分配,在程序加载时分配内存,只分配一次。所以总的内存需求是200000字节。

第三组

int main(void)
{
    char *p;
    p = (char *) malloc(100000);  free(p);
    p = (char *) malloc(100000);  free(p);
    p = (char *) malloc(100000);  free(p);
}

动态分配每次分配100000字节,但由于每次分配后都释放了内存,所以最大内存需求是100000字节。

因此,第二组程序需要的内存最多。

9.5 另一组内存需求比较问题

同样地,我们比较以下三组程序的内存需求:

第一组

void f1(void) {auto char  a[100000]; }
void f2(void) {auto char  a[100000]; }
int main(void) { f1(); f2();  return 0; }

最大内存需求是100000字节。

第二组

void f1(void) {auto char  a[100000]; }
void f2(void) {auto char  a[100000]; f1(); }
int main(void) { f2(); return 0; }

f2 调用 f1 时, f2 的数组还在栈上,此时栈上会同时存在两个100000字节的数组,所以最大内存需求是200000字节。

第三组

void f1(void) {auto char a[100000]; } 
void f2(void) {auto char b[100000]; } 
void f3(void) {auto char  c[100000]; } 
int main(void) { f1(); f2(); f3(); return 0; }  

每次只有一个函数的数组在栈上,所以最大内存需求是100000字节。

综上,第二组程序需要的内存最多。

9.6 栈空间分配问题

考虑以下C程序,分析程序执行到各个编号点时栈空间的分配情况:

int main(void) 
{ 
    ➊ 
    f1(1000) ; 
    ➎ 
    if (...) 
    { 
        char array[1000] ; 
        ➏ 
        f2(1000) ; 
    } 
    ➓ 
    return 0 ;
}  

void f1(int bytes) 
{ 
    static int k ; 
    for (k = 1; k <= 2; k++) 
    { 
        char array[k*bytes] ; 
        ➋ 
    } 
    ➌ 
    for (k = 1; k <= 2; k++) 
    { 
        char *p = alloca(k*bytes) ; 
        ➍ 
    }
}  

void f2(int bytes)
{
    static int k ;
    for (k = 1; k <= 2; k++)
    {
        char *p = alloca(k * bytes) ;
        ➐
    }
    ➑
    for (k = 1; k <= 2; k++)
    {
        char array[k * bytes] ;
        ➒
    }
}
  • ➊:进入 main 函数,栈空间分配为0字节。
  • ➋:在 f1 的第一个循环中, k = 1 时, array 大小为1000字节,栈空间分配为1000字节; k = 2 时,栈空间分配为2000字节。
  • ➌:第一个循环结束,栈空间释放,栈空间分配为0字节。
  • ➍:在 f1 的第二个循环中, k = 1 时, alloca 分配1000字节,栈空间分配为1000字节; k = 2 时,栈空间分配为3000字节(之前的1000字节加上新分配的2000字节)。
  • ➎: f1 函数返回,栈空间释放,栈空间分配为0字节。
  • ➏:在 main 函数的 if 块中, array 分配1000字节,栈空间分配为1000字节。
  • ➐:在 f2 的第一个循环中, k = 1 时, alloca 分配1000字节,栈空间分配为2000字节; k = 2 时,栈空间分配为4000字节。
  • ➑:第一个循环结束,栈空间不释放( alloca 在函数返回时释放),栈空间分配为4000字节。
  • ➒:在 f2 的第二个循环中, k = 1 时, array 分配1000字节,栈空间分配为5000字节; k = 2 时,栈空间分配为7000字节。
  • ➓: f2 if 块结束,栈空间释放,栈空间分配为0字节。
9.7 函数返回值打印问题

以下代码中四个 printf 语句的输出结果分析:

int foo(void) 
{ 
    auto int x = 0; 
    x = x + 1; 
    return x;  
}
int bar(void) 
{ 
    static int x = 0; 
    x = x + 1; 
    return x;  
}
int main(void) 
{ 
    printf("%d", foo()); 
    printf("%d", foo()); 
    printf("%d", bar()); 
    printf("%d", bar()); 
    return 0; 
}
  • foo 函数中的 x 是自动变量,每次调用都会重新初始化,所以前两个 printf 都输出1。
  • bar 函数中的 x 是静态变量,只初始化一次,每次调用会累加,所以第三个 printf 输出1,第四个 printf 输出2。
9.8 递归程序相关问题

以下程序编译运行后,分析相关问题:

#include <stdio.h> 
int main(void) 
{ 
    go(3); 
    return 0; 
}  

void go(int n) 
{ 
    static int s = 0; 
    auto int a = ++s; 
    show("Enter", &n, &a, &s) ; 
    if (n != 0) go(n - 1); 
    show("Leave", &n, &a, &s) ; 
}  

void show(char *label, int *pn, int *pa, int *ps)
{
    printf("%s: Object Address     Contents\n", label) ;
    printf(" n %08X %d\n", pn, *pn) ;
    printf(" a %08X %d\n", pa, *pa) ;
    printf(" s %08X %d\n", ps,  *ps) ;
    printf("\n") ;
}
  • (a) 函数参数“n”的最大共存实例数量 :由于 go 函数递归调用,最多有4个 n 的实例共存( n 从3到0)。
  • (b) 静态整数“s”的最大共存实例数量 :静态变量只有一个实例,所以最大共存实例数量是1。
  • (c) 自动整数“a”的最大共存实例数量 :每次递归调用都会创建一个新的 a 实例,最多有4个 a 的实例共存。
  • (d) 从同一区域分配内存的对象 :函数参数 n 和自动变量 a 都从栈上分配内存,所以 n a 从同一区域分配内存。
9.9 变量值打印问题

以下代码中六个 printf 语句的输出结果分析:

int x; 
void one(void) { int x; x = 1; } 
void two(int x) { x = 2; } 
void three(void) { x = 2; } 
void four (int *p) {*p = 3; } 
int main(void) 
{ 
    printf("%d", x); 
    x = 4; one(); 
    printf("%d", x); 
    x = 5; two(x); 
    printf("%d", x); 
    x = 6; 
    { 
        int x = 7; three(); 
        printf("%d", x); 
    } 
    printf("%d", x); 
    x = 8; four(&x); 
    printf("%d", x); 
    return 0; 
}
  • 第一个 printf :全局变量 x 未初始化,输出为0。
  • 第二个 printf one 函数中的 x 是局部变量,不影响全局变量 x ,所以输出4。
  • 第三个 printf two 函数中的 x 是参数,不影响全局变量 x ,所以输出5。
  • 第四个 printf three 函数修改全局变量 x 为2,但 {} 内的 x 是局部变量,输出7。
  • 第五个 printf :输出全局变量 x 的值,为2。
  • 第六个 printf four 函数通过指针修改全局变量 x 为3,输出3。
9.10 代码变量作用域优化问题

以下代码可以通过限制变量作用域进行优化:

#include <stdio.h> 
int old_value = 0; 
int average; 
void f(int value) 
{ 
    if (value != old_value) 
    { 
        average = (value + old_value) / 2; 
        printf("average = %d\n", average); 
    } 
    old_value = value; 
}

优化后的代码如下:

#include <stdio.h> 

void f(int value) 
{ 
    static int old_value = 0; 
    if (value != old_value) 
    { 
        int average = (value + old_value) / 2; 
        printf("average = %d\n", average); 
    } 
    old_value = value; 
}

old_value 设为静态局部变量, average 设为局部变量,限制了它们的作用域。

9.11 栈空间分配分析问题

分析以下程序在各个编号点的栈空间分配情况:

int main(void)
{
    static int k = 600 ;
    static char *p ;
    ➊ 
    if (1)
    {
        char b[4*k] ;
        ➋ 
    }
    ➌ 
    if (1)
    {
        char a[1000] ;
        ➍ 
        p = alloca(8*k) ;
        ➎ 
    }
    ➏ 
    return 0 ;
}
  • ➊:进入 main 函数,栈空间分配为0字节。
  • ➋:在第一个 if 块中, b 分配4 * 600 = 2400字节,栈空间分配为2400字节。
  • ➌:第一个 if 块结束,栈空间释放,栈空间分配为0字节。
  • ➍:在第二个 if 块中, a 分配1000字节,栈空间分配为1000字节。
  • ➎: alloca 分配8 * 600 = 4800字节,栈空间分配为5800字节。
  • ➏: if 块结束, alloca 在函数返回时释放,栈空间分配为5800字节。
9.12 代码错误分析问题

以下代码中,编译器会产生错误信息的行分析:

int *f(int p)
{
    register int r1 = 0;
    register int r2 = r1 + 1;
    auto int a1 = 0;
    auto int a2 = a1 + 1;
    static int s1 = 0;
    static int s2 = s1 + 1;
    if (r1 == 0) return &r1;
    if (a1 == 0) return &a1;
    if (s1 == 0) return &s1;
    return &p;
}
  • if (r1 == 0) return &r1; :寄存器变量 r1 没有内存地址,不能取地址,会产生错误。
  • if (a1 == 0) return &a1; :自动变量 a1 在函数返回时会释放内存,返回其地址会导致悬空指针,会产生错误。
  • return &p; :函数参数 p 在函数返回时会释放内存,返回其地址会导致悬空指针,会产生错误。

综上所述,C语言中的内存分配方式多样,每种方式都有其特点和适用场景。在编程过程中,我们需要根据具体需求选择合适的内存分配方式,同时要注意避免内存泄漏等问题,以确保程序的正确性和稳定性。通过对这些内存分配知识的深入理解和掌握,我们可以编写出更高效、更健壮的C语言程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值