第7章 内存分配

很多系统程序需要为动态数据结构(例如链表和二叉树)分配额外的内存。动态数据结构的大小(size)取决于运行时的信息。本章阐述了用于在堆中或栈中分配内存的函数。

7.1 Allocating Memory on Heap

进程可以通过增大堆(heap)来分配内存。堆是一块可变大小的连续内存,随着内存的分配和释放而增大和减小(见Figure 6-1)。堆的当前界线(current limit)被称为 program break
在这里插入图片描述

C程序通常使用 malloc 函数家族(family of functions)来分配内存。然而我们会先阐述 brk()sbrk(),malloc函数是基于这两个函数的。

7.1.1 Adjusting the Program Break: brk() and sbrk()

调整堆的大小(也就是分配和释放内存)实际上很简单,只需要告诉内核,进程的program break在哪里,然后调整program break即可。刚开始时,program break处在未初始化数据段(uninitialized data segment)的末端(也就是Figure 6-1 的 end 位置)。
在program break增加后,程序可以访问新分配区域的任何地址,但是还不会给它分配 物理内存页(physical memory pages) 。当进程第一次想要访问这些页(page)中的地址时,内核才会自动地分配新的物理内存页。
在这里插入图片描述

传统的UNIX系统提供了两个系统调用来操作program break(这两种系统调用在Linux中也可用):brk()sbrk()。虽然这两个系统调用很少在程序中直接用到,但是理解这两个系统调用有助于我们理解内存分配的工作原理:

#include <unistd.h>
// 成功时返回0,失败时返回-1
int brk(void *end_data_segment);
// 成功时返回之前的program break,失败时返回(void *)-1
void *sbrk(intptr_t increment);

brk() 系统调用将program break设置成 end_data_segment 所指向的位置。因为虚拟内存的分配是以page为单位的,end_data_segment实际是下个page的边界。
尝试将program break设置成小于初始值(也就是比 end 小)很可能导致不可预料的行为,例如当尝试访问初始(或未初始化)数据段的不存在的部分(译者注:这两个数据段大小固定,不存在的部分就是指没有使用的部分)会出现segmention fault(SIGSEGV信号,20.2节)。
program break可设置的 上限 取决于很多因素,包括:进程数据段长度(RLIMIT_DATA, 36.3节)这个资源限制(resources limit)、内存映射的位置、共享内存段(shared memory segments)和共享库。
调用 sbrk() 会通过在原基础上增加increment来调整program break。(在Linux中,sbrk是建立在brk()之上的库函数)intptr_t类型用于声明increment是一个整型(integer)。执行成功时,sbrk会返回之前program break的地址。换句话说,如果我们增加了program break,那么返回的值是一个指针,指向新分配的内存块的 起始位置
调用sbrk(0)返回当前的program break,而不会改变它。如果我们想要知道堆的大小,这就很有用。

SUSv2指定了brk()和sbrk()(标记为LEGACY)。SUSv3删除了它们的规范。

7.1.2 Allocating Memory on the Heap: malloc() and free()

C程序一般使用malloc函数家族在堆上分配和释放内存。这些函数与brk()和sbrk()相比具有以下优势:

  • 是C语言的标准部分
  • 在线程程序中更易使用
  • 提供简单的接口,允许以很小的单元分配内存
  • 可以任意地释放内存块,这些被释放的内存块由一个空闲链表(a free list)维护,在将来调用内存分配时可进行重用。

malloc()函数从堆中分配size个字节,返回一个指针,指向新分配的内存块。分配的内存是未被初始化的。

#include <stdlib.h>
// 成功时返回一个指向新分配内存的指针,失败时返回NULL
void *malloc(size_t size);

因为malloc()返回 void * ,所以我们可以赋予任何类型的C指针。malloc()返回的内存块会 对齐 一个适用于所有C数据结构类型的字节边界(byte boundary)。实时上,这意味着在大部分架构中分配在8字节或16字节的边界上。

