4张图搞懂堆和栈的区别

1. 堆和栈的基本概念

今天我们来聊聊堆栈,堆栈是软件开发人员绕不过的一个概念。

堆栈指的是堆和栈两种内存区域

很多读者喜欢从数据结构的角度来学习堆栈,这种方式并不会让大家真正理解堆栈。

1.1. 进程地址空间

进程地址空间可以分为两种类型:用户空间和内核空间。

用户空间是操作系统为每个进程分配的私有地址空间,是进程可以直接访问的内存区域,如图 1 所示。

图片

图 1:进程地址空间

以 32 位系统为例,用户空间被划分为以下几个区域:

  • 代码段(Text Segment):存放程序的指令代码,是只读区域。
  • 数据段(Data Segment):存储程序中已初始化的全局变量和静态变量,是可读写的区域,用于存放程序运行时的数据。
  • BSS 段(Block Started by Symbol Segment):存放未初始化的全局变量和静态变量,程序启动时由操作系统初始化为零。
  • 堆(Heap):用于动态内存分配,程序运行时通过 mallocnew 等函数分配内存,大小可变,用于存储动态数据。
  • 栈(Stack):用于存储函数调用时的局部变量、函数参数和返回地址等信息,大小有限,遵循后进先出(LIFO)原则。

虽然进程地址空间被划分为不同的区域,但是这些区域并没有本质的区别,它们只是一块用于存储数据或代码的内存区域而已。

就像我们买了一个房子,我们会对每个房间的功能进行划分,以此来满足我们的生活需求,但是每个房间并没有本质区别。

进程地址空间指的是虚拟地址空间,虚拟地址空间的范围为 0-4G(32 位系统),0-3G(0xC0000000)为用户空间,3G-4G 为内核空间。

虚拟地址需要转换成物理地址才能访问物理内存或者外部设备,转换的方法是页表机制,有兴趣的同学可以看一下我的这篇文章:一张图搞懂 mmap 实现原理(通俗易懂)

1.2. 堆和栈实现原理

关于进程地址空间,有过一定编程经验的同学应该都很了解。

但是,对于大部分读者来说,进程地址空间仅仅只是一个理论模型,本节我们把进程地址空间具象化(从理论模型变为具体实现),让大家彻底搞懂它。

图片

图 2:进程地址空间的内核实现

如图 2 所示,从内核的角度来看,每个用户程序都是一个 task_struct 结构,task_struct 结构表示一个进程或者线程,其定义如下:

struct task_struct {
    /* 1. 进程状态与标识 */
    volatile long           state;  // 进程状态:TASK_RUNNING(运行/就绪), TASK_INTERRUPTIBLE(可中断睡眠) 等
    pid_t                   pid;    // 进程唯一标识符 (PID)
    pid_t                   tgid;   // 线程组 ID(主线程 PID,用于标识线程归属)
    char                    comm[TASK_COMM_LEN]; // 进程名称(如通过 `ps` 查看的命令名)

    /* 2. 调度信息 */
    int                     prio;   // 动态优先级(调度器实际使用的优先级)
    int                     static_prio; // 静态优先级(用户设定的基准值)
    unsigned int            policy; // 调度策略:SCHED_NORMAL(普通), SCHED_FIFO(实时先进先出) 等
    struct sched_info       sched_info; // 调度统计信息(如运行时间片)

    /* 3. 进程关系 */
    struct task_struct __rcu *real_parent; // 原始父进程(创建者)
    struct task_struct __rcu *parent;      // 当前父进程(接收 SIGCHLD 信号)
    struct list_head        children;      // 子进程链表头
    struct list_head        sibling;       // 兄弟进程链表节点(链接到父进程的 children 链表)

    /* 4. 内存与资源 */
    struct mm_struct        *mm;           // 进程内存管理结构(虚拟内存布局、页表等)
    struct fs_struct        *fs;           // 文件系统信息(工作目录、根目录)
    struct files_struct     *files;        // 打开的文件描述符表

    /* 5. 信号处理 */
    sigset_t                pending; // 待处理信号集
    sigset_t                blocked; // 阻塞(屏蔽)信号掩码
    struct signal_struct    *signal;  // 信号处理函数及共享属性(如线程组共享信号)
};

