Linux深入理解内存管理30(基于Linux6.6)---malloc介绍
一、Malloc
概述
malloc
(memory allocation)是 C 语言中用于动态分配内存的标准库函数,定义在 <stdlib.h>
头文件中。它用于在堆上分配一块指定大小的内存,并返回该内存块的起始地址。由于它涉及动态内存管理,因此在操作系统中,尤其是 Linux 中,malloc
的实现需要依赖操作系统的内存管理机制和一些高级的数据结构。
1.1、malloc
的基本功能
-
功能:
malloc
用于在堆内存(heap)上动态地分配一定大小的内存空间。 -
语法:
-
void* malloc(size_t size);
size
:请求分配的内存大小(以字节为单位)。- 返回值:如果分配成功,返回分配内存的起始地址;如果分配失败,返回
NULL
。
-
初始化:
malloc
分配的内存不会初始化,也就是说,返回的内存块中的内容是未定义的(可能是随机数据)。 -
释放内存:通过
free
函数释放之前通过malloc
分配的内存,以防止内存泄漏。
-
void free(void* ptr);
1.2、内存管理与 malloc
实现
Linux 系统中的 malloc
实际上是由底层的内存分配器(如 ptmalloc、glibc、tcmalloc 等)来实现的。以下是一些基本的内存管理机制与 malloc
的实现相关的要点:
1、内存分配器
在 Linux 上,malloc
由 C 标准库(如 glibc)提供实现。glibc 使用 ptmalloc
作为内存分配器,它是基于 伙伴系统(Buddy System) 和 空闲链表 进行内存分配和回收的。不同的内存分配器可能使用不同的算法和数据结构来优化内存分配和回收的效率。
2、堆(Heap)与内存区域
- 堆:堆是一个由程序动态管理的内存区域,用于存储程序运行时通过
malloc
、calloc
等函数动态分配的内存。堆的管理是由操作系统的内存分配器负责的。 - 空闲内存管理:内存分配器通常会维护一个空闲内存块的列表(链表、树结构等)。当
malloc
请求内存时,分配器从这个列表中找到足够大的空闲块。如果没有足够大的空闲块,它可能会请求操作系统分配更多内存。
3、内存分配过程
- 小块内存分配:对于小块内存(例如几 KB),分配器通常采用快速分配算法,如 双端队列、空闲链表 或 边界标记法(Boundary Tag Method)。
- 大块内存分配:对于较大的内存块,分配器通常通过与操作系统的交互(如
brk
或mmap
)来请求更大块的内存。 - 合并空闲块:当内存被释放时,内存分配器会尝试合并相邻的空闲块,以减少内存碎片。
4、内存碎片
- 内存碎片是指由于不断分配和释放内存导致的内存块之间的空隙。它分为两种类型:
- 外部碎片:指堆内存中空闲区域的分布不连续,导致尽管总空闲内存足够,但不能提供大块连续的内存。
- 内部碎片:由于内存分配器分配的内存块大小通常是固定的(如 4KB、8KB 等),有时可能分配的内存比实际使用的更多,从而浪费了一部分内存。
为了减少碎片,现代内存分配器采用了各种优化技术,如合并空闲块、按需扩展堆等。
5、malloc
与操作系统的交互
malloc
不直接向操作系统请求每一次内存分配,而是通过分配器内部的内存池来管理分配的内存。在内存池不够用时,它才会向操作系统请求更多内存。
brk
/sbrk
:这是传统的系统调用,用于增加进程的堆内存。当内存池满时,malloc
会通过这些系统调用请求更多的内存。mmap
:用于请求大块内存,通常在需要分配较大的内存块时使用。mmap
调用比brk
更加灵活,并且可以映射文件或匿名内存。
6、malloc
的优化
- 内存池(Memory Pool):分配器通常维护内存池以避免每次调用
malloc
都向操作系统请求内存,减少系统调用的开销。 - 快速分配算法:一些分配器使用 快速分配算法,例如通过预先划分的多个大小的块(例如小于 16B、大于 16B 和小于 1KB的块)来提高分配和释放的效率。
- 延迟回收:一些分配器使用 延迟回收 策略,将已经释放的内存块暂时保留在内存池中,以便未来能更快地重复使用。
二、malloc简介
malloc函数使C/C++中常用内存分配库函数,使用malloc时,需包含头文件<stdlib.h>,函数原型如下
void* malloc(size_t size);
- 功能:分配长度为size的内存块,一般为系统堆上的可用内存上找到一块长度大于size的连续内存空间。如果分配成功,则返回指向分配内存的指针,否则返回空指针NULL。
- 返回值:类型为void *,表示未确定类型指针,它可以强制转换为任意其他类型的指针
- 当内存不再使用时,应用free函数将内存块释放。将之前malloc分配的空间还给操作系统,释放传入指针指向的那块内存区域。指针本身的数值没有变,释放后,指向的内容是垃圾内容,所以最好将这块内存的指针再指向NULL,防止后面的程序误用。
而对于进程的堆,并不是直接建立在Linux的内核的内存分配策略上的,而是建立在glibc的堆管理策略上的(也就是glibc的动态内存分配策略上),堆的管理是由glibc进行的。所以我们调用free对malloc得到的内存进行释放的时候,并不是直接释放给操作系统,而是还给了glibc的堆管理实体,而glibc会在把实际的物理内存归还给系统的策略上做一些优化,以便优化用户任务的动态内存分配过程。
malloc的调用规律(该过程可以通过系统调用接口strace命令跟踪)
- 即分配一块小型内存(小于或等于128kb),malloc()会调用brk()调高断点(brk是将数据段(.data)的最高地址指针_edata往高地址推),分配的内存在堆区域。
- 当分配一块大型内存(大于128kb),malloc()会调用mmap2()分配一块内存(mmap是在进程的虚拟地址空间中(一般是堆和栈中间)找一块空闲的空间。
小于128K的堆内存分配方式
- stack的内存地址是向下增长的,heap的内存地址是向上增长。
- glibc对于heap内存申请大于128k的内存申请,glibc采用mmap的方式向内核申请内存,这不能保证内存地址向上增长;小于128k的则采用brk,对于它来讲是正确的。128k的阀值,可以通过glibc的库函数进行设置。
2.1、brk
- 1,进程启动的时候,其(虚拟)内存空间的初始布局。
- 2,进程调用A=malloc(30K)以后,,将_edata指针往高地址推30K,就完成虚拟内存分配,内存空间如图:
- 3,进程调用 free© 以后,如下图所示,C对应的虚拟内存和物理内存都没有释放,因为只有一个 _edata 指针,如果往回推,那么 C 这块内存怎么办呢?当然,B 这块内存是可以重用的,如果这个时候再来一个 30K 的请求,那么 malloc 很可能就将 B 这块内存返回的。
- 4,进程调用 free(A) 以后,如下图所示,C 和 A 连接起来变成一块70K 的空闲内存。当最高地址空间的空闲内存超过128K(可由 M_TRIM_THRESHOLD 选项调节)时,执行内存紧缩操作(trim)。在上一个步骤 free 的时候,发现最高地址空闲内存超过128K,于是内存紧缩,如下图所示:
事实是:_edata+30K只是完成虚拟地址的分配,A这块内存现在还是没有物理页与之对应的,等到进程第一次读写A这块内存的时候,发生缺页中断,这个时候,内核才分配A这块内存对应的物理页。也就是说,如果用malloc分配了A这块内容,然后从来不访问它,那么,A对应的物理页是不会被分配的。
同时brk 分配的内存需要等到高地址内存释放以后才能释放(例如,在 C 释放之前,A 是不可能释放的,这就是内存碎片产生的原因),而 mmap 分配的内存可以单独释放。
2.2、mmap
使用mmap分配内存。在堆和栈之间找一块空闲内存分配(对应独立内存,而且初始化为0)
- 进程调用B=malloc(200K)以后,内存空间如图4 ,默认情况下,malloc函数分配内存,如果请求内存大于128K(可由M_MMAP_THRESHOLD选项调节),那就不是去推_edata指针了,而是利用mmap系统调用,从堆和栈的中间分配一块虚拟内存。
- 进程调用free(B)以后,B对应的虚拟内存和物理内存一起释放
默认情况下,malloc函数分配内存,如果请求内存大于128K(可由M_MMAP_THRESHOLD选项调节),那就不是去推_edata指针了,而是利用mmap系统调用,从堆和栈的中间分配一块虚拟内存。
brk分配的内存需要等到高地址内存释放以后才能释放(例如,在B释放之前,A是不可能释放的,因为只有一个_edata 指针,这就是内存碎片产生的原因,什么时候紧缩看下面),而mmap分配的内存可以单独释放。
三、源码分析
malloc用于用户空间堆扩展的函数接口。该函数是C库,属于封装了相关系统调用(brk())的glibc库函数。而不是系统调用(系统可没有sys_malloc()。如果谈及malloc函数涉及的系统内核的那些操作,那么总体可以分为用户空间层面和内核空间层面,主要是讲解内核空间。
malloc和free是在用户层工作的,该接口为用户提供一个比较方便管理堆的接口。它的主要工作是维护一个空闲的堆空间缓冲区链表。该缓冲区可以用如下数据结构表述,详细的过程见glibc源码:
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
每当用户进程调用malloc,首先会在该堆缓冲区寻找足够大小的内存块分配给进程。如果从缓冲区无法找到满足需求的内存块时,那么malloc就会根据相应的内存大小调用系统调用brk或者mmap。我们以sys_brk为例,这个时候,才真正的进入到内核空间。
在32位linux内核中,每个用户进程拥有3GB的虚拟地址空间,那么内核如何为用户空间划分这3GB的虚拟地址空间呢?由上一章讲解,用户进程的可执行文件由text、data、bss段组成。如下图:
- 用户进程的用户栈从3GB的虚拟空间的顶部开始,由顶向下延伸
- brk分配的空间是从数据段的顶部end_data到用户栈的地步,所以动态分配空间是从用户栈的地步。
看看内核关于brk的代码实现,其主要的实现流程图如下图所示,最终是调用了do_brk来实现内存的分配:
由于这个函数既可以用来分配空间,即把动态分配区地步的边界往上推;也可以用来释放,即归还空间。因此,它的代码也大致可以分为两部分。首先是第一部分:收缩数据区,伸长操作。分为两种情况来分析。对于第一部分,内存映射一起学习,这部分主要是第二部分do_brk :
static unsigned long do_brk(unsigned long addr, unsigned long len)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma, *prev;
unsigned long flags;
struct rb_node **rb_link, *rb_parent;
pgoff_t pgoff = addr >> PAGE_SHIFT;
int error;
len = PAGE_ALIGN(len);
if (!len)
return addr;
flags = VM_DATA_DEFAULT_FLAGS | VM_ACCOUNT | mm->def_flags;
error = get_unmapped_area(NULL, addr, len, 0, MAP_FIXED);
if (offset_in_page(error))
return error;
error = mlock_future_check(mm, mm->def_flags, len);
if (error)
return error;
/*
* mm->mmap_sem is required to protect against another thread
* changing the mappings in case we sleep.
*/
verify_mm_writelocked(mm);
/*
* Clear old maps. this also does some error checking for us
*/
while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
&rb_parent)) {
if (do_munmap(mm, addr, len))
return -ENOMEM;
}
/* Check against address space limits *after* clearing old maps... */
if (!may_expand_vm(mm, len >> PAGE_SHIFT))
return -ENOMEM;
if (mm->map_count > sysctl_max_map_count)
return -ENOMEM;
if (security_vm_enough_memory_mm(mm, len >> PAGE_SHIFT))
return -ENOMEM;
/* Can we just expand an old private anonymous mapping? */
vma = vma_merge(mm, prev, addr, addr + len, flags,
NULL, NULL, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma)
goto out;
/*
* create a vma struct for an anonymous mapping
*/
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma) {
vm_unacct_memory(len >> PAGE_SHIFT);
return -ENOMEM;
}
INIT_LIST_HEAD(&vma->anon_vma_chain);
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_pgoff = pgoff;
vma->vm_flags = flags;
vma->vm_page_prot = vm_get_page_prot(flags);
vma_link(mm, vma, prev, rb_link, rb_parent);
out:
perf_event_mmap(vma);
mm->total_vm += len >> PAGE_SHIFT;
if (flags & VM_LOCKED)
mm->locked_vm += (len >> PAGE_SHIFT);
vma->vm_flags |= VM_SOFTDIRTY;
return addr;
}
do_brk()有两个参数,addr是要开展的目标区域的开始地址,len是目标区域的长度,其实质也应该是处理vm_erea,目标是在进程空间中有一个匿名vm_erea能映射到[addr, len)这段区域中。下面是代码主要的分析
- 1.**get_unmapped_area()**函数在当前进程的用户空间中查找一个符合len大小的线性地址区域。PAGE_MASK的值为0xFFFFF000,因此,如果 (error & ~PAGE_MASK)为非0,说明addr最低12位非0,addr就不是一个有效的地址,就以这个地址作为返回值;否则,addr就是一个有效的地址(最低12位为0)
- 2.通过**find_vma_links()**在当前进程的所有线性区组成的红黑树中依次遍历每个vma,以确定上一步找到的新区域之前线性区对象位置。如果addr位于某个现存的vma中,则调用do_munmap删去这个线性区。主要是找到合适的VMA的插入点。
- 3.经过2,已经找到了一个合适大小的空闲线性区,接下来通过vma_merge去试着将当前的线性区与临近的线性区进行合并,确认这个新节点是否能够与现有树中的节点进行合并,如果地址是连续的,就能够合并,则不用创建新的vm_eara_struct了,直接跳出out,更新统计值即可。
struct vm_area_struct *vma_merge(struct mm_struct *mm,
struct vm_area_struct *prev, unsigned long addr,
unsigned long end, unsigned long vm_flags,
struct anon_vma *anon_vma, struct file *file,
pgoff_t pgoff, struct mempolicy *policy,
struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
mm:描述添加新区域进程的内存空间
prev:指向当前区域之前的一个内存区
addr:表示新区域的起始地址
end:表示新区域的结束地址
vm_flag:表示该区域的标志,如果该新区域映射了一个磁盘文件,则file结构表示该文件
pgoff:表示文件映射的偏移量
- 4.如果不能合并,则通过kmem_cache_zalloc创建新的vm_erea_struct,加到anon_vma_chain链表中,也将新创建的VMA加入到mm->mmap链表和红黑树中
到这里malloc就结束了,从以上可看出malloc在完成调用后并没有立马分配物理内存,而是在需要在进程需要访问此虚拟内存块时才会产生缺页中断,才会分配物理内存,并建立虚拟内存到物理内存的映射。以上情况除了当分配flag带有VM_MLOCK时需要立即调用mm_populate来分配物理内存,其实现为mm_populate,其代码实现如下:
mm/gup.c
int __mm_populate(unsigned long start, unsigned long len, int ignore_errors)
{
struct mm_struct *mm = current->mm;
unsigned long end, nstart, nend;
struct vm_area_struct *vma = NULL;
int locked = 0;
long ret = 0;
end = start + len;
for (nstart = start; nstart < end; nstart = nend) {
/*
* We want to fault in pages for [nstart; end) address range.
* Find first corresponding VMA.
*/
if (!locked) {
locked = 1;
mmap_read_lock(mm);
vma = find_vma_intersection(mm, nstart, end);
} else if (nstart >= vma->vm_end)
vma = find_vma_intersection(mm, vma->vm_end, end);
if (!vma)
break;
/*
* Set [nstart; nend) to intersection of desired address
* range with the first VMA. Also, skip undesirable VMA types.
*/
nend = min(end, vma->vm_end);
if (vma->vm_flags & (VM_IO | VM_PFNMAP))
continue;
if (nstart < vma->vm_start)
nstart = vma->vm_start;
/*
* Now fault in a range of pages. populate_vma_page_range()
* double checks the vma flags, so that it won't mlock pages
* if the vma was already munlocked.
*/
ret = populate_vma_page_range(vma, nstart, nend, &locked);
if (ret < 0) {
if (ignore_errors) {
ret = 0;
continue; /* continue at next VMA */
}
break;
}
nend = nstart + ret * PAGE_SIZE;
ret = 0;
}
if (locked)
mmap_read_unlock(mm);
return ret; /* 0 or negative error code */
}
- 1.以start为起始地址,先通过find_vma()查找VMA,如果没有找到VMA,则退出循环
- 2.调用populate_vma_page_range为VMA分配物理内存,最终会调用__get_user_pages为进程地址空间分配物理内存并且建立映射关系。
至此,已经为这块进程地址空间VMA分配了物理页面,并建立了映射关系。对于malloc函数使为用户空间分配进程地址空间,其实现流程如下:
至此上面过程,malloc返回了一个线性地址,如果此时用户地址访问这个线性地址,那么就会发生缺页异常,内核才会真正的为虚拟地址分配实际的物理内存。所以实际在用户空间,如果我们通过malloc(4096),申请4k的地址空间,当我们不去使用的时候,是不会申请到实际的物理地址。其分配流程如下图:
四、总结
malloc() 是 C 标准库提供的内存分配函数,对应到系统调用上,有两种实现方式,即 brk() 和 mmap()。
特性 | brk() | mmap() |
---|---|---|
功能 | 调整进程的数据段(堆)的结束位置,从而扩展或收缩堆的大小。 | 映射文件或匿名内存到进程的地址空间,可以分配大块内存。 |
适用场景 | 适合分配小到中等大小的内存,尤其是连续内存。 | 适合分配大块内存(如几 MB 以上),以及映射文件或共享内存。 |
内存分配方式 | 增加或减少数据段(堆)的大小。 | 通过内存映射文件或匿名内存区域直接分配内存。 |
内存分配的连续性 | 提供连续的内存块。 | 提供连续或非连续的内存块,取决于映射方式。 |
性能 | 分配内存较为快速,但仅限于堆内存的连续分配。 | 对于大块内存,mmap() 性能更好,尤其是在内存映射大文件时。 |
内存回收方式 | 通过 brk() 调整堆的结束位置,无法直接回收小块内存。 | 通过 munmap() 显式回收映射的内存块。 |
灵活性 | 功能较为简单,仅支持堆内存的扩展。 | 提供更多功能,包括映射文件、共享内存和匿名内存。 |
最大内存大小 | 受限于系统对进程数据段的大小限制。 | 可以映射非常大的内存区域,通常没有太大限制。 |
内存碎片 | 堆内存可能会产生碎片。 | 内存碎片化较少,尤其是通过 mmap() 映射文件时。 |
线程安全 | 默认不具备线程安全,可能需要外部同步。 | mmap() 适合多线程环境,系统会在多个进程或线程中共享内存映射。 |
操作系统支持 | 主要由传统的 UNIX 系统(如 Linux)支持,逐渐被淘汰。 | 被现代操作系统广泛支持,尤其适合大规模的内存分配和内存映射文件。 |
使用限制 | 只能扩展堆的大小,无法进行复杂的内存映射操作。 | 可以映射多个区域,支持共享内存和文件映射,灵活性高。 |
1. brk()
的优缺点
-
优点:
- 实现简单,适合分配较小的内存。
- 内存分配和释放速度较快(因为只是简单地调整数据段大小)。
-
缺点:
- 只能用来扩展堆,且内存必须是连续的,适应性差。
- 难以应对大内存需求或者复杂的内存管理场景(如共享内存、内存映射文件等)。
- 在内存碎片化严重时,性能下降明显。
2. mmap()
的优缺点
-
优点:
- 支持大块内存的映射,适应大规模内存分配需求。
- 可以映射文件,支持文件与内存的共享,适用于大文件的操作。
- 内存映射不受连续内存的限制,避免了内存碎片化。
- 具有灵活性,能够映射匿名内存、共享内存、文件等。
-
缺点:
- 较为复杂,操作较
brk()
慢一些,尤其是在小内存分配时。 - 内存映射的管理相对繁琐,可能会涉及额外的系统调用(如
munmap()
)。 - 对系统调用的依赖较大,可能需要额外的内存管理工作。
- 较为复杂,操作较
3. 应用场景
-
brk()
:- 适用于小型程序或应用,特别是传统的堆内存分配需求。
- 内存需求较为固定且可预见的情况。
-
mmap()
:- 适用于大规模内存分配、高性能计算以及需要映射文件的场景。
- 适合多进程共享内存或需要动态扩展内存的应用(例如数据库、大文件操作等)。
进程向 OS 申请和释放地址空间的接口 sbrk/mmap/munmap 都是系统调用,频繁调用系统调用都比较消耗系统资源的。并且, mmap 申请的内存被 munmap 后,重新申请会产生更多的缺页中断。例如使用 mmap 分配 1M 空间,第一次调用产生了大量缺页中断 (1M/4K 次 ) ,当munmap 后再次分配 1M 空间,会再次产生大量缺页中断。缺页中断是内核行为,会导致内核态CPU消耗较大。另外,如果使用 mmap 分配小内存,会导致地址空间的分片更多,内核的管理负担更大。
同时堆是一个连续空间,并且堆内碎片由于没有归还 OS ,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低 CPU 的消耗。 因此, glibc 的 malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128k) 才使用 mmap 获得地址空间,也可通过 mallopt(M_MMAP_THRESHOLD, ) 来修改这个临界值。