SUSv3规定malloc(0)可以返回NULL,也可以返回指向很小的一片内存的指针,并且这片内存可以(以及应该)被free()释放。在Linux中,malloc(0)遵循后者行为。

如果内存不能被分配(可能因为program break到达了系统指定的最大界限),那么malloc()会返回NULL,并且设置errnor以表示这个错误。尽管分配内存时失败的可能性很小,但是对malloc()及相关函数的调用都应该检查这个错误返回。

#include <stdlib.h>
void free(void *ptr)

free() 函数一般不会减小program break,而是将内存块添加到空闲块链表(a list of free blocks)中,用于将来调用malloc()时重用。这样做有以下几个原因:

  • 被释放的内存块一般在堆的某个中间位置,而不是末尾,所以减小program break变得不可能。
  • 最小化sbrk()的调用次数,只要当程序必须调用sbrk()时才调用。(正如3.1节所述,系统调用的开销虽小但不可忽视)
  • 在很多情况下,减小program break不会帮助程序节省内存。因为程序一般会不断地释放和重新分配内存,而不会释放了内存后,连续运行很长一段时间不申请内存。

如果free()中传入的参数是NULL指针,那么这次调用不会做任何事(换句话说,向free()中传入NULL指针不会报错)。
调用了free()之后,再对ptr做任何操作(例如,再次传入free()中)会发生错误,导致不可预料的后果。

Examle program

Listing 7-1的程序可以用来说明free()对program break的影响。程序分配了多个内存块,然后释放部分(或所有)内存块,取决于命令行参数。
前面两个命令行参数需要分配的块的数量和大小。第三个命令行参数指定了释放内存块的步长单位,如果我们指定1(如果省略这个参数,则默认是1),那么程序会释放每个内存块;如果指定2,那么每隔一个释放一个。第四个和第五个参数,指定了我们相应释放的内存块的范围。如果这些参数省略,那么所有的内存块(以第三个命令行参数给定的步长)释放。

// Listing 7-1 展示当内存释放后,program break会发生什么
// memalloc/free_and_sbrk.c
# include "tlpi_hdr.h"
# define MAX_ALLOCS 1000000

int main(int argc, char * argv[]) 
{
  char *ptr[MAX_ALLOCS];
  int freeStep, freeMin, freeMax, blockSize, numAllocs, j;
  printf("\n");
  if (argc < 3 || strcmp(argv[1], "--help") == 0) 
    usageErr("%s num-allocs block-size [step [min [max]]]\n", argv[0]);
  numAllocs = getInt(argv[1], GN_GT_0, "num-allocs");
  if (numAllocs > MAX_ALLOCS)
    cmdLineErr("num-alloc > %d\n", MAX_ALLOCS);

  blockSize = getInt(argv[2], GN_GT_0 | GN_ANY_BASE, "block-size");
  freeStep = (argc > 3) ? getInt(argv[3], GN_GT_0, "step") : 1;
  freeMin = (argc > 4) ? getInt(argv[4], GN_GT_0, "min") : 1;
  freeMax = (argc > 5) ? getInt(argc[5], GN_GT_0, "max"): numAllocs;

  if (freeMax > numAllocs)
    cmdLineErr("free-max > num-allocs\n");
  
  printf("Initial program break:  %10p\n",sbrk(0));
  printf("Allocating %d * %d bytes\n", numAllocs, blockSize);
  
  for (j = 0; j < numAllocs; j++) 
  {
    ptr[j] = malloc(blockSize);
    if (ptr[j] == NULL)
      errExit("malloc");
  }

  printf("Program break is now:    %10p\n", sbrk(0));
  printf("Freeing blocks from %d to %d in steps of %d\n", freeMin, freeMax. freeStep);

  for (j = freeMin-1; j < freeMax; j += freeStep)
    free(ptr[j]);
  
  printf("After free(), program break is:  %10p\n", sbrk(0));

  exit(EXIT_SUCCESS);
}   

使用以下命令运行Listing 7-1的程序,为会程序分配1000个内存块,然后每隔一个块释放一个。