task_struct 结构中有一个 mm 成员(struct mm_struct 结构),这个成员就是用来表示进程地址空间,struct mm_struct 结构体定义如下:

struct mm_struct {
    /* 1. 页表与地址空间 */
    pgd_t *pgd;                 // 页全局目录(Page Global Directory)基地址
    unsigned long task_size;    // 用户空间大小(TASK_SIZE 常量值)
    unsigned long mmap_base;    // 内存映射起始地址(栈/共享库的基准)

    /* 2. 内存布局关键边界 */
    unsigned long start_code, end_code;   // 代码段起始/结束地址
    unsigned long start_data, end_data;   // 数据段起始/结束地址
    unsigned long start_brk, brk;         // 堆起始地址/当前堆顶
    unsigned long start_stack;            // 栈起始地址(向下增长)
};

mm_struct 结构的 start_codeend_code 成员记录了代码段的起始和结束地址,start_dataend_data 成员记录了数据段的起始和结束地址。

start_brkbrk 成员记录了堆的起始地址和当前边界地址(堆顶),start_stack 记录了栈的起始地址(栈底)。

到了这一步,我想读者们对进程地址空间开始有直观的了解了。

说句题外话,每个进程都会通过虚拟地址空间来记录数据和代码片段,对于内核来说,进程就是一个个代码和数据的集合。

进程的调度就是 CPU 选择不同的代码和数据来运行的过程。

本文的主角是堆和栈,我们把注意力放在堆和栈上面。

我们先来聊聊栈,start_stack 记录栈的起始地址(栈底),即栈空间的最高地址(栈从高地址向低地址增长)。

只知道栈的起始地址还不够,我们还要知道栈的大小,这样才能完整描述一个栈空间。

进程栈的大小默认为 8MB,可通过 ulimit -s 命令查看和修改。

我们再来聊聊堆,start_brk 记录堆的起始地址(堆空间的最低地址,堆从低地址向高地址增长),brk 记录堆的当前边界地址(堆顶),堆的实际大小通过 brk - start_brk 来计算。

堆的最大值默认是没有限制的(受物理内存限制),可通过 ulimit -d 命令查看和修改。

由于堆的大小没有限制,所以我们编程时,如果需要申请一大块内存,通常需要从堆空间进行申请(调用 malloc 等函数)。

1.2.1. 为什么堆有一个 brk 成员?

堆的最大值通常是没有限制的或者会被限制为一个比较大的值。

如果堆空间的虚拟地址全部映射为物理地址,那么物理地址可能会被耗尽。

然而这仅仅只是一个进程的堆空间地址映射,如果系统中每个进程都进行堆空间地址映射,这将是一场灾难。

为了避免出现这样的问题,内核通过 brk 成员来动态指定堆的实际大小,即按需进行堆空间地址映射。

当用户程序使用的堆内存超过 brk 时,调高 brk 值进行扩容,当回收了大块堆内存时,调低 brk 进行缩容。

1.3. 正确使用堆

相较于堆,栈的使用非常简单,因为栈不需要用户程序手动管理,系统会自动帮我们管理栈内存的申请和释放。

需要注意的是,用户程序不能进行一些违规的操作,如:创建一个超大的局部数组或者进行无限递归调用,这样会导致栈溢出等问题。

堆的使用比较复杂,用户程序除了需要动态申请和释放堆内存,还要进行动态扩容和缩容。

1.3.1. 堆内存动态扩容和缩容

Linux 系统通过系统调用或 glibc 提供的接口来实现堆内存动态扩容和缩容。

扩容和缩容的本质是修改 struct mm_struct 结构的 brk 成员的值。

1.3.1.1. brk 系统调用

brk 系统调用定义如下:

#include <sys/syscall.h>
syscall(SYS_brk, addr);

参数说明:addrunsigned long 类型,表示堆当前边界值,分为以下几种情况:

  • 扩展堆:当 addr > 当前堆顶时,向上移动 brk 指针,分配新内存,调用成功后返回更新后的堆当前边界值。
  • 收缩堆:当 addr < 当前堆顶时,向下移动 brk 指针,释放堆顶内存,调用成功后返回更新后的堆当前边界值。
  • 查询边界brk(0) 返回堆当前边界值。

