Linux 将它的物理内存虚拟化进程并不能直接在物理内存上寻址, 而是由 Linux 内核为每个进程维护一个特殊的虚拟地址空间 (virtual address space) 这个地址空间是线性的, 从 0 开始, 到某个最大值虚拟空间由许多页组成系统的体系结构以及机型决定了页的大小 (页的大小是固定的), 典型的页的大小包括 4K(32 位系统) 和 8K(64 位系统)每个页面都只有无效 (invalid) 和有效 (valid) 这两种状态,
有效页面 (valid page) 和一个物理页或者一些二级存储介质相关联, 例如一个交换分区或者一个在硬盘上的文件
无效页面 (invalid page) 没有关联, 代表它没有被分配或使用对无效页面的访问会引发一个段错误
地址空间不需要是连续的虽然是线性编址, 但实际上中间有很多未编址的小区域一个进程不能访问一个处在二级存储中的页, 除非这个页和物理内存中的页相关联如果一个进程尝试访问这样的页面, 那么存储器管理单元 (MMU) 会产生一个页错误(page fault)
虚存中的多个页面, 甚至是属于不同进程的虚拟地址空间, 也有可能被映射到同一个物理页面这样允许不同的虚拟地址空间共享 (share) 物理内存上的数据共享的数据可能是只读的, 或者是可读可写的当一个进程试图写某个共享的可写页时, 另一种情况是 MMU 会截取这次写操作并产生一个异常; 作为回应, 内核就会透明的创造一份这个页的拷贝以供该进程进行写操作我们将这种方法称为写时拷贝(copy-on-write)(COW)
内核将具有某些相同特征的页组织成块 (blocks), 例如读写权限这些块叫做存储器区域(memory regions), 段(segments), 或者映射(mappings) 典型的段包括:
1, 文本段 (text segment) 包含着一个进程的代码, 字符串, 常量和一些只读的数据在 Linux 中, 文本段被标记为只读, 并且直接从目标文件 (可执行程序或是库文件) 映射到内存中
2, 堆栈段 (stack) 包括一个进程的执行栈, 随着栈的深度动态的伸长或收缩执行栈中包括了程序的局部变量 (local variables) 和函数的返回值
3, 数据段(data segment), 又叫堆(heap), 包含着一个进程的动态存储空间这个段是可写的, 而且它的大小是可以变化的这部分空间往往是由 malloc 分配的
4,BSS 段 (bss segment) 包含了没有被初始化的全局变量这些变量根据不同的 C 标准都有特殊的值, 通常来说, 都是 0
动态内存分配
- void malloc(size_t size);
- void calloc(size_t nr, size_t size);
- void realloc(void ptr, size_t size);
- void free(void * ptr);
malloc()时会得到一个 size 大小的内存区域, 并返回一个指向这部分内存首地址的指针这块内存区域的内容是未定义的, 不要自认为全是 0 失败时, malloc()返回 NULL, 并设置 errno 错误值为 ENOMEM
数组分配 calloc 与 malloc 不同的是, calloc 将分配的区域全部用 0 进行初始化要注意的是二进制 0 和和浮点 0 是不一样的
调整已分配内存大小 realloc 成功调用 realloc()将 ptr 指向的内存区域的大小变为 size 字节它返回一个指向新空间的指针, 当试图扩大内存块的时候返回的指针可能不再是 ptr 因为有潜在的拷贝操作, 如果 size 是 0, 效果就会跟在 ptr 上调用 free()相同
调用 free()会释放 ptr 指向的内存但 ptr 必须是之前调用 malloc(),calloc(), 或者 realloc()的返回值也就是说, 你不能用 free()来释放申请到的部分内存, 比如说用一个指针指向一块空间中间的位置 ptr 可能是 NULL 这个时候 free()什么都不做就返回了, 因此调用 free()时并不需要检查 ptr 是否为 NULL 内存泄漏和悬垂指针有两个常用的工具可以帮助你解决这些问题: Electric Fence 和 valgrind
数据的对齐 (alignment) 是指数据地址和由硬件确定的内存块之间的关系一个变量的地址是它大小的倍数时, 就叫做自然对齐 (naturally aligned)POSIX1003.1d 提供一个叫做 posix_memalign() 的函数 BSD 和 SunOS 分别提供了如下接口: valloc 除了标准类型的对齐和内存分配, 对齐问题还可以进行扩展比如说, 复杂的数据类型的对齐问题将会比标准类型的更复杂
数据段的管理
堆的起始地址空间在由操作系统和执行文件大小决定的
- int brk(void addr);
- void sbrk(intptr_t increment);
因为 malloc()和其它的方法更强大也易于使用, 大多数程序都不会直接地使用这些接口 sbrk 老版本 Unix 系统中函数的名字, 那时堆和栈还在同一个段中堆中动态存储器的分配由数据段的底部向上生长; 栈从数据段的顶部向着堆往下生长堆和栈的分界线叫做中断 (break) 或中断点 (break point) 在现代系统中, 数据段存在于它自己的内存映射中, 我们仍用中断点来标记映射的结束地址调用 brk()会设置中断点 (数据段的末端) 的地址为 end 在成功的时候, 返回 0 失败的时候, 返回 - 1, 并设置 errno 为 ENOMEM 调用 sbrk()将数据段末端增加 increment 字节, increment 可正可负 sbrk()返回修改后的断点所以, increment 为 0 时得到的是现在断点的地址
匿名存储器映射
Glibc 的内存分配使用了数据段和内存映射实现 malloc()最经典方法就是将数据段分为一系列的大小为 2 的幂的块, 返回最小的符合要求的那个块来满足请求释放则只是简单的将这块区域标记为未使用如果相邻的分区都是空闲的, 他们会被合成一个更大的分区如果堆的最顶端是空的, 系统可以用 brk()来降低断点, 使堆收缩, 将内存返回给系统这个算法叫做伙伴内存分配算法 (buddymemoryallocationscheme) 它的优点是高速和简单, 缺点则是会产生两种类型的碎片
1, 当使用的内存块大于请求的大小时则产生内部碎片(Internal fragmentation)
2, 外部碎片是在空闲存储器合计起来够满足一个请求, 但是没有一个单独的空间块可以来处理这个请求时发生的这同样会导致内存利用不足 (因为可能会分配一个更大的块) 或是分配的失败(如果已经没有可选的块存在了)
3, 这个算法会使一个内存的分配栓住另外一个, 导致 glibc 不能将释放的内存返回给系统想象内存中已被分配的两个块, 块 A 和块 B 块 A 正好处在中断点的位置, 块 B 刚好在 A 的下面, 就算释放了 B, 在 A 被释放前, glibc 也不能相应的调整中断点
Glibc 并不是一直在试图将空间返回给系统通常来说, 在每次释放后堆并不收缩 glibc 会维护释放的内存以供之后的分配使用只有当堆明显的大于已分配的内存时, glibc 才会减小数据段的大小
对于较大的分配, glibc 并不使用堆而是创建一个匿名内存映射 (anonymous memory mapping) 来满足要求匿名存储器映射和在第四章讨论的基于文件的映射很相似, 只是它并不基于文件所以称之为匿名实际上, 一个匿名内存映射只是一块已经用 0 初始化的大的内存块, 以供用户使用可以把它想成为单独为某次分配而使用的堆因为这种映射的存储不是基于堆的, 所以并不会在数据段内产生碎片
使用匿名映射来分配内存有下列好处:
1, 无需关心碎片
2, 匿名存储映射的大小的是可调整的, 可以设置权限, 还能像普通的映射一样接受建议
3, 每个分配存在于独立的内存映射没有必要再去管理一个全局的堆了
使用匿名映射与堆比起来也有两个缺点:
1, 每个存储器映射都是页面大小的整数倍可能浪费内存空间
2, 创建一个新的内存映射比从堆中返回内存的负载要大, 因为使用堆几乎不涉及任何内核操作越小的分配, 这样的问题也越明显, 不涉及小而频繁的请求
根据各自的优缺点来判断, glibc 的 malloc()使用数据段来满足小的分配, 而匿名内存映射则用来满足大的分配两者的临界点是可调的(请参阅本章稍后的高级内存分配部分), 并会随着 glibc 版本的不同而有所变化目前, 临界点一般是 128KB: 比 128KB 小的分配由堆实现, 相应地, 较大的由匿名存储器映射来实现用下面系统调用创建和销毁系统调用:
- void mmap(void start, size_t length, int prot, int flags, int fd, off_t offset);
- int munmap(void * start, size_t length);
当输入的 fd 是 - 1 时, 创建匿名映射, 在 BSD 系统上也可以是打开 / dev/zero 设备的 fd
高级存储器分配
int mallopt (int param, int value);
存储分配操作都是受内核的参数所控制和限制的, 程序员可以修改这些参数比如最大的存储器映射数量使用匿名映射还是数据段的判断阈值高速内存区域的大小填充字节数
size_t malloc_usable_size(void *ptr);
查询一块已分配内存中有多少可用字节
int malloc_trim(size_t padding);
调用 malloc_trim()成功时, 强制 glibc 归还所有的可释放的动态内存给内核数据段会尽可能地收缩, 但是填充字节被保留下来然后返回 1 失败时, 返回 0
调试内存分配
因为仅仅一个环境变量就能控制调试, 你不必重新编译你的程序例如, 你可以简单的执行如下指令:
$MALLOCCHECK=1 ./rudder
如果设置为 0, 存储系统会忽略所有错误如果它被设为 1 了, 信息会被输出到标准错误输出 stderr 如果设置为 2, 进程会立即通过 abort()终止
struct mallinfo mallinfo (void);
C 标准库获得关于动态存储分配系统的统计数据, 包括空闲块的个数, 匿名映射的大小, 可用的块大小等等
基于栈的分配
void * alloca (size_t size);
在一个栈中实现动态内存分配, 不必释放分配到的内存, 失败就表明出现的栈溢出但需要注意:
1, 如果要让代码具有可移植性, 你要避免使用 alloca()
2, 不能使用由 alloca()得到的内存来作为一个函数调用的参数, 因为分配到的内存块会被当做参数保存在函数的栈中
在 Linux 系统上, alloca()却是一个非常好用但没有被人们认识到的工具它表现的异常出色 (在各种架构下, 通过 alloca() 进行内存分配就和增加栈指针一样简单), 比 malloc()的性能要好很多对于 Linux 下较小的内存分配, alloca()能收获让人激动的性能
alloca()常见的用法是用来临时复制一个字符串, 因为这种需求非常多以及 alloca()实现的高效, Linux 系统专门提供了 strdup()来将一个给定的字符串复制到栈中
C99 引进了变长数组 (VLAs), 变长数组的长度是在运行时决定的, 而不是在编译的时候 alloca() 和变长数组的主要区别在于通过前者获得的内存在函数执行过程中始终存在, 而通过后者获得的内存在出了作用域后便释放了这样的方式有好有坏在 for 循环中, 我们希望每次循环都能释放空间以在没有任何副作用的情况下减小内存的开销 (我们不会希望有多余的内存始终被占用着) 然而, 如果出于某种原因我们希望这块空间能保留到下一轮的循环中, 那么使用 alloca()显然是更加合理的
选择一个合适的内存分配机制
分配方式 优点 缺点
malloc() 简单, 方便, 最常用 返回的内存为用零初始化
calloc() 使数组分配变得容易, 用 0 初始化了内存 在分配非数组空间时显得较复杂
realloc() 调整已分配的空间大小 只能用来调整已分配空间的大小
brk()和 sbrk() 允许对堆进行深入控制 对大多数使用者来说过于底层
匿名内存映射 使用简单, 可共享, 允许开发者调整保护等级并提供建议, 适合大空间的分配 不适合小分配最优时 malloc()会自动使用匿名内存映射
posix_memalign() 分配的内存按照任何合理的大小进行对齐 相对较新, 因此可移植性是一个问题; 对于对齐的要求不是很迫切的时候, 则没有必要使用
memalign()和 valloc() 相比 posix_memalign()在其它的 Unix 系统上更常见 不是 POSIX 标准, 对对齐的控制能力不如 posix_memalign()
alloca() 最快的分配方式, 不需要知道确切的大小, 对于小的分配非常适合 不能返回错误信息, 不适合大分配, 在一些 Unix 系统上表现不好
变长数组 与 alloca()类似, 但在退出此层循环时释放空间, 而不是函数返回时 只能用来分配数组, 在一些情况下 alloca()的释放方式更加适用, 在其它 Unix 系统中没有 alloca()常见
存储器操作
- void memset(void s, int c, size_t n);
- int memcmp(const void s1, const void s 2, size_t n);
- void memmove(void dst, const void src, size_t n);
- void memcpy(void dst, const void src, size_t n);
- void memchr(const void s, int c, size_t n);
- void memfrob(void s, size_t n);
C 语言提供了很多函数进行内存操作这些函数的功能和字符串操作函数 (如 strcmp() 以及 strcpy())类似, 但是他们处理的对象是用户提供的内存区域而不是以 NULL 结尾的字符串要注意这些函数都不会返回错误信息因此防范错误是程序员的责任, 如果传递错误的内存区域作参数的话, 你将毫无疑问的得到段错误
内存锁定
Linux 实现了请求页面调度, 页面调度是说在需要时将页面从硬盘交换进来, 当不再需要时再交换出去这使得系统中进程的虚拟地址空间与实际的物理内存大小没有直接的关系, 同时硬盘上的交换空间提供一个拥有近乎无限物理内存的假象, 在下面两种情况下, 应用程序可能希望影响系统的页面调度:
1, 确定性(Determinism) 时间约束严格的应用程序需要自己来决定页的调度行为如果一些内存操作引起了页错误这会导致昂贵的磁盘操作应用程序则可能会超出要求的运行时间如果能确保需要的页面总在内存中且从不被交换进磁盘, 应用程序就能保证内存操作不会导致页错误, 提供一致的, 可确定的程序行为, 从而提供了效能
2, 安全性(Security) 如果内存中含有私人信息, 这些信息可能最终被页面调度以不加密的方式储存到硬盘上例如, 如果一个用户的私钥正常情况下是以加密的方式保存在磁盘上的, 一个在内存中未加密的密钥备份最后可能保存在了交换文件中在一个高度注重安全性的环境中, 这样做可能是不可接受这样的应用程序可以请求将密钥一直保留在物理内存上
- int mlock(const void addr, size_t len);
- int mlockall(int flags);
- int munlock(const void addr, size_t len);
- int munlockall(void);
- int mincore(void start, size_t length, unsigned char vec);
因为内存的锁定能影响一个系统的整体性能 - 实际上, 如果太多的页面被锁定, 内存分配会失败 Linux 对于一个进程能锁定的页面数进行了限制拥有 CAP_IPC_LOCK 权限的进程能锁定任意多的页面没有这个权限的进程只能锁定 RLIMIT_MEMLOCK 个字节
投机性存储分配策略
Linux 使用投机分配策略当一个进程向内核请求额外的内存如扩大它的数据段, 或者创建一个新的存储器映射内核作出了分配承诺但实际上并没有分给进程任何的物理存储仅当进程对新分配到的内存区域作写操作的时候, 内核才履行承诺, 分配一块物理内存内核逐页完成上述工作, 并在需要时进行请求页面调度和写时复制这么做的优点:
1, 延缓内存分配允许内核将大部分工作推迟到最后一刻(当确实需要进行分配时)
2, 由于请求是根据需求逐页的分配, 只有真正需要物理内存的时候才会消耗物理存储
3, 分配到的虚拟内存可能比实际的物理内存甚至比可用的交换空间多得多这个特征叫做超量使用(overcommitment)
超量使用的功能可以通过修改配置文件 /proc/sys/vm/overcommit_memory 来关闭如果设置为 2, 则是使用严格审计 (strict accounting) 策略, 将虚拟内存限定在物理内存的一定比例之内默认是 50, 因为物理内存还需要包含内核页表系统保留页, 锁定页等
Linux 内存管理
来源: http://www.bubuko.com/infodetail-2489942.html