$ ./free_and_sbrk 1000 10240 2

下面的输出结果显示,当这些块被释放后,program break还是停留在之前为所有缓存块分配内存时的状态:
在这里插入图片描述

下面的命令行释放除了最后(也就是最顶端)一块内存之外的所有内存块,但是program break仍旧处于“高水位线”:
在这里插入图片描述

但是,如果我们将堆的最顶端的那些块释放掉,我们可以看到program break 从峰值降下 来了,表明free()使用了sbrk()来减小program break。这里我们释放了最顶端的500个内存块:
在这里插入图片描述

因为当释放块时,(glibc)free()函数会将相邻的空闲块合并成单个更大的块(这种合并避免了空闲链表中存在大量的小碎片( fragments),这些碎片可能会因为太小而无法满足随后的malloc()请求),所以它可以识别出heap最顶端的整块区域是否处于空闲状态。

只有当堆顶端的空闲块 “足够” 大时,glibc free()函数才会调用sbrk()来减小program break。“足够” 取决于malloc包中控制操作的参数(一般是128KB)。这可以减少sbrk()的调用次数(也就是brk()系统调用的次数)。

To free() or not to free()

当进程终止时,它的所有内存都会返还给系统,包括由malloc包中的函数所分配的堆内存。我们的程序中经常会分配内存,然后连续使用到程序终止,通常省略free()的调用,依赖进程结束后自动释放内存。这种方式在分配了很多内存块的程序中特别有用,因为多次调用free(),从CPU时间上看是昂贵的,还可能增加代码编写的复杂度。
尽管依赖进程终止来自动释放内存对于很多程序来说是可以接受的,但是因为以下原因,我们应该显示地手动释放所有分配的内存:

  • 显示地调用free()可以使程序更易阅读和维护。
  • 如果我们使用malloc调式(debugging)库来寻找程序中的内存泄漏,任何没有显示释放的内存都会被报告为内存泄漏。这使寻找真正内存泄漏的任务变得极其复杂。

7.1.3 Implementation of malloc() and free()

尽管在分配内存时使用malloc()和free()会比使用brk()和sbrk()简单很多,但是在使用过程中可能还会遇到各种编程错误。理解malloc()和free()的实现原理,有助于我们找到这些错误的原因以及避免这些错误。
malloc()的实现很简单。它首先扫描(scan)之前free()释放的内存块列表,查找一块大于或等于它所需大小(size)的内存块(scan可以采取不同策略,取决于具体的实现:first-fit 或 best-fit)。如果找到的内存块刚好与它要求的大小一样,那么直接返回给调用者。如果找到的内存块比它要求的要大,那么分割这块内存块。将分割完的符合要求的那块返回给调用者,剩下的那块留在空闲列表中。
如果空闲列表中没有足够大的内存块,那么malloc()会调用sbrk()来分配更多的内存。为了减少sbrk()的调用次数,malloc()申请的内存不会正好等于它所需的字节数,而是会申请更多的单元(虚拟内存页大小的倍数),将多余的内存放入到空闲列表中。
看一下free()的实现,我们会发现事情变得更加有趣。当free()将内存块放入到空闲链表时,它是怎么知道这个块的大小的呢?这里使用到了一个技巧。当malloc()分配内存块时,它分配了额外的字节,用于存储这个这个块的大小(整型值)。这个整型值处于块的起始位置。返回给调用者的地址刚好指向这个整型值的后面(Figure 7-1):
在这里插入图片描述

当块被放入(双向)空闲链表时,free()会使用块本身的字节来将块加入到链表中,正如Figure7-2所示:
在这里插入图片描述

随着块的释放和重分配,在内存的使用上空闲链表中的块会与分配的块混杂在一起,正如Figure7-3所示:
在这里插入图片描述