具体实现原理如图 3 所示。

图片

图 3:brk 系统调用实现原理

brk 系统调用示例代码如下:

// 获取当前堆的结束地址
void* current_brk = (void*) syscall(SYS_brk, 0);
printf("current heap end: %p\n", current_brk);

// 尝试将堆的大小增加 1024 字节
void* new_brk = (void*) syscall(SYS_brk, (unsigned long)current_brk + 1024);
printf("new heap end: %p\n", new_brk);

// 尝试将堆的大小减少 512 字节
new_brk = (void*) syscall(SYS_brk, (unsigned long)new_brk - 512);
printf("new heap end: %p\n", new_brk);

1.3.1.2. glibc brk 函数

需要注意的是,glibc 也定义了一个 brk 函数(非系统调用),函数原型如下:

#include <unistd.h>
int brk(void *addr);

参数说明:addrvoid * 类型,分为以下几种情况:

  • 扩展堆:当 addr > 当前堆顶时,向上移动 brk 指针,分配新内存。
  • 收缩堆:当 addr < 当前堆顶时,向下移动 brk 指针,释放堆顶内存。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errnoENOMEM,表示内存不足或请求不合理。

注意:glibc 定义的 brk 函数传入参数 0,并不会返回堆当前边界值。

glibc brk 函数示例代码如下:

// 获取当前堆的结束地址
void* current_brk = sbrk(0);
printf("current heap end: %p\n", current_brk);

// 尝试将堆的结束地址移动 1024 字节
brk(current_brk + 1024);

// 再次获取当前堆的结束地址
void* new_brk = sbrk(0);
printf("new heap end: %p\n", new_brk);

// 尝试将堆的结束地址减少 512 字节
brk(new_brk - 512);

// 再次获取当前堆的结束地址
void* final_brk = sbrk(0);
printf("final heap end: %p\n", final_brk);

1.3.1.3. glibc sbrk 函数

为了更方便地设置堆的大小,glibc 还定义了一个 sbrk 函数,sbrk 函数原型如下:

#include <unistd.h>
void* sbrk(intptr_t increment);

参数说明:increment 指定堆大小的变化量。

  • increment 为正数,表示增加堆的大小。
  • increment 为负数,表示减少堆的大小。
  • increment 为 0,返回堆当前边界值。

返回值:

  • 成功:返回调整后的堆当前边界值。
  • 失败:返回 (void*) -1,并设置 errno

sbrk 函数示例代码如下:

// 获取当前堆的结束地址
void* current_brk = sbrk(0);
printf("current heap end: %p\n", current_brk);

// 尝试将堆的大小增加 1024 字节
void* new_brk = sbrk(1024);
printf("new heap end: %p\n", new_brk);

// 尝试将堆的大小减少 512 字节
new_brk = sbrk(-512);
printf("new heap end: %p\n", new_brk);

1.3.2. 聊聊 ptmalloc

前面我们提到过,堆内存的使用并不简单,用户程序如果直接操作堆内存(调用 brksbrk 函数)会存在一些潜在问题:

  • 内存泄漏:手动分配后未释放内存,导致内存占用持续增长。
  • 内存碎片:频繁分配/释放不同大小内存,产生不可用的小块碎片空间。
  • 线程不安全:多线程并发操作堆内存时,竞争引发数据损坏或崩溃。
  • 越界访问:读写超出分配区域(如数组越界),破坏相邻数据结构。

为了更好地使用堆内存,我们需要通过 ptmalloc 来管理堆内存。

ptmalloc 是一个基于 glibc 的动态内存分配器,用于实现 mallocfree 和相关函数。

图片

图 4:ptmalloc 简介

如图 4 所示,用户程序通过系统调用和 ptmalloc 两种方式操作的是同一块堆内存。

如果二者混用,将会破坏 ptmalloc 内存池结构,导致内存错误(非法访问)。

直接使用堆内存也存在种种弊端,所以最好的方式是通过动态内存分配器(如:ptmalloctcmalloc 等)来管理堆内存。

1.4. 总结

本文我们花了比较多的篇幅来讲解堆栈,相信读者们看完后一定会很有收获,有问题请评论区留言。