6.3 内存分配与回收
内存分配与回收是内存管理的核心功能,操作系统需要高效地管理物理内存,为进程分配和回收内存空间,同时标准库也会在用户态提供更细粒度的内存分配,满足程序的动态内存需求。
操作系统的内存管理
操作系统的内存管理分为两个层面:
- 物理内存管理:管理整个系统的物理页,分配给各个进程
- 虚拟内存管理:管理每个进程的虚拟地址空间,将虚拟页映射到物理页
物理内存分配
操作系统需要管理所有的物理页,为进程分配和回收物理页,常用的物理内存分配算法:
1. 伙伴系统(Buddy System)
伙伴系统是Linux内核使用的物理页分配算法:
- 原理:把物理内存按2的幂次方大小划分成多个块,每个块大小是4KB、8KB、16KB…直到最大的块大小
- 当需要分配n个页时,找到大于等于n的最小的2的幂次方的块分配
- 如果没有合适的块,就把更大的块分裂成两个相等的伙伴块,直到得到需要的大小
- 回收时,检查回收的块的伙伴块是否也是空闲的,如果是就合并成更大的块
- 优点:实现简单,合并快速,减少外部碎片
- 缺点:内部碎片,比如需要5个页的话要分配8个页,浪费3个页的空间
2. Slab分配器
伙伴系统是以页为单位分配的,但操作系统经常需要分配小于页大小的内核对象(比如进程描述符、inode等),Slab分配器就是用来分配小对象的:
- 原理:为每种类型的内核对象创建一个缓存,缓存中包含多个Slab,每个Slab由一个或多个连续的物理页组成,里面存放固定大小的对象
- 分配时直接从对应的缓存中分配空闲对象,回收时放回缓存
- 优点:分配小对象速度极快,不会产生内部碎片,缓存常用的对象,避免重复初始化
- Linux内核中使用的是改进的Slub分配器,性能更好,更节省内存
进程虚拟地址空间布局
每个进程的虚拟地址空间是独立的,32位Linux系统的典型布局:
┌───────────────────┐ 0xFFFFFFFF
│ 内核空间 │ 1GB,内核使用,所有进程共享
├───────────────────┤ 0xC0000000
│ 栈区 │ 从高地址向低地址增长,存储函数调用栈、局部变量
├───────────────────┤
│ 内存映射区 │ 动态库、文件映射、mmap分配的内存
├───────────────────┤
│ 堆区 │ 从低地址向高地址增长,动态分配的内存(malloc/new)
├───────────────────┤
│ 数据段(BSS) │ 未初始化的全局变量和静态变量,初始化为0
├───────────────────┤
│ 数据段(Data) │ 已初始化的全局变量和静态变量
├───────────────────┤
│ 代码段(Text) │ 程序的可执行代码、只读常量
└───────────────────┘ 0x00000000
64位系统的虚拟地址空间更大,布局类似,但各个段的范围更大。
用户态内存分配:malloc实现
我们在程序中调用的malloc()/free()是C标准库提供的函数,属于用户态的内存分配,不是系统调用。标准库的内存分配器会提前向操作系统申请大块的内存,然后切分成小块分配给程序,减少系统调用的开销。
内存分配器的设计目标
- 高性能:分配和释放操作要尽可能快
- 低碎片:减少内存碎片,提高内存利用率
- 通用性:支持各种大小的内存分配请求
- 可移植性:在不同的操作系统和硬件平台都能运行
- 线程安全:支持多线程环境下的并发分配
常见的内存分配器
1. ptmalloc
ptmalloc是GNU C库(glibc)的默认内存分配器:
- 原理:基于Doug Lea的dlmalloc,支持多线程
- 使用分箱(Binning)技术,把不同大小的内存块放到不同的链表中,分配时直接从对应大小的链表中取
- 每个线程有自己的分配区(Arena),减少锁竞争
- 优点:稳定、兼容性好,是Linux的默认实现
- 缺点:内存碎片较多,多线程下性能一般,释放的内存不一定会还给操作系统
2. tcmalloc(Thread-Caching Malloc)
tcmalloc是Google开发的内存分配器,是gperftools的一部分:
- 核心优化:每个线程有自己的线程本地缓存,小对象分配直接在本地缓存中进行,不需要加锁,性能极高
- 大对象使用Central Cache分配,多个线程共享,使用页级分配
- 优点:多线程下性能比ptmalloc高很多,内存碎片少,释放的内存会还给操作系统
- 缺点:比ptmalloc多占用一点额外内存
- 很多高性能应用(比如Chrome、Redis)都使用tcmalloc代替默认的ptmalloc
3. jemalloc
jemalloc是Jason Evans开发的内存分配器,最早用于FreeBSD,现在是Firefox、Rust、Facebook的默认分配器:
- 核心优化:基于多层级的缓存设计,支持多核扩展,低碎片
- 使用大小类和范围分配,每个CPU有自己的缓存,并发性能很好
- 内建内存 profiling 功能,方便排查内存问题
- 优点:性能和tcmalloc相当甚至更好,在多线程大内存分配场景下表现优异,内存利用率高
- 现在很多高并发服务都使用jemalloc来提升性能
分配器性能对比
| 分配器 | 单线程性能 | 多线程性能 | 内存碎片 | 额外内存开销 |
|---|---|---|---|---|
| ptmalloc | 中等 | 一般 | 高 | 低 |
| tcmalloc | 高 | 高 | 低 | 中等 |
| jemalloc | 高 | 很高 | 很低 | 中等 |
选择建议:
- 默认情况下用系统默认的ptmalloc就可以
- 高并发多线程服务,建议换成jemalloc或tcmalloc,能带来明显的性能提升
- 内存受限的嵌入式场景,考虑更轻量的分配器比如musl libc的malloc
malloc的底层实现
malloc分配内存主要通过两个系统调用:
- brk():扩展堆区的边界,适合分配较小的内存
- mmap():在内存映射区分配一块匿名内存,适合分配较大的内存(通常大于128KB)
free释放内存时:
- 如果是brk分配的小内存,放回空闲链表,不一定会还给操作系统,会被缓存起来下次分配使用
- 如果是mmap分配的大内存,调用munmap直接还给操作系统
- 这就是为什么有时候程序占用的内存不会随着free而下降的原因,缓存的内存可以复用,减少系统调用
内存回收
操作系统的内存回收
操作系统会在内存不足时回收内存:
- 回收页缓存:回收不常用的文件缓存页,这些页可以从磁盘重新读取,不需要写回(如果是干净的)
- 交换出匿名页:把不常用的匿名内存页写到交换分区,释放物理内存
- OOM Killer:如果内存严重不足,会杀死占用内存多的进程,释放内存
垃圾回收(GC)
对于Java、Python、Go、JavaScript等带自动垃圾回收的语言,不需要手动调用free释放内存,垃圾回收器会自动回收不再使用的内存:
- 原理:跟踪所有内存对象的引用,找出不再被引用的对象,自动回收它们占用的内存
- 常见的GC算法:标记清除、标记复制、标记整理、分代回收、引用计数等
- 优点:减轻程序员的负担,减少内存泄漏和野指针问题
- 缺点:有运行时开销,GC停顿会影响响应时间,需要调优
手动内存管理
C/C++等语言需要手动管理内存,调用malloc/new分配,free/delete释放:
- 优点:没有GC开销,内存使用完全可控,性能更高
- 缺点:容易出现内存泄漏、野指针、双重释放等问题,对程序员要求高
常见问题
为什么malloc分配的小内存free后,进程的内存占用没有下降?
因为ptmalloc等分配器会把释放的小内存缓存起来,留给后续的malloc使用,不会立即还给操作系统,这样可以避免频繁的系统调用,提升性能。如果很长时间不使用,或者分配了大内存,才会还给操作系统。
什么是内存碎片?
内存碎片分为两种:
- 外部碎片:总空闲内存足够,但都是不连续的小块,无法分配连续的大块内存
- 内部碎片:分配的内存比实际需要的大,多余的部分浪费了
好的内存分配器会尽量减少内存碎片,提升内存利用率。
如何选择内存分配器?
- 普通应用:默认的ptmalloc足够
- 高并发多线程服务:jemalloc或tcmalloc,性能更好
- 嵌入式系统:轻量级分配器,减少内存开销
- 需要调试内存问题:使用带调试功能的分配器,比如AddressSanitizer
思考问题
- 操作系统的伙伴系统分配算法有什么优缺点?
- 标准库的malloc为什么不直接向操作系统申请内存,而是提前申请大块内存再切分?
- tcmalloc和jemalloc相比默认的ptmalloc有什么优势?为什么高并发场景下性能更好?
- 自动垃圾回收和手动内存管理各有什么优缺点?分别适合什么场景?