现在考虑这个问题:C语言允许我们创建指针,指向堆中的任何位置,并且可以修改它们指向的位置的值,包括由free() 和malloc()维护的指向length、previous free block和next free block的指针。当程序中存在bugs时,可能会导致相当严重的后果。举例来说,我们通过使用一个错误指向的指针,偶然地增加了分配的内存块之前的长度值,随后释放这个内存块,那么free()会在空闲链表上记录错误的内存块大小。结果malloc()可能会重新分配这块内存,导致一种场景:程序中的两个指针分别指向两个分配的内存块,它们认为这两个内存块是不同的,但其实这两个内存块 有重叠(overlap)
为了避免这些错误,我们应该遵循以下规则:

  • 在分配了内存块后,我们应当小心不要触碰这个内存块以外的任何字节。例如,指针运算发生错误或者循环中更新块的内容发生错误都可能出现这种情况。
  • 释放同个(分配的)内存块 多次 会引发错误。使用Linux中的glibc,经常会得到一个段异常(segmentation violation)(SIGSEGV信号)。这很好,它警告我们有一个编程错误。然而一般来说,释放同个内存块两次会引发不可预料的行为。
  • 万万不要将不是从malloc函数家族中获取到的指针值传入free()中。
  • 如果我们要写一个长时间运行的程序,需要重复不断地分配内存,那么我们应该确保在使用完内存块之后进行释放。如果不进行内存块的释放或者释放内存块失败,那么堆的大小会稳步增长,直到达到可用的虚拟内存限制,此时,尝试再次分配内存就会失败。这种情况被称为 内存泄漏(memory leak)

7.1.4 Other Methods of Allocating Memory on the Heap

和malloc一样,C库提供了一系列用于在堆上分配内存的其他函数,接下来介绍这些函数。

Allocating memory with calloc() and realloc()

calloc() 函数为具有相同元素的数组分配内存。

#include <stdlib.h>
// 成功时分配的内存的指针,失败时返回NULL
void * callo(size_t numitems, size_t size);

numitems参数指定了分配元素的个数,size指定了每个元素的大小。在分配了合适大小的内存块后,calloc()返回指向这个块的起始位置的指针(或者如果不能分配内存时返回NULL)。
下面是使用calloc()的一个列子:

stuct {/* Some field definitions*/ } myStruct;
struct myStruct *p;

p = calloc(1000, sizeof(struct myStruct));
if (p == NULL)
  errExit("calloc")

realloc() 函数用于调整之前由malloc包中的某个函数分配的内存块的大小(一般是扩大)

#include <stdlib.h>
// 成功时返回指向分配的内存的指针,失败是返回NULL
void *realloc(void *ptr, size_t size);

ptr参数是指向需要调整大小的内存块的指针。size参数指定了块的新的大小。
函数调用成功时,realloc()返回指向调整后的块的位置的指针。这个位置可能与调用之前的位置不同。调用失败时,realloc()返回NULL,这个块未受影响,ptr仍旧指向这个块的位置(SUSv3中要求这么做)。
当realloc()增加了分配的内存块的大小,它不会对新分配的字节进行初始化。
使用calloc()或realloc()分配的内存应该使用free()释放。

调用realloc(ptr, 0)等价于在malloc(0)之后调用realloc()。如果ptr指向NULL,那么调用realloc()等价于调用malloc(size)。

通常情况下,当realloc()用于增加块内存的大小时,如果这内存块紧邻的后面有空闲链表中的块,并且足够大,那么realloc()会合并这个空闲块;如果这个块处在堆的最末端(顶端),那么realloc()会扩展堆;如果内存块处于堆的中间位置,并且这内存块紧邻的后面没有足够的空闲空间,realloc()就会分配一个新的内存块,并将这个旧的块中的数据复制到新的块中。最后一种情况是最常见的,且是CPU密集型的。所以建议尽量少使用realloc()。

Allocating aligned memory:memalign() and posix_memalign()

memalign()posix_memalign() 函数用于分配一块内存,这块内存的起始位置以 2的幂 的边界对齐。这个功能对很多应用来说很有用(例如,请看Listing13-1)。

#include <malloc.h>
//执行成功时返回一个指针,指向分配的内存,失败时返回NULL
void *memalign(size_t boundary, size_t size);

memalign() 函数分配size个字节的内存,内存的起始地址以boundary的 倍数 为边界对齐,boundary必须是 2的幂!分配的内存的地址以函数结果返回。
不是所有的UNIX系统都提供了memalign()函数。大部分其他的UNIX系统提供了memalign()函数,但是需要包含<stdlib.h>而不是<malloc.h>,以便获取函数声明。
在SUSv3中没有指定memalign(),而是指定了一个类似的名为posix_memalign()的函数。这个函数是标准委员会最近才创建的,只有少数UNIX系统中才有。

#include <stdlib.h>
//执行成功时返回0,失败时返回一个表示错误的正数
int posix_memalign(void **memptr, size_t alignment, size_t size);

posix_memalign()函数与memalign()函数的不同之处有:

  • 分配的内存地址在memptr中返回。
  • 分配的内存以alignment的 倍数 为边界对齐,alignment必须是 2的幂乘以sizeof(voide *)(在大部分的硬件架构中是4或8字节)。

还需要注意这个函数的返回值,失败时不是返回-1,而是返回一个错误的数字(通常是一个正整数)。
如果sizeof(void *)是4,则我们可以使用posix_memalign()分配一块65536个字节的内存,以4096字节为边界对齐:

int s;
void *memptr;
s = posix_memalign(&memptr, 1024 * sizeof(void *), 65535);
if (s != 0)
  /* Handle error */

使用memalign()或者posix_memalign()分配的内存块应该使用free()来释放。

7.2 Allocating Memory on the Stack: alloca()

就像malloc包中的函数一样,alloca() 也用于动态分配内存。然而,alloca()不是在堆中获取内存,而是通过增加栈帧(stack frame)的大小,从栈中获取内存。因为根据定义,当前调用函数的栈帧是在栈的最顶端,所以这是可行的。因为,在栈顶端具有空间,所以只需要修改栈指针的值就能完成扩展。
在这里插入图片描述

#include <alloca.h>
// 返回指向分配的内存块的指针
void *alloca(size_t size);

size参数指定了要为栈所分配的字节数。alloca()函数返回指向分配的内存的指针。
我们不需要,事实上是不能调用free()来释放alloca()分配的内存。同样地,也不能使用realloc()来调整由alloca()创建的内存大小。
尽管alloca()不是SUSv3中的规范,但是大部分UNIX系统提供了这个函数,因此是具有可移植性的。

旧版本的glibc和一些其他的UNIX系统(主要是BSD这个分支)获取alloca()的声明时要求包含<stdlib.h>而不是<alloca.h>

如果调用alloca()发生了 栈溢出(stack overflow) ,那么程序将出现不可预知的行为。尤其是程序不会返回NULL来告知我们这个错误。(事实上,在这种情况下,我们可能会接收到一个SIGSEGV信号。更多详情请参考21.3节)
注意,我们不能在函数的参数列表中使用alloca(),正如下面的例子:

func(x, alloca(size), z); /* WRONG!!*/

我们必须使用类似如下的代码:

void *y;
y = alloca(size);
func(x, y, z);

使用alloca()分配内存与malloc()相比具有一些优势。一个优势是alloca()分配内存块的速度比malloc()快,因为alloca()由编译器作为内联代码(inline code)实现,直接调整栈指针。此外,alloca()不需要维护一个空闲块链表。
alloca()的另一个优势是:当帧被删除时(也就是调用了alloca()的函数返回时),alloca()所分配的内存块会被自动释放。当正在执行的函数返回时,栈指针就会指向之间的帧。因为我们不需要在函数返回时手动释放分配的内存块,所以一些函数的编写变得更加简单。

7.3 Summary

使用malloc函数家族,进程可以在堆上动态地分配和释放内存。鉴于这些函数的实现,我们看到如果错误地处理分配的内存块,会导致各种错误的产生。有一些调试工具可以帮助我们找到这些错误。
alloca()函数用于在栈上分配内存。当调用alloca()的函数返回时,这些内存会自动被释放。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值